fix: handle antimeridian-crossing tiles in Web Mercator reprojection#374
fix: handle antimeridian-crossing tiles in Web Mercator reprojection#374james-willis wants to merge 1 commit into
Conversation
Tiles near ±180° longitude cause RasterReprojector mesh refinement to diverge because forwardTo3857 maps nearby source coordinates on opposite sides of the antimeridian to EPSG:3857 x-values ~40M meters apart. Detect these tiles by checking if corner x-values in EPSG:3857 span more than half the world circumference, then wrap the projection functions to make the coordinate space continuous. Extract the wrapping logic into wrapAntimeridianProjections() in proj.ts with unit tests covering no-op passthrough, wrapping detection, coordinate continuity, and forward/inverse round-tripping. Closes developmentseed#366
39cf0d5 to
7280242
Compare
|
I'm still thinking through how we'd want to handle generic antimeridian-crossing. At a minimum this PR needs:
Also |
| const WEB_MERCATOR_METER_CIRCUMFERENCE = 2 * Math.PI * WGS84_ELLIPSOID_A; | ||
| const HALF_CIRCUMFERENCE = WEB_MERCATOR_METER_CIRCUMFERENCE / 2; | ||
|
|
||
| type ProjectionFn = (x: number, y: number) => [number, number]; |
There was a problem hiding this comment.
Edit you can use the copy in transform-bounds.ts
There was a problem hiding this comment.
Pull request overview
Fixes Web Mercator reprojection instability for EPSG:4326 COG tiles that cross the antimeridian by making per-tile EPSG:3857 X coordinates continuous, preventing RasterReprojector mesh refinement from diverging.
Changes:
- Added
wrapAntimeridianProjectionsutility to conditionally wrap EPSG:3857 forward/inverse projections for antimeridian-crossing tiles. - Applied the wrapping logic in
COGLayer._renderSubLayersfor the Web Mercator rendering path. - Added unit tests covering wrapping detection, continuity, and forward/inverse round-tripping.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/deck.gl-geotiff/src/proj.ts | Introduces wrapAntimeridianProjections and supporting Web Mercator circumference constants. |
| packages/deck.gl-geotiff/src/cog-layer.ts | Detects antimeridian-crossing tiles via projected corner X span and uses wrapped reprojection fns in Web Mercator mode. |
| packages/deck.gl-geotiff/tests/proj.test.ts | Adds tests for wrapping behavior, continuity across ±180°, and round-trip correctness. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const wrapped = wrapAntimeridianProjections( | ||
| cornerXs, | ||
| forwardTo3857, | ||
| inverseFrom3857, | ||
| ); |
There was a problem hiding this comment.
wrapAntimeridianProjections creates new closure functions for forwardReproject/inverseReproject on every _renderSubLayers call when wrapping is needed. RasterLayer.updateState treats changes in these function identities as a reason to regenerate the adaptive mesh, so antimeridian tiles can end up re-meshing every render. Consider memoizing/caching the wrapped projection functions (e.g., once per COGLayer instance or via a WeakMap keyed by the original fns) so their identities remain stable across renders for the same source projection.
There was a problem hiding this comment.
This does seem like a valid critique. I'm not sure at a glance how it could be fixed.
| /** | ||
| * If a tile's EPSG:3857 corner x-values span more than half the globe, wrap | ||
| * `forwardTo3857` / `inverseFrom3857` so the coordinate space is continuous. | ||
| * | ||
| * Returns the original functions unchanged when no wrapping is needed. | ||
| */ |
There was a problem hiding this comment.
Would this heuristic trigger on global rasters?
Since we have the corners of the raster in WGS84, can't we instead just check to see whether the left longitude is greater than the right longitude?
There was a problem hiding this comment.
Thanks @gadomski, this is a good point.
If we have an image that's 512px wide, 256px tall that covers the entire EPSG:4326 coordinate space, then it would trigger this function, even though it doesn't wrap the antimeridian.
(We should add unit test cases that such an input image doesn't trigger antimeridian handling)
…hes to ±85.051° (#574) * docs(specs): add antimeridian crossing-tile (cut-in-two) design Design for rendering imagery crossing ±180° in Web Mercator (issues #171, #366). Splits a crossing tile at the antimeridian into west/east pieces so each reprojects as a normal non-crossing tile — avoiding the proj4-rewrap unwrap that prior attempts (#353/#374/#269) stumbled on. Generalizes the RasterReprojector to accept a delaunator-shaped initial-triangulation seed (subsuming #351 uvBounds / pole clamp), splits in _renderSubLayers into two single-mesh RasterLayers, and uses a two-box bounding volume composing with the merged world-copy traversal (#518). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(specs): refine antimeridian spec — add test fixture, handle slanted cuts - Use the vendored geotiff-test-data antimeridian.tif fixture (42x42 EPSG:4326, crosses -180 at column 24) as the primary deterministic crossing test. - Handle slanted (rotated-geotransform) cuts, not just vertical: any straight cut yields convex pieces delaunator triangulates exactly; error only on curved/concave cuts. createInitialConditions is therefore part of the MVP crossing path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raster-reproject): add createInitialConditions (delaunator-backed seed) Add the InitialTriangulation type and a tree-shakeable createInitialConditions helper that builds a Delaunay seed from a UV point set. delaunator is confined to its own module (initial-conditions.ts); delatin.ts does not import it and the package is sideEffects:false, so it tree-shakes out for consumers that don't use it. Foundation for antimeridian cut-in-two and the sub-domain capability in #351. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raster-reproject): document delaunator seed pattern, don't ship a wrapper Per review: a one-line delaunator wrapper isn't worth a runtime dependency. Expose only the InitialTriangulation type and show the delaunator one-liner in its docstring. delaunator moves to devDependencies — used by tests to validate winding compatibility, not shipped to consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(specs): drop createInitialConditions wrapper from antimeridian design raster-reproject exposes only the InitialTriangulation type + documents the delaunator one-liner; delaunator is a dev/test dep (winding validation), not shipped. Runtime seed-building for crossing tiles is the deck.gl-raster builder's job (follow-up plan). Mark stage 1 done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raster-reproject): seed RasterReprojector from an initial triangulation Generalize the constructor to accept an optional initialTriangulation seed (delaunator's data shape), defaulting to a hardcoded unit-square seed so behavior is unchanged and the package needs no runtime triangulation dep. Refinement only ever splits existing triangles, so a sub-domain seed confines the mesh to that region. Tests build seeds via delaunator (the documented pattern) to validate winding compatibility + sub-domain confinement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raster-reproject): add rectangleSeed helper for sub-rectangle seeds Build an axis-aligned 2-triangle rectangle seed for a UV sub-rectangle (no delaunator, runtime-safe). UNIT_SQUARE_SEED is now rectangleSeed(0,0,1,1). Used to clamp a mesh to a UV band — e.g. the valid Web Mercator latitude band (#182 / #351) — and reused by the antimeridian vertical-cut case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deck.gl-raster): add webMercatorClampSeed (clamp mesh to ±85.051°) Pure helper that returns a rectangleSeed clamping a north-up geographic tile's reprojection mesh to the Web Mercator latitude band, or undefined when no clamp is needed/possible (rotated/projected tiles, fully-polar tiles). Avoids the degenerate near-pole triangles from #182 / #351. Unit-tested with synthetic corner latitudes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deck.gl-raster): clamp Web Mercator meshes to valid latitude band Wire the reprojector seed through the render path: RasterLayer gains an initialTriangulation prop (passed to RasterReprojector, regenerated on change); getTileMetadata computes a per-tile _webMercatorReprojectorSeed via webMercatorClampSeed; _renderSubLayers passes it in the Web Mercator branch only (globe shows the poles, full mesh). Fixes the degenerate near-pole triangles for EPSG:4326 imagery reaching ±90° (#182 / #351). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(deck.gl-raster): name clamp seed as initialTriangulation Rename webMercatorClampSeed → webMercatorInitialTriangulation and the tile metadata field _webMercatorReprojectorSeed → _webMercatorInitialTriangulation, for consistency with the InitialTriangulation type and the RasterLayer.initialTriangulation prop (drops the ad-hoc 'seed'/'ReprojectorSeed' terms). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(deck.gl-raster): rename clamp fn to createInitialWebMercatorTriangulation Verb-prefixed name for the builder (was webMercatorInitialTriangulation); the tile metadata field stays _webMercatorInitialTriangulation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raster-reproject): rename rectangleSeed -> triangulateRectangle Active verb name for the helper that triangulates a UV rectangle into an InitialTriangulation; internal UNIT_SQUARE_SEED -> UNIT_SQUARE_TRIANGULATION; test file renamed to match. Updates the deck.gl-raster clamp caller too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: apply triangulateRectangle rename across source + caller Completes the rename (a657eb8 only moved the test file): rectangleSeed -> triangulateRectangle in delatin.ts + index export + the renamed test, and the deck.gl-raster web-mercator-clamp caller. UNIT_SQUARE_SEED -> UNIT_SQUARE_TRIANGULATION. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fixes #366 — COG tiles near ±180° longitude cause
RasterReprojectormesh refinement to diverge in Web Mercator mode.Problem
When rendering an EPSG:4326 COG that spans the antimeridian,
forwardTo3857maps nearby source coordinates on opposite sides of ±180° to EPSG:3857 x-values ~40 million meters apart (+20M vs -20M). The mesh triangles connecting these vertices span the entire map, and the reprojection error never converges (currentError=43200after 10001 iterations).Solution
In
COGLayer._renderSubLayers, for the Web Mercator code path:+circumferenceso the tile's coordinate space is continuous (all positive), and unwrap in the inverse directionThis makes the mesh subdivision see a smooth, continuous coordinate space instead of a discontinuity, allowing
RasterReprojectorto converge normally.Changes
packages/deck.gl-geotiff/src/cog-layer.ts:HALF_CIRCUMFERENCEconstant_renderSubLayersTesting
pnpm build)pnpm lint)