Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions dev-docs/world-copies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
53 changes: 51 additions & 2 deletions packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,10 +117,25 @@ export class MosaicTileset2D<MosaicT extends MosaicSource> extends Tileset2D {
}

const viewportBounds = viewport.getBounds();
const indices = index.search(...viewportBounds);
const matched = new Set<number>(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
Expand Down Expand Up @@ -140,3 +163,29 @@ export class MosaicTileset2D<MosaicT extends MosaicSource> 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<number>,
): 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;
}
47 changes: 47 additions & 0 deletions packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
});
});
Loading