Skip to content

fix: handle antimeridian-crossing tiles in Web Mercator reprojection#374

Open
james-willis wants to merge 1 commit into
developmentseed:mainfrom
james-willis:fix/antimeridian-mesh-divergence
Open

fix: handle antimeridian-crossing tiles in Web Mercator reprojection#374
james-willis wants to merge 1 commit into
developmentseed:mainfrom
james-willis:fix/antimeridian-mesh-divergence

Conversation

@james-willis

Copy link
Copy Markdown

Summary

Fixes #366 — COG tiles near ±180° longitude cause RasterReprojector mesh refinement to diverge in Web Mercator mode.

Problem

When rendering an EPSG:4326 COG that spans the antimeridian, forwardTo3857 maps 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=43200 after 10001 iterations).

Solution

In COGLayer._renderSubLayers, for the Web Mercator code path:

  1. Detect antimeridian-crossing tiles — project the four tile corners to EPSG:3857 and check whether their x-values span more than half the world circumference (~20M meters)
  2. Wrap the projection functions — shift negative EPSG:3857 x-values by +circumference so the tile's coordinate space is continuous (all positive), and unwrap in the inverse direction

This makes the mesh subdivision see a smooth, continuous coordinate space instead of a discontinuity, allowing RasterReprojector to converge normally.

Changes

  • packages/deck.gl-geotiff/src/cog-layer.ts:
    • Added HALF_CIRCUMFERENCE constant
    • Added antimeridian detection + projection wrapping logic in the Web Mercator branch of _renderSubLayers

Testing

  • Build passes (pnpm build)
  • Lint passes (pnpm lint)
  • Existing tests are unaffected (pre-existing Node.js version issue prevents test runner from starting on this machine)

@github-actions github-actions Bot added the fix label Mar 25, 2026
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
@james-willis james-willis force-pushed the fix/antimeridian-mesh-divergence branch from 39cf0d5 to 7280242 Compare March 25, 2026 21:39
@james-willis james-willis marked this pull request as ready for review March 25, 2026 21:52
@kylebarron

Copy link
Copy Markdown
Member

I'm still thinking through how we'd want to handle generic antimeridian-crossing.

At a minimum this PR needs:

  • more screenshots discussing what goes wrong now and how the new PR fixes it
  • A runnable example that uses an image crossing the antimeridian (probably choose a public sentinel2 TCI image of alaska?)

Also deck.gl-geotiff/src/proj.ts moved to the @developmentseed/proj package.

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];

@kylebarron kylebarron Apr 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit you can use the copy in transform-bounds.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 wrapAntimeridianProjections utility to conditionally wrap EPSG:3857 forward/inverse projections for antimeridian-crossing tiles.
  • Applied the wrapping logic in COGLayer._renderSubLayers for 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.

Comment on lines +482 to +486
const wrapped = wrapAntimeridianProjections(
cornerXs,
forwardTo3857,
inverseFrom3857,
);

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does seem like a valid critique. I'm not sure at a glance how it could be fixed.

Comment on lines +27 to +32
/**
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

kylebarron added a commit that referenced this pull request May 27, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

COG tiles near ±180° longitude cause RasterReprojector mesh divergence in Web Mercator mode

4 participants