Skip to content

docs(specs): add antimeridian seam-handling detail#576

Draft
kylebarron wants to merge 13 commits into
mainfrom
kyle/antimeridian-crossing
Draft

docs(specs): add antimeridian seam-handling detail#576
kylebarron wants to merge 13 commits into
mainfrom
kyle/antimeridian-crossing

Conversation

@kylebarron
Copy link
Copy Markdown
Member

Splitting at \xc2\xb1180\xc2\xb0 isn't quite enough: proj4 normalizes the seam to the +max_X
boundary, so the wrapped (negative-side) piece's seam corner lands at common-x
512 while its interior is near 0 -> still spans the world -> diverges. Fix:
on the wrapped piece's forwardReproject, if the projected X comes back positive,
subtract one world-width (output-sign-based, deterministic, only the seam flips).

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

kylebarron and others added 2 commits May 27, 2026 15:05
Splitting at \xc2\xb1180\xc2\xb0 isn't quite enough: proj4 normalizes the seam to the +max_X
boundary, so the wrapped (negative-side) piece's seam corner lands at common-x
512 while its interior is near 0 -> still spans the world -> diverges. Fix:
on the wrapped piece's forwardReproject, if the projected X comes back positive,
subtract one world-width (output-sign-based, deterministic, only the seam flips).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the docs label May 27, 2026
Comment thread packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts
Comment on lines +34 to +47
const edgeUCut = (westLng: number, eastLng: number): number | undefined => {
// Not crossing if the eastward span doesn't wrap.
if (eastLng >= westLng) {
return undefined;
}
// Eastward distance west→(+180) then (−180)→east.
const toSeam = 180 - westLng;
const fromSeam = eastLng + 180;
const total = toSeam + fromSeam;
if (total <= 0) {
return undefined;
}
return toSeam / total;
};
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this can be a top-level function

if (topUCut === undefined || bottomUCut === undefined) {
return undefined;
}
// Vertical only: both edges must cross at the same u.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This comment should be expanded to say that we should support more general antimeridian handling, we just don't right now

kylebarron and others added 2 commits May 27, 2026 15:25
- Document U_EPSILON (vertical-cut tolerance)
- Hoist edgeUCut to a top-level function with a docstring
- Expand the vertical-only comment + docstring: slanted/curved crossings
  should eventually be supported (issue #575), just not yet

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing tiles

The east/negative-side piece of an antimeridian-crossing tile needs its ±180°
seam corner at common-x ≈ 0, but proj4 normalizes it to +max_X. Add
projectPositionWrapped (output-sign-based flip) on the tileset and expose it as
_projectPositionWrapped on tile metadata. Unused until the split lands. Export
TILE_SIZE from raster-tile-traversal to avoid duplicating the world width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kylebarron and others added 9 commits June 2, 2026 11:47
projectTo4326 is built from proj4(source, "EPSG:4326").forward, which is
identity for a 4326 source and does NOT normalize longitude to (−180, 180].
So getTileMetadata hands the function values like −204/−162 (the actual
antimeridian.tif origin), not the 156/−162 the original test assumed —
making the previous edgeUCut short-circuit silently on real data.

Rewrite edgeUCut to seam-find: locate the smallest antimeridian line
(−180 + 360k) strictly interior to (westLng, eastLng) and return its
position as a fraction of the edge's eastward span. Strict inequalities
treat edges that touch ±180 exactly as non-crossing (no degenerate uCut=0
or 1). Update tests to native un-normalized lngs + boundary cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getTileMetadata now mirrors the _webMercatorInitialTriangulation pattern: for
each tile it builds a cornerLng = projectTo4326(corner)[0] and asks
antimeridianCut whether the tile crosses ±180°, attaching the result (or
undefined for non-crossing tiles) as _antimeridianCut. Unused until
RasterTileLayer._renderSubLayers splits crossing tiles into two pieces in the
next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erLayers

In the Web Mercator branch of _renderSubLayers, when tile._antimeridianCut is
defined, emit two RasterLayers (id `${id}-raster-west` + `${id}-raster-east`)
seeded with triangulateRectangle(0, 0, uCut, 1) and triangulateRectangle(
uCut, 0, 1, 1) respectively. Both pieces share one reprojectionFns object
using _projectPositionWrapped — only the initial-triangulation seed differs.

Spec correction: the original design said "west stock + east wrapped" on the
assumption that proj4 maps the seam to +max_X (common-x 512). Empirically
proj4 4326→3857 maps native −180 to −max_X (cx 0) and native +180 to +max_X
(cx 512), so which piece is broken under stock depends on the seam family:

  antimeridian.tif (seam = native −180):  west piece broken (spans cx [0, 478])
  hypothetical [170, 190] (seam = +180):  east piece broken (spans [14, 512])

Applying the wrapped projection ("if cx > TILE_SIZE/2, subtract TILE_SIZE")
to both pieces folds everything into the (−256, 256] world copy, where the
two pieces meet continuously at cx 0 for either family. Idempotent for any
vertex already in [0, 256]. World-copy traversal (#518) then renders the
composite at every in-view world offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dianTile

_renderSubLayers had grown to handle three rendering modes inline (globe,
non-crossing Web Mercator, antimeridian-crossing Web Mercator). Split into
three private methods:

- _baseRasterProps — shared sub-layer props (width/height/image/pipeline/
  debug). One source of truth.
- _renderNormalTile — single-mesh path; handles globe (lng/lat) and Web
  Mercator non-crossing (cartesian) via one internal branch on isGlobe.
- _renderAntimeridianTile — two-mesh split for Web Mercator crossing tiles.

_renderSubLayers is now a thin dispatcher: gate on data + tileResult, decide
which renderer based on isGlobe/_antimeridianCut, splice in debugLayers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a local-fixtures Vite middleware that serves the vendored
geotiff-test-data fixture from the submodule, with proper Range support so
COGLayer behaves like a production COG bucket. New entry in COG_OPTIONS
points at /__fixtures/antimeridian.tif — EPSG:4326, bbox (−204, −18, −162,
24), crosses native −180° at u ≈ 24/42 (pixel column 24 of 42). First real
test of the antimeridian split end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ped projection

The wrapped projection (cx > TILE_SIZE/2 ⇒ subtract TILE_SIZE) made the
forward continuous across the seam but had no clean inverse: at the cx=0
boundary the same wrapped value could come from stock cx=0 OR cx=TILE_SIZE.
The reprojector's error metric round-trips every sample through
inverseReproject + inverseTransform to recover pixel coords; stock
unprojectPosition for the wrapped piece returned the canonical-normalized
lng (e.g. +156° for a piece whose native is −204°), which then projected
through inverseTransform as pixel column +360 instead of 0 — a huge "error"
that triggered pathological mesh refinement on one piece and accidentally
fooled the metric into one big triangle on the other (visible in cog-basic
with antimeridian.tif: rainbow over-refinement vs giant pink triangle).

Replace it with a per-piece longitude shift composed into the
forwardTransform/inverseTransform. For each piece, k·360° is chosen so the
piece's native lngs land inside proj4's valid [−180°, 180°]. The piece
then uses the stock projectPosition / unprojectPosition (no wrap fold) and
round-trips cleanly. The visual side effect is that the two pieces render
in different world copies; deck.gl `repeat: true` + the world-copy
traversal (#518) bring them together on screen.

Removed:
- projectPositionWrapped on RasterTileset2D
- _projectPositionWrapped on RasterTileMetadata
- TILE_SIZE import (only the wrap fold used it)

Added:
- buildPieceReprojection() builds a ReprojectionFns bundle for one piece,
  choosing lngShift from the piece's native midpoint.
- _westReprojection / _eastReprojection on RasterTileMetadata, computed in
  getTileMetadata when _antimeridianCut is set.

Tests now exercise both seam families (native [−204, −162] crossing −180,
native [170, 190] crossing +180) and verify the per-piece geotransform
round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…or UV

The reprojector maps its UV [0, 1] to pixel-INDEX [0, W-1] (delatin.ts:470),
so a uCut of 24/42 (the geographic fraction where antimeridian.tif crosses
−180°) actually lands on pixel 23.43, native lng −180.57°, which proj4 still
treats as canonical positive (+179.43° → cx 511). The east piece's left edge
was sitting on the wrong side of the seam — its mesh then went the long way
around the world, rendering as a wedge through South America.

In _renderAntimeridianTile, scale uCut by W/(W-1) before passing it to
triangulateRectangle, so the cut lands on the seam pixel (column 24 for
antimeridian.tif). Document the reprojector convention and note this also
implies a latent half-pixel offset on non-cut tile edges (out of scope here).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t tile.tileWidth (block size)

For a COG, RasterTileMetadata.tileWidth is set from img.tileWidth, which is
the COG block size — e.g. 64 for antimeridian.tif (a 42×42 image stored in a
single 64×64 block). The reprojector keys off `width: data.width` (the
actual image dimensions, 42), so the W/(W-1) scaling that maps the
geographic-fraction uCut to the reprojector's UV must use the same W. Using
the block size left the seam off by ~0.5 pixel + (W_block/W_image) ratio,
which is enough for proj4 to still treat the east piece's left edge as
canonical positive — wedge through South America again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… internally

I missed that RasterLayer constructs the reprojector with `width + 1` rows
and columns (raster-layer.ts:252) — "we add 1 to both width and height when
generating the mesh." For antimeridian.tif (data.width=42), the reprojector's
internal W=43, so its UV*(W-1) = pixel-index already maps UV=24/42 exactly to
pixel column 24 (the seam). My previous scaling (uCut*42/41) pushed the cut
slightly past the seam — wrong direction.

Standalone reprojector test with W=43 + unscaled uCut=24/42 produces 14
triangles per piece, cx [0, 25.6] east + cx [477.87, 512] west — the
geometry we want.

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.

1 participant