From 66568f0275e8cdcc26bf658738118c8669a64747 Mon Sep 17 00:00:00 2001 From: willjnz Date: Fri, 21 Aug 2026 11:25:05 +1000 Subject: [PATCH] fix(mosaic): repeat MosaicTileset2D spatial search across world copies MosaicTileset2D.getTileIndices queried its Flatbush spatial index once, using the viewport's raw (possibly unwrapped) longitude bounds. Once a WebMercatorViewport with repeat: true pans past +-180 degrees, viewportBounds extends past that range while sources are still indexed at their true, unwrapped bbox, so items on the far side of the antimeridian were never selected -- RasterTileLayer's own world-copy traversal (#517 / #518) never gets a chance to run, since a source's layer is never built if it's never selected. Repeats the query at world-copy offsets (same subViewports.length > 1 gate and MAX_MAPS walk-until-empty pattern as raster-tile-traversal.ts) shifting the search bounds by worldOffset * 360 degrees of longitude instead of translating a bounding volume, since Flatbush operates on WGS84 degrees rather than deck.gl common space. Documented as a second, independent layer in dev-docs/world-copies.md. --- dev-docs/world-copies.md | 22 ++++++++ .../src/mosaic-layer/mosaic-tileset-2d.ts | 53 ++++++++++++++++++- .../tests/mosaic-tileset-2d.test.ts | 47 ++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/dev-docs/world-copies.md b/dev-docs/world-copies.md index e5908fb6..4d1c0d10 100644 --- a/dev-docs/world-copies.md +++ b/dev-docs/world-copies.md @@ -79,9 +79,31 @@ Differences: - We cache the offset-0 OBB per `(zRange)` and translate from the cache rather than recomputing per offset. +## A second, independent layer: MosaicTileset2D source selection + +This file focusses primarily on how *pixel tiles* are selected within an already-open COG, inside +`RasterTileLayer`. A separate concern sits one level up: `MosaicLayer`'s +[`MosaicTileset2D`](../packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts) +decides which `sources` (each `{ id?, bbox }`, see +`MosaicSource`) are visible at all via a Flatbush spatial query over source +bboxes *before* any `RasterTileLayer` for that source exists. `viewport.getBounds()` +returns unwrapped longitudes once the camera pans past ±180°, so a single, +un-repeated query misses sources whose bbox sits on the far side of the seam. +A source that's never selected here never gets a `RasterTileLayer`, so the +fix above can't help it. + +The fix mirrors this doc's model: `subViewports.length > 1` gate and same +`MAX_MAPS` walk-until-empty pattern but shifts the *query bounds* by +`worldOffset * 360°` of longitude instead of translating a bounding volume by +`worldOffset * 512` in common space, since Flatbush operates on WGS84 +degrees, not deck.gl common space. Matches are deduplicated across offsets +(a `Set`) since the same source can satisfy more than one offset's query at +once (e.g. a source near the prime meridian at extreme zoom-out). + ## Out of scope - The large-zarr-root cull path in `createRootTiles` does not yet consider world-copy intersections. For typical OGC pyramids this doesn't matter (root tiles are enumerated unconditionally). If a zarr dataset hits the same symptom, generalize that path. +- Antimeridian issues such as COGs whose bbox crosses ±180° in WGS84 longitude will be handled separately. diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts index 2486a431..975886ce 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts @@ -4,6 +4,14 @@ import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import { _sortItemsByDistanceFromViewportCenter as sortItemsByDistanceFromViewportCenter } from "@developmentseed/deck.gl-raster"; import type Flatbush from "flatbush"; +/** + * Maximum number of world copies to test on each side of the primary world + * so max 7 (primary + 3 either side if visible). Mirrors + * `raster-tile-traversal.ts`'s `MAX_MAPS`, which in turn matches upstream + * `@deck.gl/geo-layers/tile-2d-traversal.ts`. + */ +const MAX_MAPS = 3; + /** Tile index. * * Note this is essentially just to type-check deck.gl, since getTileIndices @@ -109,10 +117,25 @@ export class MosaicTileset2D extends Tileset2D { } const viewportBounds = viewport.getBounds(); - const indices = index.search(...viewportBounds); + const matched = new Set(index.search(...viewportBounds)); + + // World-copy passes: see "MosaicTileset2D source selection" in + // dev-docs/world-copies.md. + if ((viewport.subViewports?.length ?? 0) > 1) { + for (let worldOffset = -1; worldOffset >= -MAX_MAPS; worldOffset--) { + if (!searchAtOffset(index, viewportBounds, worldOffset, matched)) { + break; + } + } + for (let worldOffset = 1; worldOffset <= MAX_MAPS; worldOffset++) { + if (!searchAtOffset(index, viewportBounds, worldOffset, matched)) { + break; + } + } + } const sources = this.getSources(); - const selectedSources = indices.map((sourceIndex) => { + const selectedSources = [...matched].map((sourceIndex) => { const source = sources[sourceIndex]!; return { // Remove once https://github.com/visgl/deck.gl/pull/10299 @@ -140,3 +163,29 @@ export class MosaicTileset2D extends Tileset2D { ); } } + +/** + * Query `index` with `bounds` shifted by `worldOffset * 360°` of longitude, + * adding any matched source indices into `matched`. Returns `true` if this + * offset matched anything, signaling the caller to keep walking further from + * the primary world; `false` stops that direction's walk (the offset has + * moved past the visible range). + */ +function searchAtOffset( + index: Flatbush, + bounds: [number, number, number, number], + worldOffset: number, + matched: Set, +): boolean { + const shift = worldOffset * 360; + const found = index.search( + bounds[0] - shift, + bounds[1], + bounds[2] - shift, + bounds[3], + ); + for (const i of found) { + matched.add(i); + } + return found.length > 0; +} diff --git a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts index ed4089b1..9eeef507 100644 --- a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts +++ b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts @@ -8,12 +8,14 @@ import { MosaicTileset2D } from "../src/mosaic-layer/mosaic-tileset-2d.js"; function makeViewport( bounds: [number, number, number, number], zoom = 5, + subViewports: unknown[] | null = null, ): Viewport { return { equals: () => false, resolution: undefined, zoom, getBounds: () => bounds, + subViewports, } as unknown as Viewport; } @@ -151,3 +153,48 @@ describe("MosaicTileset2D tile ids", () => { expect(tileset.getTileId(result[0]!)).toBe("stable-id"); }); }); + +describe("MosaicTileset2D world-copy passes", () => { + // A source just west of the dateline and one just east of it, as a real + // antimeridian-straddling STAC collection would produce. + const west: Item = { name: "west", bbox: [176, -5, 179, 5] }; + const east: Item = { name: "east", bbox: [-179, -5, -176, 5] }; + + it("selects sources on both sides of the antimeridian when the viewport straddles it", () => { + const tileset = makeTileset([west, east]); + // Camera panned just past +180°; bounds extend past 180 while `east`'s + // bbox is still expressed in its true, unwrapped [-180, 180] range. + const viewport = makeViewport([175, -10, 183, 10], 5, [{}, {}]); + const result = tileset.getTileIndices({ viewport }); + expect(result.map((s) => s.name).sort()).toEqual(["east", "west"]); + }); + + it("finds a source via a shifted world copy after panning fully past +180°", () => { + const tileset = makeTileset([east]); + // Camera centered around longitude ~187°; raw bounds no longer overlap + // `east`'s true bbox at all without the -360°-shifted offset pass. + const viewport = makeViewport([183, -10, 191, 10], 5, [{}, {}]); + const result = tileset.getTileIndices({ viewport }); + expect(result.map((s) => s.name)).toEqual(["east"]); + }); + + it.each([ + ["only one world copy is visible", [{}]], + ["subViewports is absent (e.g. Globe view)", null], + ])("does not run world-copy passes when %s", (_label, subViewports) => { + const tileset = makeTileset([east]); + const viewport = makeViewport([183, -10, 191, 10], 5, subViewports); + expect(tileset.getTileIndices({ viewport })).toEqual([]); + }); + + it("selects a source matched by multiple offsets only once", () => { + const center: Item = { name: "center", bbox: [-1, -5, 1, 5] }; + const tileset = makeTileset([center]); + // Extremely wide bounds so both the offset 0 and offset -1 passes + // independently overlap the same source. + const viewport = makeViewport([-370, -10, 10, 10], 5, [{}, {}, {}]); + const result = tileset.getTileIndices({ viewport }); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("center"); + }); +});