From 2314ce4d2085b3a63e0e10a9f87bce521aca3935 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 29 May 2026 11:09:35 -0400 Subject: [PATCH] fix: clip AffineTilesetLevel.projectedTileCorners to array extent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiles at the right/bottom edge of a level's matrix can nominally extend past the array when matrixWidth*tileWidth > arrayWidth (likewise for height). The affine still extrapolates past the data, but for projections whose valid domain isn't a rectangle aligned with the affine — Mollweide, Sinusoidal, Equal Earth — those extrapolated points fall outside the projection's domain and proj4 collapses them onto the pole. The downstream Web Mercator clamp then sends every such corner to one horizontal line at ±85.05°, producing a degenerate bounding volume that the `tileMaxY > minY` check in `insideBounds` rejects (0 > 0 is false). Every tile gets culled and nothing renders. Clip the tile's pixel-space rectangle to [0, arrayWidth]×[0, arrayHeight] before applying the affine so every corner stays inside the data extent. Interior tiles are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/cog-basic/src/App.tsx | 4 ++ .../raster-tileset/affine-tileset-level.ts | 29 +++++++++++++-- .../affine-tileset-level.test.ts | 37 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/examples/cog-basic/src/App.tsx b/examples/cog-basic/src/App.tsx index 0cee06a9..34fcfbea 100644 --- a/examples/cog-basic/src/App.tsx +++ b/examples/cog-basic/src/App.tsx @@ -15,6 +15,10 @@ import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [ + { + title: "HFP 100", + url: "https://data.source.coop/vizzuality/hfp-100/hfp_2017_100m_v1-2_cog.tif", + }, { title: "Sentinel-2 True Color Image (New York, 2026)", url: "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif", 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..66ef3d2e 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 @@ -44,10 +44,14 @@ export class AffineTilesetLevel implements RasterTilesetLevel { private readonly _affine: Affine; private readonly _invAffine: Affine; + private readonly _arrayWidth: number; + private readonly _arrayHeight: number; constructor(options: AffineTilesetLevelOptions) { this._affine = options.affine; this._invAffine = affine.invert(options.affine); + this._arrayWidth = options.arrayWidth; + this._arrayHeight = options.arrayHeight; this.tileWidth = options.tileWidth; this.tileHeight = options.tileHeight; this.matrixWidth = Math.ceil(options.arrayWidth / options.tileWidth); @@ -79,11 +83,28 @@ export class AffineTilesetLevel implements RasterTilesetLevel { const th = this.tileHeight; const af = this._affine; + // Clip the tile's pixel-space rectangle to the array extent. Tiles at the + // right/bottom edge of the matrix may extend past the array when + // `matrixWidth*tileWidth > arrayWidth` (likewise for height); the affine + // still extrapolates past the data, but for projections whose valid + // domain isn't a rectangle aligned with the affine (e.g. Mollweide, + // Sinusoidal, Equal Earth) those extrapolated points fall outside the + // projection domain. The downstream bounding-volume sampler reprojects + // these corners and, for points outside the domain, sees them collapse to + // the pole — producing a degenerate volume that frustum culling rejects. + // Clipping to the actual data extent keeps every corner inside the + // projection's domain (modulo the projection's own corner-cases) and is + // also the semantically correct extent for the tile's data. + const x0 = Math.min(col * tw, this._arrayWidth); + const x1 = Math.min((col + 1) * tw, this._arrayWidth); + const y0 = Math.min(row * th, this._arrayHeight); + const y1 = Math.min((row + 1) * th, this._arrayHeight); + return { - topLeft: affine.apply(af, col * tw, row * th), - topRight: affine.apply(af, (col + 1) * tw, row * th), - bottomLeft: affine.apply(af, col * tw, (row + 1) * th), - bottomRight: affine.apply(af, (col + 1) * tw, (row + 1) * th), + topLeft: affine.apply(af, x0, y0), + topRight: affine.apply(af, x1, y0), + bottomLeft: affine.apply(af, x0, y1), + bottomRight: affine.apply(af, x1, y1), }; } diff --git a/packages/deck.gl-raster/tests/raster-tileset/affine-tileset-level.test.ts b/packages/deck.gl-raster/tests/raster-tileset/affine-tileset-level.test.ts index 8125e5b7..db734a64 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/affine-tileset-level.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/affine-tileset-level.test.ts @@ -102,6 +102,43 @@ describe("AffineTilesetLevel", () => { expect(corners.topRight[0]).toBeCloseTo(100 + 4 * 10 * Math.cos(rad), 10); expect(corners.topRight[1]).toBeCloseTo(200 + 4 * 10 * Math.sin(rad), 10); }); + + it("clips an edge tile's corners to the array extent when tileSize does not divide arraySize evenly", () => { + // 5x3 array tiled in 4x4 chunks → 2x1 matrix; tile (1, 0) nominally + // covers pixels [4..8, 0..4] but the array only has data at [4..5, 0..3]. + const level = new AffineTilesetLevel({ + affine: SQUARE_AFFINE, + arrayWidth: 5, + arrayHeight: 3, + tileWidth: 4, + tileHeight: 4, + mpu: 1, + }); + const corners = level.projectedTileCorners(1, 0); + // Without clipping, topRight would be at affine(8, 0) = (180, 200). + // With clipping it should sit at affine(5, 0) = (150, 200). + expect(corners.topLeft).toEqual([140, 200]); + expect(corners.topRight).toEqual([150, 200]); + expect(corners.bottomLeft).toEqual([140, 170]); + expect(corners.bottomRight).toEqual([150, 170]); + }); + + it("leaves interior tiles unaffected by clipping", () => { + // Interior tile (0, 0) is well within the array — clipping is a no-op. + const level = new AffineTilesetLevel({ + affine: SQUARE_AFFINE, + arrayWidth: 12, + arrayHeight: 12, + tileWidth: 4, + tileHeight: 4, + mpu: 1, + }); + const corners = level.projectedTileCorners(0, 0); + expect(corners.topLeft).toEqual([100, 200]); + expect(corners.topRight).toEqual([140, 200]); + expect(corners.bottomLeft).toEqual([100, 160]); + expect(corners.bottomRight).toEqual([140, 160]); + }); }); describe("tileTransform", () => {