maplibre-gl 6.2.0 → 6.3.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.
Files changed (124) hide show
  1. package/dist/maplibre-gl-dev.mjs +522 -223
  2. package/dist/maplibre-gl-dev.mjs.map +1 -1
  3. package/dist/maplibre-gl-shared-dev.mjs +103 -58
  4. package/dist/maplibre-gl-shared-dev.mjs.map +1 -1
  5. package/dist/maplibre-gl-shared.mjs +2 -2
  6. package/dist/maplibre-gl-shared.mjs.map +1 -1
  7. package/dist/maplibre-gl-worker-dev.mjs +23 -49
  8. package/dist/maplibre-gl-worker-dev.mjs.map +1 -1
  9. package/dist/maplibre-gl-worker.mjs +2 -2
  10. package/dist/maplibre-gl-worker.mjs.map +1 -1
  11. package/dist/maplibre-gl.css +1 -1
  12. package/dist/maplibre-gl.d.ts +214 -59
  13. package/dist/maplibre-gl.mjs +44 -44
  14. package/dist/maplibre-gl.mjs.map +1 -1
  15. package/package.json +10 -20
  16. package/src/data/feature_index.bench.ts +21 -0
  17. package/src/data/segment.test.ts +2 -2
  18. package/src/geo/edge_insets.test.ts +1 -1
  19. package/src/geo/lng_lat.test.ts +7 -7
  20. package/src/geo/lng_lat_bounds.test.ts +4 -4
  21. package/src/geo/mercator_coordinate.test.ts +2 -2
  22. package/src/geo/projection/camera_helper.ts +5 -0
  23. package/src/geo/projection/globe_transform.test.ts +25 -0
  24. package/src/geo/projection/globe_transform.ts +7 -3
  25. package/src/geo/projection/mercator_camera_helper.ts +1 -1
  26. package/src/geo/projection/mercator_transform.ts +21 -5
  27. package/src/geo/projection/vertical_perspective_camera_helper.ts +60 -31
  28. package/src/geo/projection/vertical_perspective_transform.ts +7 -1
  29. package/src/geo/transform_interface.ts +13 -1
  30. package/src/index.ts +7 -2
  31. package/src/render/image_atlas.test.ts +72 -0
  32. package/src/render/image_atlas.ts +17 -2
  33. package/src/render/image_manager.test.ts +46 -0
  34. package/src/render/image_manager.ts +9 -2
  35. package/src/render/painter.ts +1 -12
  36. package/src/render/update_pattern_positions_in_program.test.ts +1 -1
  37. package/src/shaders/glsl/_prelude.vertex.glsl +2 -3
  38. package/src/shaders/glsl/_prelude.vertex.glsl.g.ts +1 -1
  39. package/src/shaders/glsl/circle.vertex.glsl +4 -4
  40. package/src/shaders/glsl/circle.vertex.glsl.g.ts +1 -1
  41. package/src/shaders/glsl/fill_extrusion.vertex.glsl +3 -3
  42. package/src/shaders/glsl/fill_extrusion.vertex.glsl.g.ts +1 -1
  43. package/src/shaders/glsl/fill_extrusion_pattern.vertex.glsl +5 -5
  44. package/src/shaders/glsl/fill_extrusion_pattern.vertex.glsl.g.ts +1 -1
  45. package/src/shaders/glsl/heatmap.vertex.glsl +4 -4
  46. package/src/shaders/glsl/heatmap.vertex.glsl.g.ts +1 -1
  47. package/src/shaders/glsl/line.vertex.glsl +7 -7
  48. package/src/shaders/glsl/line.vertex.glsl.g.ts +1 -1
  49. package/src/shaders/glsl/line_gradient.vertex.glsl +6 -6
  50. package/src/shaders/glsl/line_gradient.vertex.glsl.g.ts +1 -1
  51. package/src/shaders/glsl/line_gradient_sdf.vertex.glsl +7 -7
  52. package/src/shaders/glsl/line_gradient_sdf.vertex.glsl.g.ts +1 -1
  53. package/src/shaders/glsl/line_pattern.vertex.glsl +7 -7
  54. package/src/shaders/glsl/line_pattern.vertex.glsl.g.ts +1 -1
  55. package/src/shaders/glsl/line_sdf.vertex.glsl +7 -7
  56. package/src/shaders/glsl/line_sdf.vertex.glsl.g.ts +1 -1
  57. package/src/shaders/glsl/raster.fragment.glsl +4 -4
  58. package/src/shaders/glsl/raster.fragment.glsl.g.ts +1 -1
  59. package/src/shaders/glsl/raster.vertex.glsl +11 -6
  60. package/src/shaders/glsl/raster.vertex.glsl.g.ts +1 -1
  61. package/src/shaders/glsl/symbol_icon.vertex.glsl +5 -5
  62. package/src/shaders/glsl/symbol_icon.vertex.glsl.g.ts +1 -1
  63. package/src/shaders/glsl/symbol_sdf.vertex.glsl +5 -5
  64. package/src/shaders/glsl/symbol_sdf.vertex.glsl.g.ts +1 -1
  65. package/src/shaders/glsl/symbol_text_and_icon.vertex.glsl +6 -6
  66. package/src/shaders/glsl/symbol_text_and_icon.vertex.glsl.g.ts +1 -1
  67. package/src/source/canvas_source.test.ts +31 -0
  68. package/src/source/canvas_source.ts +2 -1
  69. package/src/source/geojson_worker_source.test.ts +88 -0
  70. package/src/source/geojson_worker_source.ts +22 -39
  71. package/src/source/image_source.test.ts +205 -7
  72. package/src/source/image_source.ts +118 -4
  73. package/src/source/raster_dem_tile_worker_source.test.ts +1 -1
  74. package/src/source/vector_tile_worker_source.test.ts +94 -2
  75. package/src/source/vector_tile_worker_source.ts +18 -27
  76. package/src/source/video_source.test.ts +12 -8
  77. package/src/source/worker_tile.bench.ts +10 -0
  78. package/src/source/worker_tile.ts +0 -3
  79. package/src/style/create_style_layer.bench.ts +15 -0
  80. package/src/style/properties.bench.ts +81 -0
  81. package/src/style/style.test.ts +11 -10
  82. package/src/style/style_image.ts +67 -9
  83. package/src/style/style_layer.bench.ts +65 -0
  84. package/src/style/style_layer.test.ts +1 -1
  85. package/src/style/style_layer.ts +2 -2
  86. package/src/style/validate_style.bench.ts +11 -0
  87. package/src/style/validate_style.test.ts +2 -2
  88. package/src/symbol/anchor.test.ts +2 -2
  89. package/src/symbol/collision_index.bench.ts +116 -0
  90. package/src/symbol/cross_tile_symbol_index.bench.ts +49 -0
  91. package/src/symbol/shaping.test.ts +2 -1
  92. package/src/symbol/symbol_layout.bench.ts +30 -0
  93. package/src/tile/tile_id.test.ts +1 -1
  94. package/src/tile/tile_manager.ts +2 -2
  95. package/src/ui/control/fullscreen_control.test.ts +10 -10
  96. package/src/ui/control/geolocate_control.test.ts +3 -5
  97. package/src/ui/control/globe_control.test.ts +6 -6
  98. package/src/ui/events.ts +1 -1
  99. package/src/ui/handler/scroll_zoom.test.ts +50 -0
  100. package/src/ui/handler/two_fingers_touch.test.ts +3 -3
  101. package/src/ui/handler_manager.test.ts +243 -3
  102. package/src/ui/handler_manager.ts +79 -16
  103. package/src/ui/map.ts +17 -3
  104. package/src/ui/map_tests/map_cross_window.test.ts +2 -2
  105. package/src/ui/map_tests/map_events.test.ts +3 -3
  106. package/src/ui/map_tests/map_images.test.ts +29 -0
  107. package/src/ui/map_tests/map_style.test.ts +9 -8
  108. package/src/ui/map_tests/map_webgl.test.ts +29 -0
  109. package/src/ui/marker.test.ts +26 -42
  110. package/src/ui/popup.test.ts +14 -14
  111. package/src/util/evented.test.ts +33 -28
  112. package/src/util/evented.ts +69 -43
  113. package/src/util/test/null_gl.ts +10 -0
  114. package/src/util/test/util.ts +2 -1
  115. package/src/util/util.test.ts +1 -1
  116. package/src/util/web_worker_transfer.test.ts +5 -5
  117. package/src/webgl/context.ts +20 -0
  118. package/src/webgl/draw/draw_raster.ts +11 -6
  119. package/src/webgl/program/raster_program.ts +11 -0
  120. package/src/webgl/program.ts +31 -16
  121. package/src/webgl/texture.test.ts +16 -1
  122. package/src/webgl/texture.ts +3 -0
  123. package/src/webgl/vertex_buffer.test.ts +17 -2
  124. package/src/webgl/vertex_buffer.ts +24 -13
@@ -19,6 +19,19 @@ function createSource(options) {
19
19
  return new ImageSource('id', options, {} as any, options.eventedParent);
20
20
  }
21
21
 
22
+ async function createLoadedSourceWithTile(map: Map, server: FakeServer) {
23
+ server.respondImmediately = true;
24
+ const source = createSource({url: '/image.png', eventedParent: map});
25
+ const loaded = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata');
26
+ source.onAdd(map);
27
+ await loaded;
28
+
29
+ const {z, x, y} = source.tileID;
30
+ const tile = new Tile(new OverscaledTileID(z, 0, z, x, y), 512);
31
+ await source.loadTile(tile);
32
+ return {source, tile};
33
+ }
34
+
22
35
  describe('ImageSource', () => {
23
36
  stubAjaxGetImage(undefined);
24
37
  let server: FakeServer;
@@ -147,6 +160,112 @@ describe('ImageSource', () => {
147
160
  expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]);
148
161
  });
149
162
 
163
+ test('sets perspective transform for non-parallelogram coordinates', () => {
164
+ const source = createSource({url: '/image.png'});
165
+ source.setCoordinates([
166
+ [-122.52, 37.815],
167
+ [-122.355, 37.8],
168
+ [-122.325, 37.7],
169
+ [-122.545, 37.735]
170
+ ]);
171
+
172
+ expect(source.perspectiveTransform).not.toEqual([0, 0, 1]);
173
+ expect(source.perspectiveTransform.every(Number.isFinite)).toBe(true);
174
+ });
175
+
176
+ test('sets perspective transform when initial coordinates are loaded', async () => {
177
+ const source = createSource({
178
+ url: '/image.png',
179
+ coordinates: [
180
+ [-122.52, 37.815],
181
+ [-122.355, 37.8],
182
+ [-122.325, 37.7],
183
+ [-122.545, 37.735]
184
+ ]
185
+ });
186
+ const promise = waitForEvent(source, 'data', (e) => e.sourceDataType === 'content');
187
+
188
+ source.onAdd(map);
189
+ await sleep(0);
190
+ server.respond();
191
+ await promise;
192
+
193
+ expect(source.perspectiveTransform).not.toEqual([0, 0, 1]);
194
+ expect(source.perspectiveTransform.every(Number.isFinite)).toBe(true);
195
+ });
196
+
197
+ test('keeps projective transform when its constant coefficient is zero', () => {
198
+ const source = createSource({url: '/image.png'});
199
+ source.setCoordinates([
200
+ [-67.5, 55.77657301866769],
201
+ [-22.5, 55.77657301866769],
202
+ [0, 40.97989806962013],
203
+ [-90, 40.97989806962013]
204
+ ]);
205
+
206
+ expect(source.perspectiveTransform).toEqual([0, 1, 0]);
207
+ });
208
+
209
+ test('sets identity perspective transform for parallelogram coordinates', () => {
210
+ const source = createSource({url: '/image.png'});
211
+ source.setCoordinates([
212
+ [-122.431640625, 37.857507156],
213
+ [-122.409667969, 37.857507156],
214
+ [-122.409667969, 37.840156836],
215
+ [-122.431640625, 37.840156836]
216
+ ]);
217
+
218
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
219
+ });
220
+
221
+ test('sets identity perspective transform for collinear destination coordinates', () => {
222
+ const source = createSource({url: '/image.png'});
223
+ source.setCoordinates([
224
+ [-122.431640625, 37.857507156],
225
+ [-122.409667969, 37.857507156],
226
+ [-122.387695312, 37.857507156],
227
+ [-122.365722656, 37.857507156]
228
+ ]);
229
+
230
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
231
+ });
232
+
233
+ test('sets identity perspective transform when the inverse basis is singular', () => {
234
+ const source = createSource({url: '/image.png'});
235
+ source.setCoordinates([
236
+ [-122.431640625, 37.857507156],
237
+ [-122.431640625, 37.840156836],
238
+ [-122.431640625, 37.822802434],
239
+ [-122.409667969, 37.857507156]
240
+ ]);
241
+
242
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
243
+ });
244
+
245
+ test('sets identity perspective transform for a cancellation-degenerate quad', () => {
246
+ const source = createSource({url: '/image.png'});
247
+ source.setCoordinates([
248
+ [96.85546875, 79.36770077764092],
249
+ [-127.265625, 79.36770077764092],
250
+ [88.06640625, 79.36770077764092],
251
+ [-17.40234375, 59.534318001095585]
252
+ ]);
253
+
254
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
255
+ });
256
+
257
+ test('sets identity perspective transform when its denominator crosses zero inside the quad', () => {
258
+ const source = createSource({url: '/image.png'});
259
+ source.setCoordinates([
260
+ [-90, 66.51326044311186],
261
+ [0, 66.51326044311186],
262
+ [-78.75, 61.606396371386275],
263
+ [-90, 0]
264
+ ]);
265
+
266
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
267
+ });
268
+
150
269
  test('sets coordinates via updateImage', async () => {
151
270
  const source = createSource({url: '/image.png'});
152
271
  source.onAdd(map);
@@ -199,6 +318,57 @@ describe('ImageSource', () => {
199
318
  expect(tile.state).toBe('loaded');
200
319
  });
201
320
 
321
+ test('uploads a url into the texture the tiles already hold', async () => {
322
+ const {source, tile} = await createLoadedSourceWithTile(map, server);
323
+ source.prepare();
324
+ const texture = source.texture;
325
+ const upload = vi.spyOn(texture, 'update');
326
+ const destroy = vi.spyOn(texture, 'destroy');
327
+
328
+ const load = vi.spyOn(source, 'load');
329
+ source.updateImage({url: '/image2.png'});
330
+ await load.mock.results[0].value;
331
+ source.prepare();
332
+
333
+ expect(upload).toHaveBeenCalledTimes(1);
334
+ expect(destroy).not.toHaveBeenCalled();
335
+ expect(source.texture).toBe(texture);
336
+ expect(tile.texture).toBe(texture);
337
+ });
338
+
339
+ test('uploads a decoded image into the texture the tiles already hold', async () => {
340
+ const {source, tile} = await createLoadedSourceWithTile(map, server);
341
+ source.prepare();
342
+ const texture = source.texture;
343
+ const upload = vi.spyOn(texture, 'update');
344
+ const destroy = vi.spyOn(texture, 'destroy');
345
+
346
+ source.updateImage({image: new ImageBitmap()});
347
+ source.prepare();
348
+
349
+ expect(upload).toHaveBeenCalledTimes(1);
350
+ expect(destroy).not.toHaveBeenCalled();
351
+ expect(source.texture).toBe(texture);
352
+ expect(tile.texture).toBe(texture);
353
+ });
354
+
355
+ test('keeps the texture the tiles hold live when updateImage runs from an event fired during prepare', async () => {
356
+ const {source, tile} = await createLoadedSourceWithTile(map, server);
357
+ source.on('data', (e: MapSourceDataEvent) => {
358
+ if (e.sourceDataType === 'idle') source.updateImage({image: new ImageBitmap()});
359
+ });
360
+
361
+ source.prepare();
362
+
363
+ expect(source.texture).toBeTruthy();
364
+ expect(tile.texture).toBe(source.texture);
365
+ expect(source.texture.texture).not.toBeNull();
366
+
367
+ const upload = vi.spyOn(source.texture, 'update');
368
+ source.prepare();
369
+ expect(upload).toHaveBeenCalledTimes(1);
370
+ });
371
+
202
372
  test('serialize url and coordinates', () => {
203
373
  const source = createSource({url: '/image.png'});
204
374
 
@@ -275,6 +445,41 @@ describe('ImageSource', () => {
275
445
  expect(errorHandler).not.toHaveBeenCalled();
276
446
  });
277
447
 
448
+ test('keeps the image it displays, and its texture, when the url handed to updateImage fails to load', async () => {
449
+ const {source} = await createLoadedSourceWithTile(map, server);
450
+ source.prepare();
451
+ const texture = source.texture;
452
+ const image = source.image;
453
+ const upload = vi.spyOn(texture, 'update');
454
+ const destroy = vi.spyOn(texture, 'destroy');
455
+ const errorHandler = vi.fn();
456
+ map.on('error', errorHandler);
457
+
458
+ const load = vi.spyOn(source, 'load');
459
+ source.updateImage({url: '/missing-image.png'});
460
+ await load.mock.results[0].value;
461
+ source.prepare();
462
+
463
+ expect(errorHandler).toHaveBeenCalledTimes(1);
464
+ expect(destroy).not.toHaveBeenCalled();
465
+ expect(upload).not.toHaveBeenCalled();
466
+ expect(source.texture).toBe(texture);
467
+ expect(source.image).toBe(image);
468
+ });
469
+
470
+ test('deletes its texture and drops its image when the source is removed', async () => {
471
+ const {source} = await createLoadedSourceWithTile(map, server);
472
+ source.prepare();
473
+ const destroy = vi.spyOn(source.texture, 'destroy');
474
+
475
+ source.onRemove();
476
+
477
+ expect(destroy).toHaveBeenCalledTimes(1);
478
+ expect(source.texture).toBeNull();
479
+ expect(source.image).toBeNull();
480
+ expect(source.tiles).toEqual({});
481
+ });
482
+
278
483
  describe('updateImage with a decoded image', () => {
279
484
  let source: ImageSource;
280
485
  let transformRequest: Mock<(url: string, resourceType?: string) => any>;
@@ -309,13 +514,6 @@ describe('ImageSource', () => {
309
514
  expect(firedMetadata).toBe(true);
310
515
  });
311
516
 
312
- test('resets the texture so the new image is uploaded on the next prepare', () => {
313
- source.texture = {} as Texture;
314
- source.updateImage({image: new ImageBitmap()});
315
-
316
- expect(source.texture).toBeNull();
317
- });
318
-
319
517
  test('updates coordinates alongside the image', () => {
320
518
  source.updateImage({
321
519
  image: new ImageBitmap(),
@@ -19,6 +19,11 @@ import type Point from '@mapbox/point-geometry';
19
19
  import {ensureError, MAX_TILE_ZOOM} from '../util/util.ts';
20
20
  import {Bounds} from '../geo/bounds.ts';
21
21
  import {isAbortError} from '../util/abort_error.ts';
22
+ import {
23
+ identityPerspectiveTransform,
24
+ type RasterPerspectiveTransform
25
+ } from '../webgl/program/raster_program.ts';
26
+ import {mat2, vec3} from 'gl-matrix';
22
27
 
23
28
  /**
24
29
  * Four geographical coordinates,
@@ -141,9 +146,11 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
141
146
  image: ImageSourceImage;
142
147
  tileID: CanonicalTileID;
143
148
  tileCoords: Point[];
149
+ perspectiveTransform: RasterPerspectiveTransform = identityPerspectiveTransform;
144
150
  flippedWindingOrder: boolean = false;
145
151
  _loaded: boolean;
146
152
  _request: AbortController;
153
+ private _imageDirty: boolean = false;
147
154
 
148
155
  /** @internal */
149
156
  constructor(id: string, options: ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) {
@@ -178,7 +185,7 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
178
185
  this._loaded = true;
179
186
 
180
187
  if (image?.data) {
181
- this.image = image.data;
188
+ this._setImage(image.data);
182
189
  if (newCoordinates) {
183
190
  this.coordinates = newCoordinates;
184
191
  }
@@ -216,11 +223,10 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
216
223
  if ('image' in options) {
217
224
  // Use the already-decoded image directly, skipping the network request.
218
225
  this._loaded = true;
219
- this.image = options.image;
226
+ this._setImage(options.image);
220
227
  if (options.coordinates) {
221
228
  this.coordinates = options.coordinates;
222
229
  }
223
- this.texture = null;
224
230
  this._finishLoading();
225
231
  return this;
226
232
  }
@@ -230,10 +236,22 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
230
236
  }
231
237
 
232
238
  this.options.url = options.url;
233
- this.load(options.coordinates).finally(() => this.texture = null);
239
+ this.load(options.coordinates);
234
240
  return this;
235
241
  }
236
242
 
243
+ /** Loaded tiles hold `this.texture`, so the wrapper has to outlive the images in it. */
244
+ private _setImage(image: ImageSourceImage): void {
245
+ this.image = image;
246
+ this._imageDirty = true;
247
+ }
248
+
249
+ /** Teardown only: dropping the reference alone leaves the allocation to the GC. */
250
+ private _disposeTexture(): void {
251
+ this.texture?.destroy();
252
+ this.texture = null;
253
+ }
254
+
237
255
  _finishLoading(): void {
238
256
  if (this.map) {
239
257
  this.setCoordinates(this.coordinates);
@@ -251,6 +269,9 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
251
269
  this._request.abort();
252
270
  this._request = null;
253
271
  }
272
+ this._disposeTexture();
273
+ this.image = null;
274
+ this.tiles = {};
254
275
  }
255
276
 
256
277
  /**
@@ -287,6 +308,7 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
287
308
  // Transform the corner coordinates into the coordinate space of our
288
309
  // tile.
289
310
  this.tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round());
311
+ this.perspectiveTransform = calculateRasterPerspectiveTransform(this.tileCoords);
290
312
  this.flippedWindingOrder = hasWrongWindingOrder(this.tileCoords);
291
313
 
292
314
  this.fire(new MapSourceDataEvent('data', {sourceDataType: 'content'}));
@@ -304,7 +326,11 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
304
326
  if (!this.texture) {
305
327
  this.texture = new Texture(context, this.image, gl.RGBA);
306
328
  this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
329
+ } else if (this._imageDirty) {
330
+ this.texture.update(this.image);
331
+ this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
307
332
  }
333
+ this._imageDirty = false;
308
334
 
309
335
  let newTilesLoaded = false;
310
336
  for (const w in this.tiles) {
@@ -418,3 +444,91 @@ function hasWrongWindingOrder(coords: Point[]) {
418
444
 
419
445
  return crossProduct < 0;
420
446
  }
447
+
448
+ const perspectiveEpsilon = Number.EPSILON;
449
+ // Allows for accumulated rounding error in the three-term homogeneous dot products.
450
+ const perspectiveErrorFactor = 8;
451
+
452
+ /**
453
+ * Calculates the inverse-homography denominator used to sample non-affine
454
+ * image-source texture coordinates. Image source tile coordinates are rounded
455
+ * to integers before this runs, so singularity checks use exact zero or
456
+ * Number.EPSILON scaled to the corner denominators.
457
+ *
458
+ * Based on Paul S. Heckbert, "Fundamentals of Texture Mapping and Image
459
+ * Warping", UCB/CSD-89-516, 1989, section 2.2.3 and appendix A.2.
460
+ *
461
+ * @see https://www2.eecs.berkeley.edu/Pubs/TechRpts/1989/5504.html
462
+ * @see https://www.cs.cmu.edu/~ph/texfund/texfund.pdf
463
+ */
464
+ function calculateRasterPerspectiveTransform(cornerCoords: Point[]): RasterPerspectiveTransform {
465
+ if (isParallelogram(cornerCoords)) {
466
+ return identityPerspectiveTransform;
467
+ }
468
+
469
+ const [topLeft, topRight, bottomRight, bottomLeft] = cornerCoords;
470
+ const sx = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x;
471
+ const sy = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y;
472
+ const dx1 = topRight.x - bottomRight.x;
473
+ const dy1 = topRight.y - bottomRight.y;
474
+ const dx2 = bottomLeft.x - bottomRight.x;
475
+ const dy2 = bottomLeft.y - bottomRight.y;
476
+ const basis: mat2 = [dx1, dy1, dx2, dy2];
477
+ const determinant = mat2.determinant(basis);
478
+
479
+ if (Math.abs(determinant) < perspectiveEpsilon) {
480
+ return identityPerspectiveTransform;
481
+ }
482
+
483
+ const perspectiveX = (sx * dy2 - dx2 * sy) / determinant;
484
+ const perspectiveY = (dx1 * sy - sx * dy1) / determinant;
485
+
486
+ // A finite image of the unit square has positive homogeneous denominators
487
+ // at all four corners after normalizing the top-left denominator to one.
488
+ const forwardDenominators = [1, 1 + perspectiveX, 1 + perspectiveX + perspectiveY, 1 + perspectiveY];
489
+ const forwardDenominatorScale = Math.max(...forwardDenominators.map(value => Math.abs(value)));
490
+ const hasInvalidForwardDenominator = forwardDenominators.some(value =>
491
+ !Number.isFinite(value) || value <= perspectiveErrorFactor * perspectiveEpsilon * forwardDenominatorScale);
492
+ if (!Number.isFinite(forwardDenominatorScale) || forwardDenominatorScale === 0 || hasInvalidForwardDenominator) {
493
+ return identityPerspectiveTransform;
494
+ }
495
+
496
+ const a = topRight.x - topLeft.x + perspectiveX * topRight.x;
497
+ const b = bottomLeft.x - topLeft.x + perspectiveY * bottomLeft.x;
498
+ const d = topRight.y - topLeft.y + perspectiveX * topRight.y;
499
+ const e = bottomLeft.y - topLeft.y + perspectiveY * bottomLeft.y;
500
+ const inverseDenominator: RasterPerspectiveTransform = [0, 0, 0];
501
+ vec3.cross(inverseDenominator, [a, d, perspectiveX], [b, e, perspectiveY]);
502
+ const normalizationScale = Math.max(...inverseDenominator.map(value => Math.abs(value)));
503
+ const cornerDenominators = cornerCoords.map(({x, y}) =>
504
+ vec3.dot(inverseDenominator, [x, y, 1]));
505
+ const cornerDenominatorErrors = cornerCoords.map(({x, y}) =>
506
+ perspectiveErrorFactor * perspectiveEpsilon * (
507
+ Math.abs(inverseDenominator[0] * x) +
508
+ Math.abs(inverseDenominator[1] * y) +
509
+ Math.abs(inverseDenominator[2])));
510
+ const denominatorScale = Math.max(...cornerDenominators.map(value => Math.abs(value)));
511
+ const denominatorSign = Math.sign(cornerDenominators[0]);
512
+
513
+ // The denominator is affine within each rendered triangle. Reject a
514
+ // transform that reaches or crosses zero anywhere inside the quad.
515
+ const hasInvalidDenominator = cornerDenominators.some((value, index) =>
516
+ !Number.isFinite(value) ||
517
+ Math.abs(value) <= Math.max(perspectiveEpsilon * denominatorScale, cornerDenominatorErrors[index]) ||
518
+ Math.sign(value) !== denominatorSign);
519
+ if (!Number.isFinite(normalizationScale) || normalizationScale === 0 || !Number.isFinite(denominatorScale) ||
520
+ denominatorScale === 0 || hasInvalidDenominator) {
521
+ return identityPerspectiveTransform;
522
+ }
523
+
524
+ // The denominator is homogeneous, so normalize by its largest coefficient
525
+ // instead of assuming that its constant coefficient is nonzero.
526
+ const transform = inverseDenominator.map(value => value / normalizationScale) as RasterPerspectiveTransform;
527
+ return transform.every(Number.isFinite) ? transform : identityPerspectiveTransform;
528
+ }
529
+
530
+ function isParallelogram(cornerCoords: Point[]) {
531
+ const [tl, tr, br, bl] = cornerCoords;
532
+ return Math.abs(tl.x + br.x - tr.x - bl.x) < perspectiveEpsilon &&
533
+ Math.abs(tl.y + br.y - tr.y - bl.y) < perspectiveEpsilon;
534
+ }
@@ -14,7 +14,7 @@ describe('loadTile', () => {
14
14
  dim: 256
15
15
  } as any as WorkerDEMTileParameters);
16
16
  expect(Object.keys(source.loaded)).toEqual(['0']);
17
- expect(data instanceof DEMData).toBeTruthy();
17
+ expect(data).toBeInstanceOf(DEMData);
18
18
  });
19
19
  });
20
20
 
@@ -170,6 +170,100 @@ describe('vector tile worker source', () => {
170
170
  expect(res.rawTileData).toStrictEqual(rawTileData);
171
171
  });
172
172
 
173
+ test('VectorTileWorkerSource.reloadTile includes rawTileData in response if loadTile was aborted', async () => {
174
+ const rawTileData = new ArrayBuffer(0);
175
+
176
+ const layerIndex = new StyleLayerIndex([{
177
+ id: 'test',
178
+ source: 'source',
179
+ 'source-layer': 'test',
180
+ type: 'symbol',
181
+ layout: {
182
+ 'icon-image': 'hello',
183
+ 'text-font': ['StandardFont-Bold'],
184
+ 'text-field': '{name}'
185
+ }
186
+ }]);
187
+
188
+ let sendAsyncShouldAbort = false;
189
+ const actor = {
190
+ sendAsync: (message: {type: string; data: unknown}, abortController: AbortController) => {
191
+ if (sendAsyncShouldAbort) {
192
+ return new Promise((_resolve, reject) => {
193
+ reject('aborted by test');
194
+ });
195
+ }
196
+
197
+ return new Promise((resolve, reject) => {
198
+ const res = setTimeout(() => {
199
+ const response = message.type === 'getImages' ?
200
+ {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} :
201
+ {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}};
202
+ resolve(response);
203
+ }, 100);
204
+ abortController.signal.addEventListener('abort', () => {
205
+ clearTimeout(res);
206
+ reject('aborted by abortController');
207
+ });
208
+ });
209
+ }
210
+ };
211
+
212
+ const source = new VectorTileWorkerSource(actor, layerIndex, ['hello']);
213
+ source.loadVectorTile = (_params, _rawData) => {
214
+ return {
215
+ vectorTile: {
216
+ layers: {
217
+ test: {
218
+ version: 2,
219
+ name: 'test',
220
+ extent: 8192,
221
+ length: 1,
222
+ feature: (featureIndex: number) => ({
223
+ extent: 8192,
224
+ type: 1,
225
+ id: featureIndex,
226
+ properties: {
227
+ name: 'test'
228
+ },
229
+ loadGeometry () {
230
+ return [[new Point(0, 0)]];
231
+ }
232
+ })
233
+ }
234
+ }
235
+ },
236
+ rawData: rawTileData
237
+ };
238
+ };
239
+
240
+ server.respondWith(request => {
241
+ request.respond(200, {'Content-Type': 'application/pbf'}, rawTileData as any);
242
+ });
243
+
244
+ sendAsyncShouldAbort = true;
245
+ const loadTilePromise = source.loadTile({
246
+ source: 'source',
247
+ uid: 0,
248
+ tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
249
+ request: {url: 'http://localhost:2900/faketile.pbf'},
250
+ subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision,
251
+ } as any as WorkerTileParameters);
252
+ server.respond();
253
+ await expect(loadTilePromise).rejects.toThrow(/aborted/);
254
+
255
+ sendAsyncShouldAbort = false;
256
+ const res = await source.reloadTile({
257
+ source: 'source',
258
+ uid: 0,
259
+ tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}},
260
+ subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision,
261
+ } as any as WorkerTileParameters) as WorkerTileWithData;
262
+ expect(res).toBeDefined();
263
+ expect(res.rawTileData).toBeDefined();
264
+ expect(res.rawTileData).toStrictEqual(rawTileData);
265
+ });
266
+
173
267
  test('VectorTileWorkerSource.loadTile reparses tile if reloadTile is called during reparsing', async () => {
174
268
  const rawTileData = new ArrayBuffer(0);
175
269
  const loadVectorData = (_params, _rawData) => {
@@ -193,7 +287,6 @@ describe('vector tile worker source', () => {
193
287
  const parseWorkerTileMock = vi
194
288
  .spyOn(WorkerTile.prototype, 'parse')
195
289
  .mockImplementation(function(this: WorkerTile, _data, _layerIndex, _availableImages, _actor) {
196
- this.status = 'parsing';
197
290
  return new Promise((resolve) => {
198
291
  setTimeout(() => resolve({} as WorkerTileResult), 20);
199
292
  });
@@ -248,7 +341,6 @@ describe('vector tile worker source', () => {
248
341
  const parseWorkerTileMock = vi
249
342
  .spyOn(WorkerTile.prototype, 'parse')
250
343
  .mockImplementation(function(this: WorkerTile, _data, _layerIndex, _availableImages, _actor) {
251
- this.status = 'parsing';
252
344
  return new Promise((resolve) => {
253
345
  setTimeout(() => resolve({} as WorkerTileResult), 20);
254
346
  });
@@ -3,7 +3,7 @@ import {VectorTile} from '@mapbox/vector-tile';
3
3
  import {fromVectorTileJs, type VectorTileLayerLike, type VectorTileLike} from '@maplibre/vt-pbf';
4
4
  import {type ExpiryData, getArrayBuffer} from '../util/ajax.ts';
5
5
  import {WorkerTile} from './worker_tile.ts';
6
- import {WorkerTileState, type ParsingState} from './worker_tile_state.ts';
6
+ import {WorkerTileState} from './worker_tile_state.ts';
7
7
  import {BoundedLRUCache} from '../tile/tile_cache.ts';
8
8
  import {ensureError, extend} from '../util/util.ts';
9
9
  import {RequestPerformance} from '../util/request_performance.ts';
@@ -106,17 +106,12 @@ export class VectorTileWorkerSource implements WorkerSource {
106
106
 
107
107
  workerTile.vectorTile = vectorTile;
108
108
  this.tileState.markLoaded(uid, workerTile);
109
+ const parsingState = {rawData, cacheControl, resourceTiming};
110
+ this.tileState.setParsing(uid, parsingState);
109
111
 
110
- const parseState = {rawData, cacheControl, resourceTiming}; // Keep data so reloadTile can access if parse is canceled.
111
- this.tileState.setParsing(uid, parseState);
112
- try {
113
- return await this._parseWorkerTile(workerTile, params, parseState);
114
- } finally {
115
- this.tileState.removeParsing(uid);
116
- }
112
+ return await this._parseWorkerTile(workerTile, params);
117
113
  } catch (err) {
118
114
  this.tileState.finishLoading(uid);
119
- workerTile.status = 'done';
120
115
  this.tileState.markLoaded(uid, workerTile);
121
116
  throw err;
122
117
  }
@@ -128,16 +123,23 @@ export class VectorTileWorkerSource implements WorkerSource {
128
123
  return extend({etagUnmodified: true as const}, cacheControl, resourceTiming);
129
124
  }
130
125
 
131
- async _parseWorkerTile(workerTile: WorkerTile, params: WorkerTileParameters, parseState?: ParsingState): Promise<WorkerTileResult> {
126
+ async _parseWorkerTile(workerTile: WorkerTile, params: WorkerTileParameters): Promise<WorkerTileResult> {
127
+ const parseState = this.tileState.getParsing(workerTile.uid);
128
+
132
129
  let result = await workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, params.subdivisionGranularity);
133
130
 
131
+ // We need to pass rawTileData back to the main thread so that it can be stored in the Tile and FeatureIndex.
132
+ // After the main thread has successfully received and stored rawTileData,
133
+ // we no longer need to store it in the worker or transfer additional copies of it.
134
134
  if (parseState) {
135
135
  const {rawData, cacheControl, resourceTiming} = parseState;
136
136
  // Overzoomed tiles are always re-encoded to MVT protobuf by _getOverzoomTile
137
137
  const encoding = params.overzoomParameters ? 'mvt' : params.encoding;
138
- // Transferring a copy of rawTileData because the worker needs to retain its copy.
138
+ // Return a copy of rawData to the main thread to avoid clearing the worker's buffer
139
139
  result = extend({rawTileData: rawData.slice(0), encoding}, result, cacheControl, resourceTiming);
140
+ this.tileState.removeParsing(workerTile.uid);
140
141
  }
142
+ // else: this seems like a missing case where cache control is lost? see #3309
141
143
 
142
144
  return result;
143
145
  }
@@ -209,27 +211,16 @@ export class VectorTileWorkerSource implements WorkerSource {
209
211
  */
210
212
  async reloadTile(params: WorkerTileParameters): Promise<WorkerTileResult> {
211
213
  const uid = params.uid;
212
-
213
214
  const workerTile = this.tileState.getLoaded(uid);
214
215
  if (!workerTile) throw new Error('Should not be trying to reload a tile that was never loaded or has been removed');
215
216
 
216
- workerTile.showCollisionBoxes = params.showCollisionBoxes;
217
-
218
- if (workerTile.status === 'parsing') {
219
- // if we are cancelling the original parse, make sure to pass the rawTileData from the original parse
220
- const parseState = this.tileState.getParsing(uid);
221
- try {
222
- return await this._parseWorkerTile(workerTile, params, parseState);
223
- } finally {
224
- this.tileState.removeParsing(uid);
225
- }
217
+ // If there was no vector tile data on the initial load, don't try to reparse the tile.
218
+ if (!workerTile.vectorTile) {
219
+ return;
226
220
  }
227
221
 
228
- // If there was no vector tile data on the initial load, don't try and reparse the tile.
229
- // this seems like a missing case where cache control is lost? see #3309
230
- if (workerTile.status === 'done' && workerTile.vectorTile) {
231
- return await this._parseWorkerTile(workerTile, params);
232
- }
222
+ workerTile.showCollisionBoxes = params.showCollisionBoxes;
223
+ return await this._parseWorkerTile(workerTile, params);
233
224
  }
234
225
 
235
226
  /**