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

keplergl / kepler.gl / 24111533945

08 Apr 2026 12:42AM UTC coverage: 59.739% (-0.1%) from 59.866%
24111533945

Pull #3368

github

web-flow
Merge de5a0ec2c into d5c99cef7
Pull Request #3368: fix: fixes for effects

6530 of 13032 branches covered (50.11%)

Branch coverage included in aggregate %.

18 of 79 new or added lines in 4 files covered. (22.78%)

4 existing lines in 2 files now uncovered.

13338 of 20226 relevant lines covered (65.94%)

78.14 hits per line

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

9.39
/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 {Tiles3DLoader, CesiumIonLoader} from '@loaders.gl/3d-tiles';
6
import {I3SLoader} from '@loaders.gl/i3s';
7
import {Tileset3D, Tile3D} from '@loaders.gl/tiles';
8

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

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

26
/**
27
 * Custom DeckTile3DLayer that catches exceptions in _loadTileset and
28
 * _updateTileset (which calls selectTiles – an unhandled-promise path
29
 * where errors like "boundingVolume must contain …" escape).
30
 * See: https://github.com/visgl/deck.gl/issues/8755
31
 */
32
// @ts-expect-error Types have separate declarations of a private property '_loadTileset'.
33
class KeplerTile3DLayer extends DeckTile3DLayer {
34
  // @ts-ignore override of private method called by deck.gl internals
35
  private async _loadTileset(tilesetUrl: string) {
36
    try {
×
37
      // @ts-expect-error _loadTileset is private in DeckTile3DLayer
38
      await super._loadTileset(tilesetUrl);
×
39
    } catch (error: any) {
40
      if (error?.message?.includes("reading 'refine'")) {
×
41
        this.raiseError(new Error('Bad tileset format or invalid API key'), '_loadTileset');
×
42
      } else {
43
        console.error('Tile3DLayer: tileset load error', error);
×
44
      }
45
    }
46
  }
47

48
  /**
49
   * Override _updateTileset to catch errors from selectTiles(),
50
   * which deck.gl calls with .then() but no .catch().
51
   * Errors from tilesetInitializationPromise (e.g. invalid boundingVolume
52
   * in child tiles) surface here as unhandled promise rejections.
53
   */
54
  // @ts-ignore override of private method
55
  private _updateTileset(viewports: Record<string, any> | null): void {
56
    if (!viewports) return;
×
57
    const {tileset3d} = this.state as any;
×
58
    const {timeline} = this.context;
×
59
    const viewportsNumber = Object.keys(viewports).length;
×
60
    if (!timeline || !viewportsNumber || !tileset3d) return;
×
61

62
    tileset3d
×
63
      .selectTiles(Object.values(viewports))
64
      .then((frameNumber: number) => {
65
        const tilesetChanged = (this.state as any).frameNumber !== frameNumber;
×
66
        if (tilesetChanged) {
×
67
          this.setState({frameNumber});
×
68
        }
69
      })
70
      .catch((error: any) => {
71
        console.error('Tile3DLayer: selectTiles error', error);
×
72
      });
73
  }
74

75
  // deck.gl's ScenegraphLayer and MeshLayer do not correctly handle
76
  // defaultShaderModule changes (extensionsChanged flag):
77
  //
78
  // ScenegraphLayer (Google/Cesium 3D tiles): models are created by
79
  // luma.gl's GLTF pipeline with shader modules baked in.
80
  // updateState only recreates models when the scenegraph *prop*
81
  // changes, completely ignoring extensionsChanged. When the shadow
82
  // module is added/removed, existing models keep their old shaders.
83
  //
84
  // MeshLayer (ArcGIS/I3S): getModel() creates the GPU model with PBR
85
  // shader defines (HAS_BASECOLORMAP etc.) but does not set the
86
  // corresponding texture bindings (pbr_baseColorSampler etc.).
87
  // updateState only calls updatePbrMaterialUniforms when pbrMaterial
88
  // *prop* changes, not when the model is recreated via
89
  // extensionsChanged. luma.gl skips rendering ("Binding … not found").
90
  //
91
  // Fix: override getSubLayerClass to return patched sublayer classes.
92
  // @ts-ignore protected override
93
  protected getSubLayerClass(id: string, DefaultClass: any): any {
NEW
94
    if (id === 'mesh') {
×
NEW
95
      if (!_PatchedMeshLayer) {
×
NEW
96
        _PatchedMeshLayer = class extends DefaultClass {
×
97
          updateState(params) {
NEW
98
            super.updateState(params);
×
NEW
99
            if (params.changeFlags.extensionsChanged && this.state?.model) {
×
NEW
100
              this.updatePbrMaterialUniforms(this.props.pbrMaterial);
×
101
            }
102
          }
103
        };
NEW
104
        (_PatchedMeshLayer as any).layerName = DefaultClass.layerName || 'MeshLayer';
×
NEW
105
        (_PatchedMeshLayer as any).defaultProps = DefaultClass.defaultProps;
×
106
      }
NEW
107
      return _PatchedMeshLayer;
×
108
    }
NEW
109
    if (id === 'scenegraph') {
×
NEW
110
      if (!_PatchedScenegraphLayer) {
×
NEW
111
        _PatchedScenegraphLayer = class extends DefaultClass {
×
112
          updateState(params) {
NEW
113
            super.updateState(params);
×
NEW
114
            if (params.changeFlags.extensionsChanged && this.state?.scenegraph) {
×
NEW
115
              this._updateScenegraph();
×
116
            }
117
          }
118
        };
NEW
119
        (_PatchedScenegraphLayer as any).layerName = DefaultClass.layerName || 'ScenegraphLayer';
×
NEW
120
        (_PatchedScenegraphLayer as any).defaultProps = DefaultClass.defaultProps;
×
121
      }
NEW
122
      return _PatchedScenegraphLayer;
×
123
    }
NEW
124
    return super.getSubLayerClass(id, DefaultClass);
×
125
  }
126
}
127
(KeplerTile3DLayer as any).layerName = 'KeplerTile3DLayer';
13✔
128

129
export const TILE3D_LAYER_TYPE = LAYER_TYPES.tile3d;
13✔
130

131
export type Tile3DLayerVisConfigSettings = {
132
  opacity: VisConfigNumber;
133
  pointSize: VisConfigNumber;
134
};
135

136
export type Tile3DLayerVisConfig = {
137
  opacity: number;
138
  pointSize: number;
139
};
140

141
export const tile3DVisConfigs = {
13✔
142
  opacity: {
143
    ...LAYER_VIS_CONFIGS.opacity,
144
    defaultValue: 1,
145
    property: 'opacity'
146
  } as VisConfigNumber,
147
  pointSize: {
148
    type: 'number' as const,
149
    defaultValue: 2,
150
    label: 'layerVisConfigs.pointSize',
151
    isRanged: false,
152
    range: [0.5, 20],
153
    step: 0.5,
154
    group: 'display' as const,
155
    property: 'pointSize',
156
    allowCustomValue: false
157
  } as VisConfigNumber
158
};
159

160
const EMPTY_EXTENSIONS: any[] = [];
13✔
161
const DEPTH_TEST_PARAMS = {depthTest: true};
13✔
162

163
function getTile3DProviderFromUrl(url = ''): (typeof TILE3D_PROVIDERS)[string] | null {
×
164
  for (const key of Object.keys(TILE3D_PROVIDERS)) {
×
165
    if (url.includes(TILE3D_PROVIDERS[key].urlKey)) {
×
166
      return TILE3D_PROVIDERS[key];
×
167
    }
168
  }
169
  return null;
×
170
}
171

172
export default class Tile3DLayer extends Layer {
173
  declare visConfigSettings: Tile3DLayerVisConfigSettings;
174

175
  private _cachedDataAndLoader: {
176
    data: string;
177
    loadOptions?: Record<string, unknown>;
178
    loader?: any;
179
    _cacheKey?: string;
180
  } | null = null;
27✔
181

182
  private _layerCallbacks: BindedLayerCallbacks | null = null;
27✔
183
  private _hasFittedBounds = false;
27✔
184

185
  constructor(props: {dataId: string} & Record<string, any>) {
186
    super(props);
27✔
187
    this.registerVisConfig(tile3DVisConfigs);
27✔
188
    this.meta = {};
27✔
189
  }
190

191
  static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue {
192
    if (dataset.type !== DatasetType.TILE_3D) {
90!
193
      return {props: []};
90✔
194
    }
195

196
    return {
×
197
      props: [
198
        {
199
          label: dataset.label,
200
          isVisible: true,
201
          color: [255, 255, 255] as [number, number, number]
202
        }
203
      ]
204
    };
205
  }
206

207
  get type(): string {
208
    return TILE3D_LAYER_TYPE;
×
209
  }
210

211
  get name(): string {
212
    return '3D Tile';
27✔
213
  }
214

215
  get requireData(): boolean {
216
    return false;
27✔
217
  }
218

219
  get requiredLayerColumns(): string[] {
220
    return [];
27✔
221
  }
222

223
  get layerIcon(): typeof Tile3DLayerIcon {
224
    return Tile3DLayerIcon;
27✔
225
  }
226

227
  get supportedDatasetTypes(): DatasetType[] {
228
    return [DatasetType.TILE_3D];
×
229
  }
230

231
  get visualChannels() {
232
    return {};
×
233
  }
234

235
  shouldRenderLayer(): boolean {
236
    return Boolean(this.type && this.config.isVisible);
×
237
  }
238

239
  getHoverData(): any {
240
    return null;
×
241
  }
242

243
  formatLayerData(datasets: KeplerDatasets): Record<string, any> {
244
    const {dataId} = this.config;
×
245
    if (!dataId || !datasets[dataId]) {
×
246
      return {};
×
247
    }
248

249
    const dataset = datasets[dataId];
×
250
    const metadata = (dataset.metadata || {}) as Tile3DDatasetMetadata;
×
251

252
    return {
×
253
      tile3dUrl: metadata.tile3dUrl,
254
      tile3dAccessToken: metadata.tile3dAccessToken
255
    };
256
  }
257

258
  updateLayerMeta(dataset: KeplerDataset): void {
259
    if (dataset.type !== DatasetType.TILE_3D) {
×
260
      return;
×
261
    }
262
    const metadata = (dataset.metadata || {}) as Tile3DDatasetMetadata;
×
263
    const provider = getTile3DProviderFromUrl(metadata.tile3dUrl);
×
264
    this.updateMeta({provider});
×
265
  }
266

267
  _getDataAndLoaderOptions(
268
    tileUrl: string,
269
    accessToken?: string
270
  ): {data: string; loadOptions?: Record<string, unknown>; loader?: any} {
271
    const cacheKey = `${tileUrl}::${accessToken || ''}`;
×
272
    if (this._cachedDataAndLoader && this._cachedDataAndLoader._cacheKey === cacheKey) {
×
273
      return this._cachedDataAndLoader;
×
274
    }
275

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

279
    if (provider === TILE3D_PROVIDERS.google) {
×
280
      const separator = tileUrl.includes('?') ? '&' : '?';
×
281
      result = {
×
282
        data: `${tileUrl}${separator}key=${accessToken || ''}`,
×
283
        loader: Tiles3DLoader,
284
        loadOptions: {
285
          fetch: {headers: {'X-GOOG-API-KEY': accessToken || ''}}
×
286
        }
287
      };
288
    } else if (provider === TILE3D_PROVIDERS.cesium) {
×
289
      result = {
×
290
        data: tileUrl,
291
        loader: CesiumIonLoader,
292
        loadOptions: {
293
          'cesium-ion': {accessToken}
294
        }
295
      };
296
    } else if (provider === TILE3D_PROVIDERS.arcgis) {
×
297
      result = {
×
298
        data: tileUrl,
299
        loader: I3SLoader,
300
        loadOptions: {
301
          i3s: {useCompressedTextures: false}
302
        }
303
      };
304
    } else {
305
      result = {
×
306
        data: tileUrl,
307
        loader: Tiles3DLoader
308
      };
309
    }
310

311
    this._cachedDataAndLoader = {...result, _cacheKey: cacheKey};
×
312
    return this._cachedDataAndLoader;
×
313
  }
314

315
  _onTilesetLoad = (tileset3d: Tileset3D): void => {
27✔
316
    this._extractBoundsFromTileset(tileset3d);
×
317

318
    const tileUrl = tileset3d.url || '';
×
319
    const isGoogle = getTile3DProviderFromUrl(tileUrl) === TILE3D_PROVIDERS.google;
×
320

321
    if (!this._hasFittedBounds && this.meta?.bounds && !isGoogle) {
×
322
      this._hasFittedBounds = true;
×
323
      this._layerCallbacks?.onFitBounds?.(this.meta.bounds);
×
324
    }
325

326
    if (isGoogle) {
×
327
      tileset3d.options.onTraversalComplete = selectedTiles => {
×
328
        const credits = new Set<string>();
×
329
        selectedTiles.forEach((tile: Tile3D) => {
×
330
          const {copyright} = (tile as any).content?.gltf?.asset || {};
×
331
          if (copyright) copyright.split(';').forEach(c => credits.add(c));
×
332
        });
333
        const title = Array.from(credits).join('; ');
×
334
        this.updateMeta({
×
335
          googleAttribution: title
336
        });
337
        return selectedTiles;
×
338
      };
339
    }
340
  };
341

342
  _extractBoundsFromTileset(tileset3d: Tileset3D): void {
343
    try {
×
344
      const root = tileset3d.root;
×
345
      if (root) {
×
346
        const bbox = (root as any).boundingBox;
×
347
        if (bbox && bbox.length === 2) {
×
348
          const [min, max] = bbox;
×
349
          // bbox = [[westDeg, southDeg, minHeight], [eastDeg, northDeg, maxHeight]]
350
          this.updateMeta({
×
351
            bounds: [min[0], min[1], max[0], max[1]]
352
          });
353
          return;
×
354
        }
355
      }
356
    } catch {
357
      // boundingBox getter may throw for malformed volumes
358
    }
359

360
    // Fallback: approximate bounds from cartographicCenter and zoom
361
    const center = tileset3d.cartographicCenter;
×
362
    if (center) {
×
363
      const lng = center[0];
×
364
      const lat = center[1];
×
365
      const zoom = tileset3d.zoom || 14;
×
366
      // Approximate half-span in degrees based on zoom level
367
      const span = 180 / Math.pow(2, zoom);
×
368
      this.updateMeta({
×
369
        bounds: [lng - span, lat - span, lng + span, lat + span]
370
      });
371
    }
372
  }
373

374
  _onTileLoad = (tile: Tile3D): void => {
27✔
375
    // I3S materials often lack metallicFactor/roughnessFactor.
376
    // PBR spec defaults metallicFactor to 1 (fully metallic),
377
    // causing black surfaces when lighting/shadow effects are active.
378
    const pbr = tile.content?.material?.pbrMetallicRoughness;
×
379
    if (pbr) {
×
380
      if (pbr.metallicFactor === undefined) {
×
381
        pbr.metallicFactor = 0;
×
382
      }
383
      if (pbr.roughnessFactor === undefined) {
×
384
        pbr.roughnessFactor = 1;
×
385
      }
386
    }
387
  };
388

389
  _onTileUnload = (_tile: Tile3D): void => {
27✔
390
    /* noop */
391
  };
392

393
  renderLayer(opts: any): KeplerTile3DLayer[] {
394
    const {data, layerCallbacks} = opts;
×
395
    const {tile3dUrl, tile3dAccessToken} = data || {};
×
396
    if (!tile3dUrl) {
×
397
      return [];
×
398
    }
399

400
    this._layerCallbacks = layerCallbacks || null;
×
401

402
    const {visConfig} = this.config;
×
403
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
×
404
    const {
405
      data: tileData,
406
      loadOptions,
407
      loader
408
    } = this._getDataAndLoaderOptions(tile3dUrl, tile3dAccessToken);
×
409

410
    const {color} = this.config;
×
411
    const pointColor: [number, number, number, number] = color
×
412
      ? [color[0], color[1], color[2], 255]
413
      : [255, 255, 255, 255];
414

415
    return [
×
416
      new KeplerTile3DLayer({
417
        id: defaultLayerProps.id,
418
        coordinateSystem: defaultLayerProps.coordinateSystem,
419
        wrapLongitude: defaultLayerProps.wrapLongitude,
420
        visible: defaultLayerProps.visible,
421
        data: tileData,
422
        loader,
423
        loadOptions,
424
        onTilesetLoad: this._onTilesetLoad,
425
        onTileLoad: this._onTileLoad,
426
        onTileUnload: this._onTileUnload,
427
        getPointColor: pointColor,
428
        pointSize: visConfig.pointSize ?? 2,
×
429
        pickable: false,
430
        opacity: visConfig.opacity ?? 1,
×
431
        extensions: EMPTY_EXTENSIONS,
432
        parameters: DEPTH_TEST_PARAMS
433
      })
434
    ];
435
  }
436
}
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