From c143761e244ab44b7aea87bfa33a8ba9f28948a6 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 4 Jun 2026 16:45:16 -0400 Subject: [PATCH] fix(deck.gl-raster): clamp Web Mercator mesh for south-up affines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createInitialWebMercatorTriangulation assumed `topLeft` is the northern corner (`north = topLeft; south = bottomLeft`), then bailed via the `north - south <= LAT_EPSILON` guard. Grids with a positive-`e` affine — where row 0 is the south pole, common for GRIB/IFS-derived reanalysis data — are stored south-up, so `topLeft` is the *southern* edge. The guard tripped on every tile, the clamp was silently skipped, and the pole was meshed: degenerate near-pole triangles that never converge ("mesh refinement did not converge") plus pole misalignment. Derive the band orientation-agnostically: latitude varies linearly along v as `lat(v) = top + v * (bottom - top)`, so intersect that segment with [-maxLat, maxLat] by solving for v at each limit and taking the overlapping interval (min/max). This keeps `v` anchored to the texture's top row, so the band is correct whether latitude increases or decreases with v. North-up behavior is unchanged. Extends #574 (issues #182 / #351). Adds regression tests for a global south-up tile and a south-up tile clamped on only the south edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/raster-tileset/web-mercator-clamp.ts | 52 +++++++++++++------ .../raster-tileset/web-mercator-clamp.test.ts | 31 +++++++++++ 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts index 6a21068f..2211c91c 100644 --- a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts +++ b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts @@ -4,7 +4,7 @@ import { triangulateRectangle } from "@developmentseed/raster-reproject"; /** Maximum latitude representable in Web Mercator (EPSG:3857), in degrees. */ const MAX_WEB_MERCATOR_LAT = 85.05112877980659; -/** Tolerance for the north-up check and degenerate-band guard, in degrees. */ +/** Tolerance for the constant-latitude check and degenerate-band guard, in degrees. */ const LAT_EPSILON = 1e-6; /** @@ -27,9 +27,11 @@ export interface CornerLatitudes { * that never converge (see #182 / #351). Seeding the reprojector with the * clamped band avoids meshing those rows entirely. * - * Only **north-up geographic** tiles are handled — where latitude is constant - * across each row, so the valid band is an axis-aligned rectangle. Rotated or - * projected tiles return `undefined` (the caller falls back to the full mesh). + * Only tiles whose **rows are constant-latitude** are handled — where latitude + * is constant across each row, so the valid band is an axis-aligned rectangle. + * + * This covers both north-up grids and south-up grids. Rotated or projected + * tiles return `undefined` (the caller falls back to the full mesh). * * @param cornerLats WGS84 latitudes of the tile's four corners. * @param maxLat Web Mercator latitude limit. Defaults to ±85.051°. @@ -40,31 +42,47 @@ export function createInitialWebMercatorTriangulation( ): InitialTriangulation | undefined { const { topLeft, topRight, bottomLeft, bottomRight } = cornerLats; - // North-up means latitude is constant across each row, so the clamp band is - // an axis-aligned rectangle. Otherwise fall back to the full mesh. - const northUp = + // Each row must be constant-latitude for the clamp band to be an axis-aligned + // rectangle in UV space. Otherwise fall back to the full mesh. + const rowsIsoLatitude = Math.abs(topLeft - topRight) < LAT_EPSILON && Math.abs(bottomLeft - bottomRight) < LAT_EPSILON; - if (!northUp) { + if (!rowsIsoLatitude) { return undefined; } - const north = topLeft; - const south = bottomLeft; - // Degenerate or south-up tile: leave it to the default full mesh. - if (north - south <= LAT_EPSILON) { + // v runs 0 (top row) → 1 (bottom row); latitude varies linearly along it: + // lat(v) = top + v * (bottom - top) + // Do NOT assume top is the northern edge: a positive-`e` (south-up) affine + // puts the south pole at row 0, so `top` is the southern edge. Deriving the + // band from the actual top/bottom keeps this orientation-agnostic. + const top = topLeft; + const bottom = bottomLeft; + + // Degenerate tile (zero latitude span): leave it to the default full mesh. + if (Math.abs(bottom - top) <= LAT_EPSILON) { return undefined; } - // Nothing to clamp if the whole tile is already within bounds. - if (north <= maxLat && south >= -maxLat) { + // Nothing to clamp if the whole tile is already within bounds (linear interp + // between two in-band corners stays in band). + if ( + top <= maxLat && + top >= -maxLat && + bottom <= maxLat && + bottom >= -maxLat + ) { return undefined; } - // v runs 0 (north) → 1 (south); lat(v) = north - v * (north - south). + // Intersect the tile's latitude segment with the band [-maxLat, maxLat]: + // solve lat(v) = ±maxLat for v, then take the overlapping v-interval. min/max + // makes this independent of whether latitude increases or decreases with v. const clamp01 = (t: number) => Math.max(0, Math.min(1, t)); - const vTop = clamp01((north - maxLat) / (north - south)); - const vBottom = clamp01((north - -maxLat) / (north - south)); + const vAtMaxLat = (maxLat - top) / (bottom - top); + const vAtMinLat = (-maxLat - top) / (bottom - top); + const vTop = clamp01(Math.min(vAtMaxLat, vAtMinLat)); + const vBottom = clamp01(Math.max(vAtMaxLat, vAtMinLat)); // Fully-polar tile (entirely outside ±maxLat): empty band, nothing to render. // Such tiles are normally excluded by the dataset-bounds clamp; guard anyway diff --git a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts index 20aadddd..dd721fdd 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts @@ -41,6 +41,37 @@ describe("createInitialWebMercatorTriangulation", () => { expect(seed?.uvs[5]).toBe(1); // south within bounds → vBottom clamped to 1 }); + it("clamps a global south-up tile (row 0 = south pole) to the valid band", () => { + // A positive-`e` affine (GRIB/IFS-derived grids) makes the top row the + // south pole, so topLeft is the southern edge. Previously this tripped the + // `north - south <= 0` guard and skipped the clamp, leaving the pole to be + // meshed (degenerate near-pole triangles → "did not converge"). See #574. + const seed = createInitialWebMercatorTriangulation({ + topLeft: -90, + topRight: -90, + bottomLeft: 90, + bottomRight: 90, + }); + expect(seed).toBeDefined(); + // v=0 is the south pole here; the band starts where lat reaches -MAX_LAT. + expect(seed?.uvs[1]).toBeCloseTo((90 - MAX_LAT) / 180, 9); // vTop + expect(seed?.uvs[5]).toBeCloseTo((90 + MAX_LAT) / 180, 9); // vBottom + expect(seed?.uvs[0]).toBe(0); + expect(seed?.uvs[2]).toBe(1); + }); + + it("clamps only the south edge of a south-up tile", () => { + // top (row 0) = south pole exceeding the bound; bottom (north) within it. + const seed = createInitialWebMercatorTriangulation({ + topLeft: -90, + topRight: -90, + bottomLeft: 80, + bottomRight: 80, + }); + expect(seed?.uvs[1]).toBeCloseTo((90 - MAX_LAT) / 170, 9); // vTop in (0,1) + expect(seed?.uvs[5]).toBe(1); // north within bounds → vBottom clamped to 1 + }); + it("returns undefined for a non-north-up (rotated) tile", () => { expect( createInitialWebMercatorTriangulation({