From 0ac9e2c38e1229a3bf3e90c8a4003cbf3e835a67 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 1 Oct 2026 19:32:29 -0400 Subject: [PATCH 1/2] fix(deck.gl-raster): Leave edge-touching tiles out of crsBoundsToTileRange Both implementations floored the max edge, so in an aligned pyramid (WebMercatorQuad, or COG overviews that exactly halve) most parents also claimed their right and bottom neighbors' children, and the traversal selected those once per parent: getTileIndices returned 286 indices for 9 tiles at zoom 10 with titiler's WebMercatorQuad, and selected some children inside coarser tiles it had also selected. Tiles that only touch the box (within 1e-6 of a tile) are now left out, which also tightens root culling and MultiCOGLayer's secondary tile lookup. Co-Authored-By: Claude Opus 5.5 --- .../raster-tileset/affine-tileset-level.ts | 12 +- .../src/raster-tileset/tile-matrix-set.ts | 18 +- .../src/raster-tileset/tile-span.ts | 39 ++ .../src/raster-tileset/tileset-interface.ts | 5 + .../secondary-tile-resolver.test.ts | 78 +++- .../raster-tileset/create-root-tiles.test.ts | 71 +++- .../raster-tileset/tile-children.test.ts | 338 ++++++++++++++++++ 7 files changed, 524 insertions(+), 37 deletions(-) create mode 100644 packages/deck.gl-raster/src/raster-tileset/tile-span.ts create mode 100644 packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts 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/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..f3383989 --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts @@ -0,0 +1,338 @@ +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 { 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([]); + }, + ); + }); +}); From 79569aa22850d59466521f6a4f0a5cb914e0cd3b Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 1 Oct 2026 19:32:45 -0400 Subject: [PATCH 2/2] fix(deck.gl-raster): Return each tile once from getTileIndices When pyramid levels are not aligned (e.g. COG overviews of odd sizes), a child tile can straddle two parents, and the traversal selects it once through each: a 10980 px COG still returned 16 indices for 9 tiles. Keep the first node for each z/x/y. Co-Authored-By: Claude Opus 5.5 --- .../raster-tileset/raster-tile-traversal.ts | 23 ++++++++++++++++++- .../raster-tileset/tile-children.test.ts | 21 +++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) 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/tests/raster-tileset/tile-children.test.ts b/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts index f3383989..22f722c2 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/tile-children.test.ts @@ -8,6 +8,7 @@ import { 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"; @@ -335,4 +336,24 @@ describe("getTileIndices", () => { }, ); }); + + 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); + }); });