diff --git a/packages/deck.gl-raster/src/raster-tileset/affine-tileset-level.ts b/packages/deck.gl-raster/src/raster-tileset/affine-tileset-level.ts index 671a1f0d..e0583873 100644 --- a/packages/deck.gl-raster/src/raster-tileset/affine-tileset-level.ts +++ b/packages/deck.gl-raster/src/raster-tileset/affine-tileset-level.ts @@ -1,5 +1,6 @@ import type { Affine } from "@developmentseed/affine"; import * as affine from "@developmentseed/affine"; +import { overlappingTileSpan } from "./tile-span.js"; import type { RasterTilesetLevel } from "./tileset-interface.js"; import type { Bounds, Corners, ProjectionFunction } from "./types.js"; @@ -135,14 +136,17 @@ export class AffineTilesetLevel implements RasterTilesetLevel { const maxColIdx = this.matrixWidth - 1; const maxRowIdx = this.matrixHeight - 1; + const [firstCol, lastCol] = overlappingTileSpan(pixMinX / tw, pixMaxX / tw); + const [firstRow, lastRow] = overlappingTileSpan(pixMinY / th, pixMaxY / th); + // Asymmetric clamping: only clamp minCol/minRow up from below and // maxCol/maxRow down from above. If the bbox lies entirely outside the // array, this produces an empty range (min > max) so the consumer's // `for (i = min; i <= max; i++)` loop does nothing. - const minCol = Math.max(0, Math.floor(pixMinX / tw)); - const maxCol = Math.min(maxColIdx, Math.floor(pixMaxX / tw)); - const minRow = Math.max(0, Math.floor(pixMinY / th)); - const maxRow = Math.min(maxRowIdx, Math.floor(pixMaxY / th)); + const minCol = Math.max(0, firstCol); + const maxCol = Math.min(maxColIdx, lastCol); + const minRow = Math.max(0, firstRow); + const maxRow = Math.min(maxRowIdx, lastRow); return { minCol, maxCol, minRow, maxRow }; } diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts index fbf6e1e8..b3eb4e50 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts @@ -1010,7 +1010,28 @@ export function getTileIndices( root.getSelected(selectedNodes); } - return selectedNodes; + return uniqueTiles(selectedNodes); +} + +/** + * Drop repeated tile indices, keeping the first node for each `z/x/y`. + * + * Each parent creates its own child nodes, so a tile that is a child of two + * parents can be selected once through each. That happens when pyramid levels + * are not aligned (e.g. COG overviews with odd sizes), where a child tile can + * straddle two parents, and in TileMatrixSets whose rounded cellSizes push a + * parent's edge past a child's edge. + */ +function uniqueTiles(nodes: RasterTileNode[]): RasterTileNode[] { + const seen = new Set(); + return nodes.filter((node) => { + const key = `${node.z}/${node.x}/${node.y}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); } /** diff --git a/packages/deck.gl-raster/src/raster-tileset/tile-matrix-set.ts b/packages/deck.gl-raster/src/raster-tileset/tile-matrix-set.ts index 25136d6f..77687689 100644 --- a/packages/deck.gl-raster/src/raster-tileset/tile-matrix-set.ts +++ b/packages/deck.gl-raster/src/raster-tileset/tile-matrix-set.ts @@ -1,6 +1,7 @@ import * as affine from "@developmentseed/affine"; import type { TileMatrix, TileMatrixSet } from "@developmentseed/morecantile"; import { tileTransform, xy_bounds } from "@developmentseed/morecantile"; +import { overlappingTileSpan } from "./tile-span.js"; import type { RasterTilesetDescriptor, RasterTilesetLevel, @@ -70,7 +71,9 @@ class TileMatrixAdaptor implements RasterTilesetLevel { * 1. Treating each TileMatrix as an independent, axis-aligned grid in CRS space * 2. Mapping the parent tile's CRS bounding box into the child grid * 3. Returning the inclusive range of child tile indices whose spatial extent - * intersects the parent tile + * overlaps the parent tile. A child that only shares an edge with the + * parent is excluded, so in an aligned pyramid such as WebMercatorQuad + * each parent has exactly its 2×2 children. * * The returned indices are clamped to the valid extents of the child matrix * (`[0, matrixWidth)` and `[0, matrixHeight)`). @@ -111,11 +114,14 @@ class TileMatrixAdaptor implements RasterTilesetLevel { const originY = pointOfOrigin[1]; // Convert CRS bounds → tile indices - let minCol = Math.floor((projectedMinX - originX) / childTileWidthCRS); - let maxCol = Math.floor((projectedMaxX - originX) / childTileWidthCRS); - - let minRow = Math.floor((originY - projectedMaxY) / childTileHeightCRS); - let maxRow = Math.floor((originY - projectedMinY) / childTileHeightCRS); + let [minCol, maxCol] = overlappingTileSpan( + (projectedMinX - originX) / childTileWidthCRS, + (projectedMaxX - originX) / childTileWidthCRS, + ); + let [minRow, maxRow] = overlappingTileSpan( + (originY - projectedMaxY) / childTileHeightCRS, + (originY - projectedMinY) / childTileHeightCRS, + ); // Clamp to matrix bounds minCol = Math.max(0, Math.min(matrixWidth - 1, minCol)); diff --git a/packages/deck.gl-raster/src/raster-tileset/tile-span.ts b/packages/deck.gl-raster/src/raster-tileset/tile-span.ts new file mode 100644 index 00000000..144c07fa --- /dev/null +++ b/packages/deck.gl-raster/src/raster-tileset/tile-span.ts @@ -0,0 +1,39 @@ +/** + * How close, as a fraction of a tile, a bound must be to a tile edge to count + * as lying on it. + * + * In an aligned pyramid (WebMercatorQuad, or COG overviews that exactly halve) + * each parent tile edge falls on a child tile edge, but the CRS arithmetic + * that computes it can land a few 1e-9 of a tile to either side. 1e-6 of a + * tile is well above that error and well below a pixel. + * + * This does not absorb cellSizes that a TileMatrixSet rounds to a few + * significant digits (the OGC CDB1GlobalGrid example lists 2^-14 as + * 6.10351562e-05). Such levels drift apart in proportion to the column index, + * so far enough from the origin a parent again overlaps its neighbor's + * children. + */ +const TILE_EDGE_TOLERANCE = 1e-6; + +/** + * Find the tiles that a span overlaps along one axis of a tile grid. + * + * `start` and `end` are positions in tile units from the grid origin, so tile + * `i` spans `i` to `i + 1`. Tiles that the span only touches at an edge are + * excluded: a parent whose edges sit on child tile edges overlaps exactly the + * children it contains, not the neighbors that share those edges. + * + * @param start Start of the span, in tiles from the grid origin. + * @param end End of the span, in tiles from the grid origin. Must not be + * less than `start`. + * @returns The inclusive, unclamped index range `[first, last]`. A span + * narrower than the tolerance still returns one tile. + */ +export function overlappingTileSpan( + start: number, + end: number, +): [first: number, last: number] { + const first = Math.floor(start + TILE_EDGE_TOLERANCE); + const last = Math.ceil(end - TILE_EDGE_TOLERANCE) - 1; + return [first, Math.max(first, last)]; +} diff --git a/packages/deck.gl-raster/src/raster-tileset/tileset-interface.ts b/packages/deck.gl-raster/src/raster-tileset/tileset-interface.ts index 5069503b..5f78081b 100644 --- a/packages/deck.gl-raster/src/raster-tileset/tileset-interface.ts +++ b/packages/deck.gl-raster/src/raster-tileset/tileset-interface.ts @@ -46,6 +46,11 @@ export interface RasterTilesetLevel { * The returned range is **inclusive** on both ends: a consumer should * iterate `for (let col = minCol; col <= maxCol; col++)`. * + * Tiles that only touch the bounding box along an edge, within a small + * floating-point tolerance, are not included. Otherwise, in an aligned + * pyramid, every parent tile would also claim its neighbors' children, and + * the traversal would visit and select those children once per parent. + * * Used by the traversal algorithm to find child tiles from a parent tile's * projected bounds. */ diff --git a/packages/deck.gl-raster/tests/multi-raster-tileset/secondary-tile-resolver.test.ts b/packages/deck.gl-raster/tests/multi-raster-tileset/secondary-tile-resolver.test.ts index 1a66ce36..2a9bb730 100644 --- a/packages/deck.gl-raster/tests/multi-raster-tileset/secondary-tile-resolver.test.ts +++ b/packages/deck.gl-raster/tests/multi-raster-tileset/secondary-tile-resolver.test.ts @@ -1,5 +1,8 @@ +import * as affine from "@developmentseed/affine"; import { describe, expect, it } from "vitest"; import { resolveSecondaryTiles } from "../../src/multi-raster-tileset/secondary-tile-resolver.js"; +import { AffineTilesetLevel } from "../../src/raster-tileset/affine-tileset-level.js"; +import { overlappingTileSpan } from "../../src/raster-tileset/tile-span.js"; import type { RasterTilesetLevel } from "../../src/raster-tileset/tileset-interface.js"; import type { Corners, Point } from "../../src/raster-tileset/types.js"; @@ -56,12 +59,16 @@ function gridLevel(opts: { projectedMaxX: number, projectedMaxY: number, ) => { - // Use ceil-1 for both min and max so that exact tile boundaries are treated - // as inclusive on the left tile (the boundary point belongs to the tile ending there). - let minCol = Math.ceil((projectedMinX - originX) / tileCrsWidth) - 1; - let maxCol = Math.ceil((projectedMaxX - originX) / tileCrsWidth) - 1; - let minRow = Math.ceil((originY - projectedMaxY) / tileCrsHeight) - 1; - let maxRow = Math.ceil((originY - projectedMinY) / tileCrsHeight) - 1; + // Tiles that only share an edge with the box are left out, as the + // `RasterTilesetLevel` contract requires. + let [minCol, maxCol] = overlappingTileSpan( + (projectedMinX - originX) / tileCrsWidth, + (projectedMaxX - originX) / tileCrsWidth, + ); + let [minRow, maxRow] = overlappingTileSpan( + (originY - projectedMaxY) / tileCrsHeight, + (originY - projectedMinY) / tileCrsHeight, + ); minCol = Math.max(0, Math.min(matrixWidth - 1, minCol)); maxCol = Math.max(0, Math.min(matrixWidth - 1, maxCol)); minRow = Math.max(0, Math.min(matrixHeight - 1, minRow)); @@ -120,14 +127,59 @@ describe("resolveSecondaryTiles", () => { }); it("handles primary tile spanning two secondary tiles", () => { - // Primary tile (2,0): covers [605120, 7997440] to [607680, 8000000] - // Crosses boundary between secondary (0,0) and (1,0) - const result = resolveSecondaryTiles(primaryLevel, 2, 0, secondaryLevel, 0); - expect(result.tileIndices.length).toBe(2); - // Stitched: [600000..610240], width=10240 - // scaleX = 2560 / 10240 = 0.25, offsetX = (605120-600000)/10240 = 0.5 + // Secondary grid shifted left by half a primary tile, so its column + // boundaries fall at 603840, 608960, ... + const shiftedSecondary = gridLevel({ + originX: origin.x - 1280, + originY: origin.y, + cellSize: 20, + tileWidth: 256, + tileHeight: 256, + matrixWidth: 22, + matrixHeight: 22, + }); + // Primary tile (1,0): covers [602560, 7997440] to [605120, 8000000] + // Crosses the boundary at 603840 between secondary (0,0) and (1,0) + const result = resolveSecondaryTiles( + primaryLevel, + 1, + 0, + shiftedSecondary, + 0, + ); + expect(result.tileIndices).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]); + // Stitched: [598720..608960], width=10240 + // scaleX = 2560 / 10240 = 0.25, offsetX = (602560-598720)/10240 = 0.375 expect(result.uvTransform[2]).toBeCloseTo(0.25); - expect(result.uvTransform[0]).toBeCloseTo(0.5); + expect(result.uvTransform[0]).toBeCloseTo(0.375); + }); + + it("fetches one 20 m tile for a 10 m tile whose edges lie on its edges", () => { + // Sentinel-2-like 10 m and 20 m bands with 1024 px tiles. Primary tile + // (1,1) is the bottom-right quarter of secondary tile (0,0): its right and + // bottom edges are secondary tile edges. + const transform: affine.Affine = [10, 0, 600000, 0, -10, 5000040]; + const primary = new AffineTilesetLevel({ + affine: transform, + arrayWidth: 10980, + arrayHeight: 10980, + tileWidth: 1024, + tileHeight: 1024, + mpu: 1, + }); + const secondary = new AffineTilesetLevel({ + affine: affine.compose(transform, affine.scale(2)), + arrayWidth: 5490, + arrayHeight: 5490, + tileWidth: 1024, + tileHeight: 1024, + mpu: 1, + }); + const result = resolveSecondaryTiles(primary, 1, 1, secondary, 0); + expect(result.tileIndices).toEqual([{ x: 0, y: 0 }]); }); it("returns identity-like transform when grids align exactly", () => { diff --git a/packages/deck.gl-raster/tests/raster-tileset/create-root-tiles.test.ts b/packages/deck.gl-raster/tests/raster-tileset/create-root-tiles.test.ts index eaa889d2..87e8ed19 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/create-root-tiles.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/create-root-tiles.test.ts @@ -1,5 +1,8 @@ +import type { TileMatrixSet } from "@developmentseed/morecantile"; import { describe, expect, it } from "vitest"; import { createRootTiles } from "../../src/raster-tileset/raster-tile-traversal.js"; +import { TileMatrixSetAdaptor } from "../../src/raster-tileset/tile-matrix-set.js"; +import { overlappingTileSpan } from "../../src/raster-tileset/tile-span.js"; import type { RasterTilesetDescriptor, RasterTilesetLevel, @@ -29,22 +32,20 @@ function makeFakeLevel(opts: { throw new Error("not used"); }, crsBoundsToTileRange(minX, minY, maxX, maxY) { - const minCol = Math.max( - 0, - Math.min(matrixWidth - 1, Math.floor((minX + 180) / tileDegrees)), + // Tiles that only share an edge with the box are left out, as the + // `RasterTilesetLevel` contract requires. + const [firstCol, lastCol] = overlappingTileSpan( + (minX + 180) / tileDegrees, + (maxX + 180) / tileDegrees, ); - const maxCol = Math.max( - 0, - Math.min(matrixWidth - 1, Math.floor((maxX + 180) / tileDegrees)), - ); - const minRow = Math.max( - 0, - Math.min(matrixHeight - 1, Math.floor((90 - maxY) / tileDegrees)), - ); - const maxRow = Math.max( - 0, - Math.min(matrixHeight - 1, Math.floor((90 - minY) / tileDegrees)), + const [firstRow, lastRow] = overlappingTileSpan( + (90 - maxY) / tileDegrees, + (90 - minY) / tileDegrees, ); + const minCol = Math.max(0, Math.min(matrixWidth - 1, firstCol)); + const maxCol = Math.max(0, Math.min(matrixWidth - 1, lastCol)); + const minRow = Math.max(0, Math.min(matrixHeight - 1, firstRow)); + const maxRow = Math.max(0, Math.min(matrixHeight - 1, lastRow)); return { minCol, maxCol, minRow, maxRow }; }, }; @@ -130,4 +131,46 @@ describe("createRootTiles", () => { // 3 × 4 = 12 root tiles, all enumerated. expect(roots).toHaveLength(12); }); + + it("leaves out root tiles that only touch the viewport", () => { + // 360 × 180 one-degree root tiles, so the culled path runs. + const tms: TileMatrixSet = { + crs: "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + boundingBox: { lowerLeft: [-180, -90], upperRight: [180, 90] }, + tileMatrices: [ + { + id: "0", + scaleDenominator: 397569609.9759771, + cellSize: 1 / 256, + cornerOfOrigin: "topLeft", + pointOfOrigin: [-180, 90], + tileWidth: 256, + tileHeight: 256, + matrixWidth: 360, + matrixHeight: 180, + }, + ], + }; + const descriptor = new TileMatrixSetAdaptor(tms, { + projectTo3857: identity, + projectFrom3857: identity, + projectTo4326: identity, + projectFrom4326: identity, + }); + // Every viewport edge lies on a root tile edge. + const roots = createRootTiles({ + descriptor, + viewport: makeViewport([10, 20, 13, 22]), + datasetWgs84Bounds: [-180, -90, 180, 90], + }); + // Columns 190..192 cover longitudes 10..13, rows 68..69 latitudes 22..20. + expect(roots.map(({ x, y }) => `${x}/${y}`)).toEqual([ + "190/68", + "191/68", + "192/68", + "190/69", + "191/69", + "192/69", + ]); + }); }); diff --git a/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts b/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts new file mode 100644 index 00000000..22f722c2 --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts @@ -0,0 +1,359 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Viewport } from "@deck.gl/core"; +import { + _GlobeViewport as GlobeViewport, + WebMercatorViewport, +} from "@deck.gl/core"; +import * as affine from "@developmentseed/affine"; +import type { TileMatrixSet } from "@developmentseed/morecantile"; +import { describe, expect, it } from "vitest"; +import { AffineTileset } from "../../src/raster-tileset/affine-tileset.js"; +import { AffineTilesetLevel } from "../../src/raster-tileset/affine-tileset-level.js"; +import { getTileIndices } from "../../src/raster-tileset/raster-tile-traversal.js"; +import { TileMatrixSetAdaptor } from "../../src/raster-tileset/tile-matrix-set.js"; +import type { + RasterTilesetDescriptor, + RasterTilesetLevel, +} from "../../src/raster-tileset/tileset-interface.js"; +import type { Bounds } from "../../src/raster-tileset/types.js"; + +const R = 6378137; +const WEB_MERCATOR_EXTENT = Math.PI * R; +const MAX_LAT = 85.0511287798066; + +// Source CRS is EPSG:3857 for every fixture here. +const projections = { + projectTo3857: (x: number, y: number): [number, number] => [x, y], + projectFrom3857: (x: number, y: number): [number, number] => [x, y], + projectTo4326: (x: number, y: number): [number, number] => [ + (x * 180) / (Math.PI * R), + (Math.atan(Math.exp(y / R)) * 360) / Math.PI - 90, + ], + projectFrom4326: (lng: number, lat: number): [number, number] => [ + (lng * Math.PI * R) / 180, + Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)) * R, + ], +}; + +/** The OGC example WebMercatorQuad, whose cellSizes are rounded decimals. */ +function specWebMercatorQuad(): TileMatrixSet { + const path = resolve( + import.meta.dirname, + "../../../morecantile/spec/schemas/tms/2.0/json/examples/tilematrixset/WebMercatorQuad.json", + ); + const tms: TileMatrixSet = JSON.parse(readFileSync(path, "utf8")); + return { + ...tms, + boundingBox: { + lowerLeft: [-WEB_MERCATOR_EXTENT, -WEB_MERCATOR_EXTENT], + upperRight: [WEB_MERCATOR_EXTENT, WEB_MERCATOR_EXTENT], + }, + }; +} + +/** + * WebMercatorQuad as titiler serves it (Python morecantile): each level's + * cellSize is exactly half the previous one. + */ +function titilerWebMercatorQuad(): TileMatrixSet { + const tms = specWebMercatorQuad(); + return { + ...tms, + tileMatrices: tms.tileMatrices.map((matrix, z) => ({ + ...matrix, + cellSize: 156543.03392804097 / 2 ** z, + pointOfOrigin: [-WEB_MERCATOR_EXTENT, WEB_MERCATOR_EXTENT], + })), + }; +} + +const WEB_MERCATOR_QUADS = [ + ["spec example", specWebMercatorQuad()], + ["titiler", titilerWebMercatorQuad()], +] as const; + +/** + * COG-style pyramid: overviews of ceil(size / 2^k) pixels whose transform is + * scaled like `@developmentseed/geotiff`'s `Overview.transform`. + */ +function cogLevels(size: number, tileSize: number): AffineTilesetLevel[] { + const full: affine.Affine = [10, 0, 0, 0, -10, 5_000_000]; + const levels: AffineTilesetLevel[] = []; + for (let width = size; ; width = Math.ceil(width / 2)) { + levels.unshift( + new AffineTilesetLevel({ + affine: affine.compose(full, affine.scale(size / width)), + arrayWidth: width, + arrayHeight: width, + tileWidth: tileSize, + tileHeight: tileSize, + mpu: 1, + }), + ); + if (width <= tileSize) { + return levels; + } + } +} + +function tileBounds( + level: RasterTilesetLevel, + col: number, + row: number, +): Bounds { + const { topLeft, bottomRight } = level.projectedTileCorners(col, row); + return [topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]]; +} + +function childRange( + parent: RasterTilesetLevel, + child: RasterTilesetLevel, + col: number, + row: number, +) { + return child.crsBoundsToTileRange(...tileBounds(parent, col, row)); +} + +/** A sample of tile indices along one axis, including both far edges. */ +function sampleIndices(n: number): number[] { + if (n <= 16) { + return [...Array(n).keys()]; + } + return [0, 1, n / 2 - 1, n / 2, n - 2, n - 1]; +} + +function tileKeys(descriptor: RasterTilesetDescriptor, viewport: Viewport) { + return getTileIndices(descriptor, { + viewport, + maxZ: descriptor.levels.length - 1, + zRange: null, + wgs84Bounds: [-180, -MAX_LAT, 180, MAX_LAT], + }).map(({ x, y, z }) => `${z}/${x}/${y}`); +} + +/** + * Selected WebMercatorQuad tiles that cover ground another selected tile also + * covers: repeats, and tiles selected together with one of their ancestors. + */ +function overlappingTiles(keys: string[]): string[] { + const selected = new Set(keys); + const seen = new Set(); + return keys.filter((key) => { + const repeated = seen.has(key); + seen.add(key); + const [z, x, y] = key.split("/").map(Number) as [number, number, number]; + for (let dz = 1; dz <= z; dz++) { + if (selected.has(`${z - dz}/${x >> dz}/${y >> dz}`)) { + return true; + } + } + return repeated; + }); +} + +describe("crsBoundsToTileRange excludes tiles that only share an edge", () => { + describe.each(WEB_MERCATOR_QUADS)("WebMercatorQuad (%s)", (_, tms) => { + const { levels } = new TileMatrixSetAdaptor(tms, projections); + + it("maps tile 1/0/0 to its four children", () => { + expect(childRange(levels[1]!, levels[2]!, 0, 0)).toEqual({ + minCol: 0, + maxCol: 1, + minRow: 0, + maxRow: 1, + }); + }); + + it("maps every sampled tile at every zoom to its 2×2 children", () => { + const mismatches: string[] = []; + for (let z = 0; z < levels.length - 1; z++) { + for (const x of sampleIndices(2 ** z)) { + for (const y of sampleIndices(2 ** z)) { + const range = childRange(levels[z]!, levels[z + 1]!, x, y); + const expected = { + minCol: 2 * x, + maxCol: 2 * x + 1, + minRow: 2 * y, + maxRow: 2 * y + 1, + }; + if (JSON.stringify(range) !== JSON.stringify(expected)) { + mismatches.push(`${z}/${x}/${y}`); + } + } + } + } + expect(mismatches).toEqual([]); + }); + }); + + it("maps each tile of a power-of-two COG pyramid to its 2×2 children", () => { + const levels = cogLevels(8192, 512); + for (let z = 0; z < levels.length - 1; z++) { + for (let x = 0; x < levels[z]!.matrixWidth; x++) { + expect(childRange(levels[z]!, levels[z + 1]!, x, x)).toEqual({ + minCol: 2 * x, + maxCol: 2 * x + 1, + minRow: 2 * x, + maxRow: 2 * x + 1, + }); + } + } + }); + + it("maps a tile to the single tile beneath it when the footprints match", () => { + // Like the last Sentinel-2 overview: tileWidth doubles while cellSize + // halves, so each parent covers exactly one child. + const extent: affine.Affine = [20, 0, 0, 0, -20, 5_000_000]; + const parent = new AffineTilesetLevel({ + affine: extent, + arrayWidth: 2048, + arrayHeight: 2048, + tileWidth: 512, + tileHeight: 512, + mpu: 1, + }); + const child = new AffineTilesetLevel({ + affine: affine.compose(extent, affine.scale(0.5)), + arrayWidth: 4096, + arrayHeight: 4096, + tileWidth: 1024, + tileHeight: 1024, + mpu: 1, + }); + expect(childRange(parent, child, 1, 2)).toEqual({ + minCol: 1, + maxCol: 1, + minRow: 2, + maxRow: 2, + }); + }); + + it("keeps every child that overlaps its parent in a non-aligned COG pyramid", () => { + // 10980 px (a Sentinel-2 tile) halves exactly to 5490 and 2745, then to + // 1373, 687 and 344 after rounding up, so only the finer levels line up. + const levels = cogLevels(10980, 512); + for (let z = 0; z < levels.length - 1; z++) { + const parent = levels[z]!; + const child = levels[z + 1]!; + const childTile = tileBounds(child, 0, 0); + const sliver = 1e-6 * (childTile[2] - childTile[0]); + for (let x = 0; x < parent.matrixWidth; x++) { + const [minX, , maxX] = tileBounds(parent, x, 0); + const overlapping: number[] = []; + for (let col = 0; col < child.matrixWidth; col++) { + const [childMinX, , childMaxX] = tileBounds(child, col, 0); + if (Math.min(maxX, childMaxX) - Math.max(minX, childMinX) > sliver) { + overlapping.push(col); + } + } + const range = childRange(parent, child, x, 0); + expect([range.minCol, range.maxCol]).toEqual([ + overlapping[0], + overlapping.at(-1), + ]); + } + } + }); +}); + +describe("getTileIndices", () => { + const viewports: [string, Viewport][] = [ + [ + "zoom 3", + new WebMercatorViewport({ + longitude: 0, + latitude: 0, + zoom: 3, + width: 512, + height: 512, + }), + ], + [ + "zoom 10", + new WebMercatorViewport({ + longitude: 0, + latitude: 0, + zoom: 10, + width: 512, + height: 512, + }), + ], + [ + "pitched and rotated", + new WebMercatorViewport({ + longitude: -122.42, + latitude: 37.77, + zoom: 12, + width: 1024, + height: 768, + pitch: 60, + bearing: 30, + }), + ], + [ + "pitched across zoom levels", + new WebMercatorViewport({ + longitude: 20, + latitude: 40, + zoom: 2, + width: 1280, + height: 720, + pitch: 45, + }), + ], + [ + "world copies", + new WebMercatorViewport({ + longitude: 170, + latitude: 10, + zoom: 1.5, + width: 1600, + height: 800, + repeat: true, + }), + ], + [ + "globe", + new GlobeViewport({ + longitude: 0, + latitude: 20, + zoom: 2, + width: 800, + height: 600, + resolution: 10, + }), + ], + ]; + + describe.each(WEB_MERCATOR_QUADS)("WebMercatorQuad (%s)", (_, tms) => { + const descriptor = new TileMatrixSetAdaptor(tms, projections); + + it.each(viewports)( + "selects tiles that do not overlap (%s)", + (_, viewport) => { + expect(overlappingTiles(tileKeys(descriptor, viewport))).toEqual([]); + }, + ); + }); + + it("selects each tile once when levels are not aligned", () => { + const descriptor = new AffineTileset({ + levels: cogLevels(10980, 512), + ...projections, + }); + const [longitude, latitude] = projections.projectTo4326( + 54900, + 5_000_000 - 54900, + ); + const viewport = new WebMercatorViewport({ + longitude, + latitude, + zoom: 10, + width: 1024, + height: 768, + }); + const keys = tileKeys(descriptor, viewport); + expect(keys.length).toBe(new Set(keys).size); + }); +});