maplibre-gl 6.3.0 → 6.4.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 (56) hide show
  1. package/dist/maplibre-gl-dev.mjs +590 -208
  2. package/dist/maplibre-gl-dev.mjs.map +1 -1
  3. package/dist/maplibre-gl-shared-dev.mjs +258 -235
  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 +2 -2
  8. package/dist/maplibre-gl-worker.mjs +2 -2
  9. package/dist/maplibre-gl-worker.mjs.map +1 -1
  10. package/dist/maplibre-gl.d.ts +258 -97
  11. package/dist/maplibre-gl.mjs +18 -18
  12. package/dist/maplibre-gl.mjs.map +1 -1
  13. package/package.json +7 -7
  14. package/src/data/bucket/fill_extrusion_bucket.ts +1 -13
  15. package/src/data/bucket/round_polygon_corners.test.ts +84 -36
  16. package/src/data/bucket/round_polygon_corners.ts +90 -39
  17. package/src/data/extent_bounds.ts +28 -1
  18. package/src/geo/projection/globe_transform.test.ts +129 -1
  19. package/src/geo/projection/globe_utils.ts +189 -2
  20. package/src/geo/projection/vertical_perspective_camera_helper.test.ts +24 -0
  21. package/src/geo/projection/vertical_perspective_camera_helper.ts +11 -10
  22. package/src/render/image_atlas.test.ts +110 -2
  23. package/src/render/image_atlas.ts +35 -4
  24. package/src/render/image_manager.test.ts +66 -1
  25. package/src/render/image_manager.ts +136 -144
  26. package/src/render/painter.ts +5 -2
  27. package/src/render/pattern_atlas.test.ts +131 -0
  28. package/src/render/pattern_atlas.ts +144 -0
  29. package/src/shaders/glsl/_prelude.vertex.glsl +8 -8
  30. package/src/shaders/glsl/_prelude.vertex.glsl.g.ts +1 -1
  31. package/src/shaders/glsl/color_relief.fragment.glsl +1 -2
  32. package/src/shaders/glsl/color_relief.fragment.glsl.g.ts +1 -1
  33. package/src/shaders/glsl/hillshade_prepare.fragment.glsl +12 -12
  34. package/src/shaders/glsl/hillshade_prepare.fragment.glsl.g.ts +1 -1
  35. package/src/source/image_source.test.ts +41 -31
  36. package/src/source/image_source.ts +14 -15
  37. package/src/source/raster_dem_tile_source.test.ts +37 -3
  38. package/src/source/raster_dem_tile_source.ts +1 -2
  39. package/src/source/raster_tile_source.test.ts +5 -26
  40. package/src/source/raster_tile_source.ts +4 -3
  41. package/src/style/style.test.ts +64 -0
  42. package/src/style/style.ts +37 -64
  43. package/src/symbol/cross_tile_symbol_index.test.ts +31 -0
  44. package/src/symbol/cross_tile_symbol_index.ts +67 -37
  45. package/src/tile/tile_manager.test.ts +69 -1
  46. package/src/tile/tile_manager.ts +14 -9
  47. package/src/ui/handler/two_fingers_touch.test.ts +14 -3
  48. package/src/ui/handler_inertia.test.ts +91 -0
  49. package/src/ui/handler_inertia.ts +65 -14
  50. package/src/ui/map_tests/map_images.test.ts +26 -0
  51. package/src/ui/map_tests/map_webgl.test.ts +2 -1
  52. package/src/ui/marker.test.ts +151 -0
  53. package/src/ui/marker.ts +120 -10
  54. package/src/util/image_request.ts +31 -1
  55. package/src/webgl/draw/draw_background.ts +1 -1
  56. package/src/webgl/program/pattern.ts +3 -3
@@ -1,54 +1,32 @@
1
- /* eslint-disable key-spacing */
2
- import potpack from 'potpack';
3
-
4
1
  import {ErrorEvent, Evented} from '../util/evented.ts';
5
2
  import {MapStyleImageMissingEvent} from '../ui/events.ts';
6
3
  import {RGBAImage} from '../util/image.ts';
7
- import {ImagePosition} from './image_atlas.ts';
8
- import {Texture} from '../webgl/texture.ts';
9
4
  import {renderStyleImage} from '../style/style_image.ts';
10
5
  import {warnOnce} from '../util/util.ts';
11
6
 
12
7
  import type {StyleImage} from '../style/style_image.ts';
13
- import type {Context} from '../webgl/context.ts';
14
- import type {PotpackBox} from 'potpack';
15
8
  import type {GetImagesResponse} from '../util/actor_messages.ts';
16
9
 
17
10
  export type MissingImageRequestHandler = (id: string) => void | Promise<void>;
18
11
 
19
- type Pattern = {
20
- bin: PotpackBox;
21
- position: ImagePosition;
22
- };
23
-
24
- /**
25
- * When copied into the atlas texture, image data is padded by one pixel on each side. Icon
26
- * images are padded with fully transparent pixels, while pattern images are padded with a
27
- * copy of the image data wrapped from the opposite side. In both cases, this ensures the
28
- * correct behavior of GL_LINEAR texture sampling mode.
29
- */
30
- const padding = 1;
31
-
32
12
  type ImageManagerEventType = {
33
13
  error: ErrorEvent;
34
14
  styleimagemissing: MapStyleImageMissingEvent;
35
15
  };
36
16
 
37
17
  /**
38
- * ImageManager does three things:
39
- *
40
- * 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled.
41
- * 2. Builds a texture atlas for pattern images.
42
- * 3. Rerenders renderable images once per frame
43
- *
44
- * These are disparate responsibilities and should eventually be handled by different classes. When we implement
45
- * data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time
46
- * to refactor this.
18
+ * Owns every image of the style - the ones the sprites bring in as well as the ones added at
19
+ * runtime - and tracks requests for them from the tile workers, sending responses when the
20
+ * requests are fulfilled.
47
21
  */
48
22
  export class ImageManager extends Evented<ImageManagerEventType> {
49
- images: {[_: string]: StyleImage};
50
- updatedImages: {[_: string]: boolean};
51
- callbackDispatchedThisFrame: {[_: string]: boolean};
23
+ images: Record<string, StyleImage>;
24
+ /**
25
+ * Incremented on every {@link ImageManager.updateImage} call. {@link ImageAtlas} instances
26
+ * compare it against the value they last patched against, so that a tile whose atlas is up to
27
+ * date can skip its patching work entirely.
28
+ */
29
+ updateVersion: number;
52
30
  loaded: boolean;
53
31
  /**
54
32
  * This is used to track requests for images that are not yet available. When the image is loaded,
@@ -59,40 +37,37 @@ export class ImageManager extends Evented<ImageManagerEventType> {
59
37
  promiseResolve: (value: GetImagesResponse | PromiseLike<GetImagesResponse>) => void;
60
38
  }>;
61
39
  missingImageResolver: MissingImageRequestHandler | null;
62
-
63
- patterns: {[_: string]: Pattern};
64
- atlasImage: RGBAImage;
65
- atlasTexture: Texture;
66
- dirty: boolean;
40
+ /**
41
+ * The images whose `render` callback already ran in the current frame. A given image usually
42
+ * sits in the atlas of several tiles, so it would otherwise be asked to re-render once per
43
+ * tile that holds it.
44
+ */
45
+ private _renderCallbacksDispatchedThisFrame: Record<string, boolean>;
46
+ /**
47
+ * The ids of the images each sprite has brought in, keyed by sprite id, so that they can be
48
+ * removed again when that sprite is reloaded or removed.
49
+ */
50
+ private _spriteImagesIds: Record<string, string[]>;
51
+ /** Cached result of {@link ImageManager.listImages}, invalidated whenever an image is added or removed */
52
+ private _imagesIds: string[] | null;
67
53
 
68
54
  constructor() {
69
55
  super();
70
56
  this.images = {};
71
- this.updatedImages = {};
72
- this.callbackDispatchedThisFrame = {};
57
+ this.updateVersion = 0;
73
58
  this.loaded = false;
74
59
  this.requestors = [];
75
60
  this.missingImageResolver = null;
76
-
77
- this.patterns = {};
78
- this.atlasImage = new RGBAImage({width: 1, height: 1});
79
- this.dirty = true;
61
+ this._spriteImagesIds = {};
62
+ this._imagesIds = null;
63
+ this._renderCallbacksDispatchedThisFrame = {};
80
64
  }
81
65
 
82
66
  destroy(): void {
83
- // Destroy atlas texture if it exists
84
- if (this.atlasTexture) {
85
- this.atlasTexture.destroy();
86
- this.atlasTexture = null;
87
- }
88
- // Remove all images and patterns
89
67
  for (const id of Object.keys(this.images)) {
90
68
  this.removeImage(id);
91
69
  }
92
-
93
- this.patterns = {};
94
- this.atlasImage = new RGBAImage({width: 1, height: 1});
95
- this.dirty = true;
70
+ this._spriteImagesIds = {};
96
71
  }
97
72
  isLoaded(): boolean {
98
73
  return this.loaded;
@@ -136,6 +111,7 @@ export class ImageManager extends Evented<ImageManagerEventType> {
136
111
  if (this.images[id]) throw new Error(`Image id ${id} already exist, use updateImage instead`);
137
112
  if (this._validate(id, image)) {
138
113
  this.images[id] = image;
114
+ this._imagesIds = null;
139
115
  if (image.isWebGLImage) this.updateImage(id, image, false);
140
116
  }
141
117
  }
@@ -182,28 +158,120 @@ export class ImageManager extends Evented<ImageManagerEventType> {
182
158
  return content[3] >= content[1];
183
159
  }
184
160
 
161
+ /**
162
+ * Replaces an image that is already known under this id, bumping its version so that the
163
+ * atlases holding it notice.
164
+ *
165
+ * The old record is read directly rather than through {@link ImageManager.getImage}, which
166
+ * would force a synchronous decode of the sprite data of the very image being replaced. Its
167
+ * size therefore comes from whichever of the two the pixels are still in, since an image that
168
+ * came from a sprite and was never rendered has not been decoded yet, and its version is
169
+ * defaulted, since images start out without one.
170
+ *
171
+ * @param validate - whether to reject an image of a different size than the one it replaces
172
+ */
185
173
  updateImage(id: string, image: StyleImage, validate: boolean = true): void {
186
- const oldImage = this.getImage(id);
187
- if (validate && (oldImage.data.width !== image.data.width || oldImage.data.height !== image.data.height)) {
188
- throw new Error(`size mismatch between old image (${oldImage.data.width}x${oldImage.data.height}) and new image (${image.data.width}x${image.data.height}).`);
174
+ const oldImage = this.images[id];
175
+ if (validate) {
176
+ const oldData = oldImage.data || oldImage.spriteData;
177
+ if (oldData.width !== image.data.width || oldData.height !== image.data.height) {
178
+ throw new Error(`size mismatch between old image (${oldData.width}x${oldData.height}) and new image (${image.data.width}x${image.data.height}).`);
179
+ }
189
180
  }
190
- image.version = oldImage.version + 1;
181
+ image.version = (oldImage.version ?? 0) + 1;
191
182
  this.images[id] = image;
192
- this.updatedImages[id] = true;
183
+ this.updateVersion++;
193
184
  }
194
185
 
195
186
  removeImage(id: string): void {
196
187
  const image = this.images[id];
188
+ if (!image) return;
197
189
  delete this.images[id];
198
- delete this.patterns[id];
190
+ this._imagesIds = null;
199
191
 
200
192
  if (image.userImage?.onRemove) {
201
193
  image.userImage.onRemove();
202
194
  }
203
195
  }
204
196
 
197
+ /**
198
+ * @returns the ids of every image currently held, sprite images and runtime ones alike. The
199
+ * returned array is shared between callers and must not be modified.
200
+ */
205
201
  listImages(): string[] {
206
- return Object.keys(this.images);
202
+ this._imagesIds ??= Object.keys(this.images);
203
+ return this._imagesIds;
204
+ }
205
+
206
+ /**
207
+ * Images of the `default` sprite keep their plain id, the ones of any other sprite are
208
+ * namespaced by their sprite's id.
209
+ */
210
+ private _getSpriteImageId(spriteId: string, imageId: string): string {
211
+ return spriteId === 'default' ? imageId : `${spriteId}:${imageId}`;
212
+ }
213
+
214
+ /**
215
+ * Takes over the images of a single sprite: adds the new ones, updates the ones that are
216
+ * already known, and removes the ones that a previous load of this same sprite had brought in
217
+ * but that are not part of it anymore.
218
+ *
219
+ * @param spriteId - the id of the sprite the images belong to
220
+ * @param images - the sprite's images, keyed by their id within the sprite
221
+ * @returns the ids of the images that this sprite now provides, and the ids of the ones that
222
+ * were removed because they are no longer part of it
223
+ */
224
+ setSpriteImages(spriteId: string, images: Record<string, StyleImage>): {loaded: string[]; removed: string[]} {
225
+ const previousIds = this._spriteImagesIds[spriteId] ?? [];
226
+ const loaded: string[] = [];
227
+
228
+ for (const id in images) {
229
+ const imageId = this._getSpriteImageId(spriteId, id);
230
+ loaded.push(imageId);
231
+
232
+ if (imageId in this.images) {
233
+ this.updateImage(imageId, images[id], false);
234
+ } else {
235
+ this.addImage(imageId, images[id]);
236
+ }
237
+ }
238
+
239
+ const loadedIds = new Set(loaded);
240
+ const removed = previousIds.filter(imageId => !loadedIds.has(imageId));
241
+ for (const imageId of removed) {
242
+ this.removeImage(imageId);
243
+ }
244
+
245
+ this._spriteImagesIds[spriteId] = loaded;
246
+ return {loaded, removed};
247
+ }
248
+
249
+ /**
250
+ * Removes all the images a single sprite has brought in.
251
+ * @returns the ids of the removed images
252
+ */
253
+ removeSpriteImages(spriteId: string): string[] {
254
+ const removed = this._spriteImagesIds[spriteId] ?? [];
255
+ for (const imageId of removed) {
256
+ this.removeImage(imageId);
257
+ }
258
+
259
+ delete this._spriteImagesIds[spriteId];
260
+ return removed;
261
+ }
262
+
263
+ /**
264
+ * Removes the images of every sprite, leaving the images added at runtime alone.
265
+ * @returns the ids of the removed images
266
+ */
267
+ removeAllSpriteImages(): string[] {
268
+ const removed = Object.values(this._spriteImagesIds).flat();
269
+ for (const imageId of removed) {
270
+ this.removeImage(imageId);
271
+ }
272
+
273
+ this._spriteImagesIds = {};
274
+ return removed;
207
275
  }
208
276
 
209
277
  setMissingImageResolver(resolver: MissingImageRequestHandler | null): void {
@@ -237,7 +305,7 @@ export class ImageManager extends Evented<ImageManagerEventType> {
237
305
  const resolver = this.missingImageResolver;
238
306
 
239
307
  if (resolver) {
240
- await Promise.all(Array.from(unresolvedIds, (id) => resolver(id)));
308
+ await Promise.allSettled(Array.from(unresolvedIds, (id) => resolver(id)));
241
309
  }
242
310
 
243
311
  const response: GetImagesResponse = {};
@@ -272,94 +340,18 @@ export class ImageManager extends Evented<ImageManagerEventType> {
272
340
  return response;
273
341
  }
274
342
 
275
- // Pattern stuff
276
-
277
- getPixelSize(): {width: number; height: number} {
278
- const {width, height} = this.atlasImage;
279
- return {width, height};
280
- }
281
-
282
- getPattern(id: string): ImagePosition {
283
- const pattern = this.patterns[id];
284
-
285
- const image = this.getImage(id);
286
- if (!image) {
287
- return null;
288
- }
289
-
290
- // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- pattern?.position.version would be undefined when pattern is nullish, making undefined === undefined true
291
- if (pattern && pattern.position.version === image.version) {
292
- return pattern.position;
293
- }
294
-
295
- if (!pattern) {
296
- const w = image.data.width + padding * 2;
297
- const h = image.data.height + padding * 2;
298
- const bin = {w, h, x: 0, y: 0};
299
- const position = new ImagePosition(bin, image);
300
- this.patterns[id] = {bin, position};
301
- } else {
302
- pattern.position.version = image.version;
303
- }
304
-
305
- this._updatePatternAtlas();
306
-
307
- return this.patterns[id].position;
308
- }
309
-
310
- bind(context: Context): void {
311
- const gl = context.gl;
312
- if (!this.atlasTexture) {
313
- this.atlasTexture = new Texture(context, this.atlasImage, gl.RGBA);
314
- } else if (this.dirty) {
315
- this.atlasTexture.update(this.atlasImage);
316
- this.dirty = false;
317
- }
318
-
319
- this.atlasTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
320
- }
321
-
322
- _updatePatternAtlas(): void {
323
- const bins = [];
324
- for (const id in this.patterns) {
325
- bins.push(this.patterns[id].bin);
326
- }
327
-
328
- const {w, h} = potpack(bins);
329
-
330
- const dst = this.atlasImage;
331
- dst.resize({width: w || 1, height: h || 1});
332
-
333
- for (const id in this.patterns) {
334
- const {bin} = this.patterns[id];
335
- const x = bin.x + padding;
336
- const y = bin.y + padding;
337
- const src = this.getImage(id).data;
338
- const w = src.width;
339
- const h = src.height;
340
-
341
- RGBAImage.copy(src, dst, {x: 0, y: 0}, {x, y}, {width: w, height: h});
342
-
343
- // Add 1 pixel wrapped padding on each side of the image.
344
- RGBAImage.copy(src, dst, {x: 0, y: h - 1}, {x, y: y - 1}, {width: w, height: 1}); // T
345
- RGBAImage.copy(src, dst, {x: 0, y: 0}, {x, y: y + h}, {width: w, height: 1}); // B
346
- RGBAImage.copy(src, dst, {x: w - 1, y: 0}, {x: x - 1, y}, {width: 1, height: h}); // L
347
- RGBAImage.copy(src, dst, {x: 0, y: 0}, {x: x + w, y}, {width: 1, height: h}); // R
348
- }
349
-
350
- this.dirty = true;
351
- }
352
-
353
343
  beginFrame(): void {
354
- this.callbackDispatchedThisFrame = {};
344
+ this._renderCallbacksDispatchedThisFrame = {};
355
345
  }
356
346
 
347
+ /**
348
+ * Re-renders the images among `ids` that were added with a `render` callback (see
349
+ * `StyleImageInterface`), at most once per frame each.
350
+ */
357
351
  dispatchRenderCallbacks(ids: string[]): void {
358
352
  for (const id of ids) {
359
-
360
- // the callback for the image was already dispatched for a different frame
361
- if (this.callbackDispatchedThisFrame[id]) continue;
362
- this.callbackDispatchedThisFrame[id] = true;
353
+ if (this._renderCallbacksDispatchedThisFrame[id]) continue;
354
+ this._renderCallbacksDispatchedThisFrame[id] = true;
363
355
 
364
356
  const image = this.getImage(id);
365
357
  if (!image) warnOnce(`Image with ID: "${id}" was not found`);
@@ -29,6 +29,7 @@ import type {StyleLayer} from '../style/style_layer.ts';
29
29
  import type {CrossFaded} from '../style/properties.ts';
30
30
  import type {LineAtlas} from './line_atlas.ts';
31
31
  import type {ImageManager} from './image_manager.ts';
32
+ import type {PatternAtlas} from './pattern_atlas.ts';
32
33
  import type {GlyphManager} from './glyph_manager.ts';
33
34
  import type {VertexBuffer} from '../webgl/vertex_buffer.ts';
34
35
  import type {IndexBuffer} from '../webgl/index_buffer.ts';
@@ -140,6 +141,7 @@ export class Painter {
140
141
  options: PainterOptions;
141
142
  lineAtlas: LineAtlas;
142
143
  imageManager: ImageManager;
144
+ patternAtlas: PatternAtlas;
143
145
  glyphManager: GlyphManager;
144
146
  depthRangeFor3D: DepthRangeType;
145
147
  opaquePassCutoff: number;
@@ -518,6 +520,7 @@ export class Painter {
518
520
 
519
521
  this.lineAtlas = style.lineAtlas;
520
522
  this.imageManager = style.imageManager;
523
+ this.patternAtlas = style.patternAtlas;
521
524
  this.glyphManager = style.glyphManager;
522
525
 
523
526
  this.symbolFadeChange = style.placement.symbolFadeChange(now());
@@ -807,8 +810,8 @@ export class Painter {
807
810
  isPatternMissing(image?: CrossFaded<ResolvedImage> | null): boolean {
808
811
  if (!image) return false;
809
812
  if (!image.from || !image.to) return true;
810
- const imagePosA = this.imageManager.getPattern(image.from.toString());
811
- const imagePosB = this.imageManager.getPattern(image.to.toString());
813
+ const imagePosA = this.patternAtlas.getPattern(image.from.toString());
814
+ const imagePosB = this.patternAtlas.getPattern(image.to.toString());
812
815
  return !imagePosA || !imagePosB;
813
816
  }
814
817
 
@@ -0,0 +1,131 @@
1
+ import {describe, expect, test, vi, beforeEach} from 'vitest';
2
+ import {ImageManager} from './image_manager.ts';
3
+ import {PatternAtlas} from './pattern_atlas.ts';
4
+ import {Context} from '../webgl/context.ts';
5
+ import {Texture} from '../webgl/texture.ts';
6
+ import {createNullGL} from '../util/test/null_gl.ts';
7
+ import {RGBAImage} from '../util/image.ts';
8
+
9
+ import type {StyleImage} from '../style/style_image.ts';
10
+
11
+ const PADDING_ON_EACH_SIDE = 2;
12
+ const sizeInAtlas = (size: number) => ({width: size + PADDING_ON_EACH_SIDE, height: size + PADDING_ON_EACH_SIDE});
13
+
14
+ function createStyleImage(size: number, fill: number = 0): StyleImage {
15
+ const data = new Uint8Array(size * size * 4).fill(fill);
16
+ return {
17
+ data: new RGBAImage({width: size, height: size}, data),
18
+ pixelRatio: 1,
19
+ sdf: false
20
+ };
21
+ }
22
+
23
+ describe('PatternAtlas', () => {
24
+ let imageManager: ImageManager;
25
+ let patternAtlas: PatternAtlas;
26
+ let context: Context;
27
+
28
+ beforeEach(() => {
29
+ vi.restoreAllMocks();
30
+ imageManager = new ImageManager();
31
+ patternAtlas = new PatternAtlas(imageManager);
32
+ context = new Context(createNullGL());
33
+ });
34
+
35
+ test('packs an image into a slot padded by one pixel on each side, and keeps handing back its position while it does not change', () => {
36
+ imageManager.addImage('pattern', createStyleImage(4));
37
+
38
+ const position = patternAtlas.getPattern('pattern');
39
+
40
+ expect(position.displaySize).toEqual([4, 4]);
41
+ expect(patternAtlas.getPixelSize()).toEqual(sizeInAtlas(4));
42
+ expect(patternAtlas.getPattern('pattern')).toBe(position);
43
+ });
44
+
45
+ test('re-packs the slot when a sprite reload replaces the image with a bigger one', () => {
46
+ imageManager.addImage('pattern', createStyleImage(4));
47
+ patternAtlas.getPattern('pattern');
48
+
49
+ imageManager.updateImage('pattern', createStyleImage(8), false);
50
+
51
+ expect(patternAtlas.getPattern('pattern').displaySize).toEqual([8, 8]);
52
+ expect(patternAtlas.getPixelSize()).toEqual(sizeInAtlas(8));
53
+ });
54
+
55
+ test('re-packs the slot when the image is removed and another one is added under the same id, even though neither carries a version', () => {
56
+ imageManager.addImage('pattern', createStyleImage(4));
57
+ const positionOfRemovedImage = patternAtlas.getPattern('pattern');
58
+
59
+ imageManager.removeImage('pattern');
60
+ imageManager.addImage('pattern', createStyleImage(8, 255));
61
+
62
+ const position = patternAtlas.getPattern('pattern');
63
+ expect(position).not.toBe(positionOfRemovedImage);
64
+ expect(position.displaySize).toEqual([8, 8]);
65
+ });
66
+
67
+ test('keeps the slot of an image that was updated in place, as an animated pattern is, and follows its version', () => {
68
+ const image = createStyleImage(4);
69
+ imageManager.addImage('pattern', image);
70
+ const position = patternAtlas.getPattern('pattern');
71
+
72
+ imageManager.updateImage('pattern', image, false);
73
+
74
+ expect(patternAtlas.getPattern('pattern')).toBe(position);
75
+ expect(position.version).toBe(image.version);
76
+ });
77
+
78
+ test('returns null for an image that was removed, and reclaims its slot on the next re-packing', () => {
79
+ imageManager.addImage('removedPattern', createStyleImage(4));
80
+ imageManager.addImage('keptPattern', createStyleImage(4));
81
+ patternAtlas.getPattern('removedPattern');
82
+ patternAtlas.getPattern('keptPattern');
83
+
84
+ imageManager.removeImage('removedPattern');
85
+
86
+ expect(patternAtlas.getPattern('removedPattern')).toBeNull();
87
+
88
+ imageManager.updateImage('keptPattern', createStyleImage(4), false);
89
+ expect(patternAtlas.getPattern('keptPattern').displaySize).toEqual([4, 4]);
90
+ expect(patternAtlas.getPixelSize()).toEqual(sizeInAtlas(4));
91
+ });
92
+
93
+ test('uploads the atlas to its texture only once it changed', () => {
94
+ const update = vi.spyOn(Texture.prototype, 'update');
95
+ const bind = vi.spyOn(Texture.prototype, 'bind');
96
+ imageManager.addImage('pattern', createStyleImage(4));
97
+ patternAtlas.getPattern('pattern');
98
+
99
+ patternAtlas.bind(context);
100
+ const uploadsToCreateTheTexture = update.mock.calls.length;
101
+ patternAtlas.bind(context);
102
+ expect(update).toHaveBeenCalledTimes(uploadsToCreateTheTexture);
103
+
104
+ imageManager.addImage('anotherPattern', createStyleImage(4));
105
+ patternAtlas.getPattern('anotherPattern');
106
+ patternAtlas.bind(context);
107
+ patternAtlas.bind(context);
108
+
109
+ expect(update).toHaveBeenCalledTimes(uploadsToCreateTheTexture + 1);
110
+ expect(bind).toHaveBeenCalledTimes(4);
111
+ });
112
+
113
+ test('destroy releases the texture and empties the atlas', () => {
114
+ const destroy = vi.spyOn(Texture.prototype, 'destroy');
115
+ imageManager.addImage('pattern', createStyleImage(4));
116
+ patternAtlas.getPattern('pattern');
117
+ patternAtlas.bind(context);
118
+
119
+ patternAtlas.destroy();
120
+
121
+ expect(destroy).toHaveBeenCalledTimes(1);
122
+ expect(patternAtlas.getPixelSize()).toEqual({width: 1, height: 1});
123
+ });
124
+
125
+ test('destroy does nothing to an atlas that was never bound', () => {
126
+ const destroy = vi.spyOn(Texture.prototype, 'destroy');
127
+
128
+ expect(() => patternAtlas.destroy()).not.toThrow();
129
+ expect(destroy).not.toHaveBeenCalled();
130
+ });
131
+ });
@@ -0,0 +1,144 @@
1
+ /* eslint-disable key-spacing */
2
+ import potpack from 'potpack';
3
+
4
+ import {RGBAImage} from '../util/image.ts';
5
+ import {ImagePosition} from './image_atlas.ts';
6
+ import {Texture} from '../webgl/texture.ts';
7
+
8
+ import type {ImageManager} from './image_manager.ts';
9
+ import type {StyleImage} from '../style/style_image.ts';
10
+ import type {Context} from '../webgl/context.ts';
11
+ import type {PotpackBox} from 'potpack';
12
+
13
+ type Pattern = {
14
+ bin: PotpackBox;
15
+ position: ImagePosition;
16
+ /**
17
+ * The image this entry was packed from. Kept to notice when an image was removed and added
18
+ * again under the same id, in which case the entry has to be rebuilt even though the version
19
+ * of the new image may happen to match the one of the old.
20
+ */
21
+ image: StyleImage;
22
+ };
23
+
24
+ /**
25
+ * When copied into the atlas texture, pattern images are padded by one pixel on each side with a
26
+ * copy of the image data wrapped from the opposite side. This ensures the correct behavior of
27
+ * GL_LINEAR texture sampling mode.
28
+ */
29
+ const PADDING = 1;
30
+
31
+ /**
32
+ * A texture atlas of the pattern images that are drawn without a tile's own {@link ImageAtlas},
33
+ * i.e. `background-pattern`. Entries are packed on demand and re-packed whenever one of them is
34
+ * added or changes, which is rare enough for the repacking cost not to matter.
35
+ */
36
+ export class PatternAtlas {
37
+ private _imageManager: ImageManager;
38
+ private _entries: Record<string, Pattern>;
39
+ private _image: RGBAImage;
40
+ private _texture: Texture;
41
+ private _dirty: boolean;
42
+
43
+ constructor(imageManager: ImageManager) {
44
+ this._imageManager = imageManager;
45
+ this._entries = {};
46
+ this._image = new RGBAImage({width: 1, height: 1});
47
+ this._dirty = true;
48
+ }
49
+
50
+ destroy(): void {
51
+ if (this._texture) {
52
+ this._texture.destroy();
53
+ this._texture = null;
54
+ }
55
+
56
+ this._entries = {};
57
+ this._image = new RGBAImage({width: 1, height: 1});
58
+ this._dirty = true;
59
+ }
60
+
61
+ getPixelSize(): {width: number; height: number} {
62
+ const {width, height} = this._image;
63
+ return {width, height};
64
+ }
65
+
66
+ /**
67
+ * @returns the position of the pattern within the atlas, or `null` if there is no such image
68
+ */
69
+ getPattern(id: string): ImagePosition {
70
+ const image = this._imageManager.getImage(id);
71
+ if (!image) {
72
+ return null;
73
+ }
74
+
75
+ const entry = this._entries[id];
76
+ if (entry?.image !== image) {
77
+ // there is no entry yet, or the image was replaced by another one under the same id,
78
+ // in which case the slot has to be packed anew - the new image may have another size
79
+ const w = image.data.width + PADDING * 2;
80
+ const h = image.data.height + PADDING * 2;
81
+ const bin = {w, h, x: 0, y: 0};
82
+ this._entries[id] = {bin, position: new ImagePosition(bin, image), image};
83
+ } else if (entry.position.version !== image.version) {
84
+ entry.position.version = image.version;
85
+ } else {
86
+ return entry.position;
87
+ }
88
+
89
+ this._update();
90
+
91
+ return this._entries[id].position;
92
+ }
93
+
94
+ bind(context: Context): void {
95
+ const gl = context.gl;
96
+ if (!this._texture) {
97
+ this._texture = new Texture(context, this._image, gl.RGBA);
98
+ this._dirty = false;
99
+ } else if (this._dirty) {
100
+ this._texture.update(this._image);
101
+ this._dirty = false;
102
+ }
103
+
104
+ this._texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
105
+ }
106
+
107
+ private _update(): void {
108
+ // drop the entries whose image is gone - they hold on to a slot of the atlas for nothing
109
+ for (const id in this._entries) {
110
+ if (!this._imageManager.getImage(id)) {
111
+ delete this._entries[id];
112
+ }
113
+ }
114
+
115
+ const bins = [];
116
+ for (const id in this._entries) {
117
+ bins.push(this._entries[id].bin);
118
+ }
119
+
120
+ const {w, h} = potpack(bins);
121
+
122
+ const dst = this._image;
123
+ dst.resize({width: w || 1, height: h || 1});
124
+
125
+ for (const id in this._entries) {
126
+ const {bin} = this._entries[id];
127
+ const x = bin.x + PADDING;
128
+ const y = bin.y + PADDING;
129
+ const src = this._entries[id].image.data;
130
+ const w = src.width;
131
+ const h = src.height;
132
+
133
+ RGBAImage.copy(src, dst, {x: 0, y: 0}, {x, y}, {width: w, height: h});
134
+
135
+ // Add 1 pixel wrapped padding on each side of the image.
136
+ RGBAImage.copy(src, dst, {x: 0, y: h - 1}, {x, y: y - 1}, {width: w, height: 1}); // T
137
+ RGBAImage.copy(src, dst, {x: 0, y: 0}, {x, y: y + h}, {width: w, height: 1}); // B
138
+ RGBAImage.copy(src, dst, {x: w - 1, y: 0}, {x: x - 1, y}, {width: 1, height: h}); // L
139
+ RGBAImage.copy(src, dst, {x: 0, y: 0}, {x: x + w, y}, {width: 1, height: h}); // R
140
+ }
141
+
142
+ this._dirty = true;
143
+ }
144
+ }