docs(specs): add antimeridian seam-handling detail#576
Draft
kylebarron wants to merge 13 commits into
Draft
Conversation
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>
kylebarron
commented
May 27, 2026
kylebarron
commented
May 27, 2026
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; | ||
| }; |
Member
Author
There was a problem hiding this comment.
I think this can be a top-level function
kylebarron
commented
May 27, 2026
| if (topUCut === undefined || bottomUCut === undefined) { | ||
| return undefined; | ||
| } | ||
| // Vertical only: both edges must cross at the same u. |
Member
Author
There was a problem hiding this comment.
This comment should be expanded to say that we should support more general antimeridian handling, we just don't right now
- 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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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