From e2110cf794762cd47719157baf87b8caa187685e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:05:50 -0400 Subject: [PATCH 01/13] docs(specs): add antimeridian seam-handling detail 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) --- .../2026-05-27-antimeridian-crossing-tile-design.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md b/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md index 8db71e2b..f8e83932 100644 --- a/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md +++ b/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md @@ -33,14 +33,20 @@ projectPosition = (x, y) => rescaleEPSG3857ToCommonSpace(descriptor.projectTo385 Rather than keep the crossing tile as one mesh and fight proj4 to make its coordinates continuous (the **render-as-one** family: #374 output-space shift, #269 reprojector unwrap, #353 global `+over`), **split the tile at the antimeridian into a west piece and an east piece.** Each piece lies wholly on one side of the dateline, so: -- The west piece is monotonic in 3857 (all +x → common-x up to 512); the east piece all −x → common-x from 0. **The discontinuity never exists within a piece.** -- `projectTo3857` stays stock — no unwrap, no proj4 reconfiguration, no `+over`. +- The west piece is monotonic in 3857 (all +x → common-x up to 512); the east piece all −x → common-x from 0. **The discontinuity exists only *at* the shared seam edge, not within a piece's interior** (see "Seam handling" below). +- Almost no projection change: the west piece uses stock `projectTo3857`; the east piece needs only a trivial one-line seam fix — no proj4 reconfiguration, no `+over`, no phase-unwrap. - The `RasterReprojector` needs zero antimeridian awareness — Delatin converges normally on each piece. - Mesh vertices stay within `[0, 512]`, so the fp64 high-zoom precision scheme ([`coordinate-systems.md`](../coordinate-systems.md)) is untouched. - Each piece is a normal tile that the merged world-copy traversal (#518) selects and draws across copies. The antimeridian becomes *a tile boundary*, which the pipeline already handles, instead of a coordinate-space discontinuity. +### Seam handling + +Splitting at the antimeridian is *almost* enough — but not quite. proj4 normalizes ±180° to the **positive** boundary (+max_X / common-x 512). That's correct for the west piece (its right edge *is* +180°), but the east piece's left edge is also the antimeridian and must sit at common-x 0 (−max_X). With stock proj4 the east piece's seam corner lands at 512 while its interior is near 0, so its seed triangle still spans the world and the reprojector diverges — the original #366 failure. + +The fix is local to the **wrapped (negative-side) piece** only: in its `forwardReproject`, **if the projected X comes back positive, subtract one world-width** (the +max boundary → −max). Within that piece the *only* vertex proj4 places on the positive side is the ±180° seam, so this single sign test flips exactly the seam corner and leaves the interior untouched. It is **output-sign-based, not an input-value test** — the seam may be lng +180° or −180° depending on the source's longitude convention, and both pieces share the same seam *input*, so only *which piece* you're rendering decides the handling (known at cut time: the wrapped piece is the one whose interior projects to negative X). `inverseReproject` is unchanged: the piece is now a clean negative range the stock inverse maps back correctly. This is **not** the general phase-unwrap that sank #374 — the piece is known a priori to be wholly on the negative side, so the rule is trivial and deterministic. + ### Why not render-as-one Render-as-one is simpler at the render layer (one mesh, one draw, no internal seam) and more CRS-general (it unwraps the output value, indifferent to source pixel geometry). But it re-attempts the exact unwrap that has failed three times: detection needs phase-unwrapping (a full-world continuous tile must not be mistaken for a crossing tile), mesh vertices leave `[0, 512]` (precision risk), and forward+inverse must stay consistent. We choose cut-in-two as the primary mechanism and keep **render-as-one as the documented fallback for curved-meridian CRS** (see Scope), where cut-in-two degrades. From 03c945fc417fa3fef25081651b91b40a3a94d532 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:19:21 -0400 Subject: [PATCH 02/13] feat(deck.gl-raster): add antimeridianCut (vertical-cut detection) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/raster-tileset/antimeridian-cut.ts | 59 +++++++++++++++++++ .../raster-tileset/antimeridian-cut.test.ts | 42 +++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts create mode 100644 packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts new file mode 100644 index 00000000..78aca243 --- /dev/null +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -0,0 +1,59 @@ +/** WGS84 longitudes (normalized to (−180, 180]) of a tile's four corners. */ +export interface CornerLongitudes { + topLeft: number; + topRight: number; + bottomLeft: number; + bottomRight: number; +} + +/** A vertical antimeridian cut in a tile's UV space. */ +export interface AntimeridianCut { + /** UV u-coordinate (0..1) where the tile crosses ±180°. */ + uCut: number; +} + +const U_EPSILON = 1e-6; + +/** + * Detect whether a tile crosses the antimeridian and, if so, locate the cut. + * + * Only **axis-aligned (vertical) crossings** are handled (MVP): the top and + * bottom edges must cross ±180° at the same u. Returns `undefined` for + * non-crossing tiles and for non-vertical (slanted/curved) crossings, which + * fall back to a single full-mesh layer. + * + * Assumes u increases eastward (standard north-up geotransform). A non-crossing + * tile has west-edge lng < east-edge lng; a crossing tile wraps, so + * `eastLng < westLng`. + */ +export function antimeridianCut( + cornerLngs: CornerLongitudes, +): AntimeridianCut | undefined { + const { topLeft, topRight, bottomLeft, bottomRight } = cornerLngs; + + 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; + }; + + const topUCut = edgeUCut(topLeft, topRight); + const bottomUCut = edgeUCut(bottomLeft, bottomRight); + if (topUCut === undefined || bottomUCut === undefined) { + return undefined; + } + // Vertical only: both edges must cross at the same u. + if (Math.abs(topUCut - bottomUCut) > U_EPSILON) { + return undefined; + } + return { uCut: (topUCut + bottomUCut) / 2 }; +} diff --git a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts new file mode 100644 index 00000000..b1b5b87e --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { antimeridianCut } from "../../src/raster-tileset/antimeridian-cut.js"; + +// cornerLngs are WGS84 longitudes normalized to (−180, 180], as returned by +// descriptor.projectTo4326(corner)[0]. +describe("antimeridianCut", () => { + it("returns undefined for a non-crossing tile (west < east)", () => { + expect( + antimeridianCut({ + topLeft: 10, + topRight: 20, + bottomLeft: 10, + bottomRight: 20, + }), + ).toBeUndefined(); + }); + + it("locates a vertical cut for an axis-aligned crossing tile", () => { + // antimeridian.tif: west edge lng −204° → normalized 156°; east edge −162°. + const cut = antimeridianCut({ + topLeft: 156, + topRight: -162, + bottomLeft: 156, + bottomRight: -162, + }); + expect(cut).toBeDefined(); + // from 156° east to +180° is 24°; from −180° to −162° is 18°; total 42°. + expect(cut?.uCut).toBeCloseTo(24 / 42, 9); + }); + + it("returns undefined for a slanted (non-vertical) crossing cut", () => { + // top and bottom edges cross the antimeridian at different u → not vertical. + expect( + antimeridianCut({ + topLeft: 156, + topRight: -162, + bottomLeft: 170, + bottomRight: -150, + }), + ).toBeUndefined(); + }); +}); From 521fc08c201fa4289d4477b05e3b38c1f7a40232 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:25:58 -0400 Subject: [PATCH 03/13] refactor(deck.gl-raster): address PR review on antimeridianCut - 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) --- .../src/raster-tileset/antimeridian-cut.ts | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index 78aca243..6a9b7f35 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -12,15 +12,44 @@ export interface AntimeridianCut { uCut: number; } +/** + * Tolerance for treating the top- and bottom-edge crossings as the same u + * (i.e. the cut is vertical). Crossings further apart than this are rejected as + * slanted. + */ const U_EPSILON = 1e-6; +/** + * Locate where a single horizontal edge crosses the antimeridian, as a fraction + * of the edge's eastward span (0 at the west corner, 1 at the east corner). + * + * Returns `undefined` if the edge does not cross: with u increasing eastward, a + * non-crossing edge has west lng < east lng, while a crossing edge wraps, so + * `eastLng < westLng`. + */ +function 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; +} + /** * Detect whether a tile crosses the antimeridian and, if so, locate the cut. * - * Only **axis-aligned (vertical) crossings** are handled (MVP): the top and - * bottom edges must cross ±180° at the same u. Returns `undefined` for - * non-crossing tiles and for non-vertical (slanted/curved) crossings, which - * fall back to a single full-mesh layer. + * Only **axis-aligned (vertical) crossings** are handled today (MVP): the top + * and bottom edges must cross ±180° at the same u. We *should* eventually + * support the general case — slanted cuts (rotated geotransforms) and curved + * cuts (non-geographic CRSs) — but for now those return `undefined` and fall + * back to a single full-mesh layer. See issue #575. * * Assumes u increases eastward (standard north-up geotransform). A non-crossing * tile has west-edge lng < east-edge lng; a crossing tile wraps, so @@ -31,27 +60,14 @@ export function antimeridianCut( ): AntimeridianCut | undefined { const { topLeft, topRight, bottomLeft, bottomRight } = cornerLngs; - 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; - }; - const topUCut = edgeUCut(topLeft, topRight); const bottomUCut = edgeUCut(bottomLeft, bottomRight); if (topUCut === undefined || bottomUCut === undefined) { return undefined; } - // Vertical only: both edges must cross at the same u. + // Vertical only for now: both edges must cross at the same u. A slanted cut + // (top and bottom crossing at different u) is a valid antimeridian crossing + // we don't yet handle — see the function docstring and issue #575. if (Math.abs(topUCut - bottomUCut) > U_EPSILON) { return undefined; } From c63892a3ca4e34b670ef90fe47902c74ec8fecc5 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:28:51 -0400 Subject: [PATCH 04/13] feat(deck.gl-raster): add wrapped (seam-flipped) projection for crossing tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../raster-tileset/raster-tile-traversal.ts | 2 +- .../src/raster-tileset/raster-tileset-2d.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts index fac63439..da286d8e 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts @@ -54,7 +54,7 @@ import type { * The origin (0,0) is at the top-left corner, and (512,512) is at the * bottom-right. */ -const TILE_SIZE = 512; +export const TILE_SIZE = 512; /** * Maximum number of world copies to test on each side of the primary world diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index f99cb7a8..6b051801 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -21,6 +21,7 @@ import { getTileIndices, rescaleCommonSpaceToEPSG3857, rescaleEPSG3857ToCommonSpace, + TILE_SIZE, } from "./raster-tile-traversal.js"; import { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js"; import type { RasterTilesetDescriptor } from "./tileset-interface.js"; @@ -102,6 +103,15 @@ export type RasterTileMetadata = { */ _unprojectPosition: ProjectionFunction; + /** + * Source CRS → common space, but with the +180° seam flipped to the negative + * side (common-x ≈ 0). Used as the `forwardReproject` for the east/wrapped + * piece of an antimeridian-crossing tile so that piece does not span the + * whole world. Same stability guarantees as + * {@link RasterTileMetadata._projectPosition}. + */ + _projectPositionWrapped: ProjectionFunction; + /** * Seed triangulation that clamps this tile's reprojection mesh to the valid * Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed. @@ -154,6 +164,7 @@ export class RasterTileset2D extends Tileset2D { private getPixelRatio: () => number; private boundingVolumeCache: BoundingVolumeCache; private projectPosition: ProjectionFunction; + private projectPositionWrapped: ProjectionFunction; private unprojectPosition: ProjectionFunction; /** * Projection mode of the viewport on the previous `getTileIndices` call. @@ -187,6 +198,19 @@ export class RasterTileset2D extends Tileset2D { return descriptor.projectFrom3857(mx, my); }; + // Wrapped variant for the negative-side (east) piece of an + // antimeridian-crossing tile. proj4 normalizes the ±180° seam to +180°, so + // the seam vertex projects to common-x ≈ TILE_SIZE (the +max boundary) + // while the rest of that piece sits near 0 — making the piece span the + // whole world and diverge. Flip any vertex that comes back on the positive + // side (common-x > TILE_SIZE / 2) by one world-width so the seam lands at + // ≈ 0. Only the seam vertex is affected; output-sign-based and + // deterministic. See dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md. + this.projectPositionWrapped = (x, y) => { + const [cx, cy] = this.projectPosition(x, y); + return cx > TILE_SIZE / 2 ? [cx - TILE_SIZE, cy] : [cx, cy]; + }; + const rawBounds = transformBounds( this.descriptor.projectTo4326, ...this.descriptor.projectedBounds, @@ -416,6 +440,7 @@ export class RasterTileset2D extends Tileset2D { inverseTransform, _projectPosition: this.projectPosition, _unprojectPosition: this.unprojectPosition, + _projectPositionWrapped: this.projectPositionWrapped, _webMercatorInitialTriangulation, }; } From 8e77bc05c7338da292007ee3860c696dfa43219f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 11:47:16 -0400 Subject: [PATCH 05/13] fix(deck.gl-raster): antimeridianCut takes native un-normalized lngs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tileset/antimeridian-cut.ts | 40 ++++++++----- .../raster-tileset/antimeridian-cut.test.ts | 58 +++++++++++++++---- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index 6a9b7f35..ccd6c348 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -1,4 +1,11 @@ -/** WGS84 longitudes (normalized to (−180, 180]) of a tile's four corners. */ +/** + * WGS84 longitudes of a tile's four corners, as returned by + * `descriptor.projectTo4326(corner)[0]` — i.e. native and **not** normalized to + * (−180, 180]. For a north-up geotransform the west edge's lng is strictly + * less than the east edge's lng (proj4 4326→4326 is identity and does not + * wrap), so a tile crossing the antimeridian shows up as a span like + * (−204, −162) rather than (156, −162). + */ export interface CornerLongitudes { topLeft: number; topRight: number; @@ -23,23 +30,25 @@ const U_EPSILON = 1e-6; * Locate where a single horizontal edge crosses the antimeridian, as a fraction * of the edge's eastward span (0 at the west corner, 1 at the east corner). * - * Returns `undefined` if the edge does not cross: with u increasing eastward, a - * non-crossing edge has west lng < east lng, while a crossing edge wraps, so - * `eastLng < westLng`. + * Returns `undefined` if the edge does not cross. Works on native un-normalized + * longitudes (e.g. `westLng = −204`, `eastLng = −162`) by searching for the + * smallest antimeridian line `−180 + 360k` strictly interior to `(westLng, + * eastLng)`. Strict inequalities give the correct non-crossing answer when a + * corner lies exactly on ±180. */ function edgeUCut(westLng: number, eastLng: number): number | undefined { - // Not crossing if the eastward span doesn't wrap. - if (eastLng >= westLng) { + // Degenerate or non-monotonic edge — caller is expected to pass + // west-then-east in the source CRS's native ordering. + 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) { + // Smallest antimeridian line (−180 + 360k) strictly greater than westLng. + const k = Math.ceil((westLng + 180) / 360); + const seam = -180 + 360 * k; + if (seam <= westLng || seam >= eastLng) { return undefined; } - return toSeam / total; + return (seam - westLng) / (eastLng - westLng); } /** @@ -51,9 +60,10 @@ function edgeUCut(westLng: number, eastLng: number): number | undefined { * cuts (non-geographic CRSs) — but for now those return `undefined` and fall * back to a single full-mesh layer. See issue #575. * - * Assumes u increases eastward (standard north-up geotransform). A non-crossing - * tile has west-edge lng < east-edge lng; a crossing tile wraps, so - * `eastLng < westLng`. + * Assumes u increases eastward (standard north-up geotransform) and that + * corner longitudes are passed in native, un-normalized form — that is what + * `descriptor.projectTo4326` returns for an EPSG:4326 source whose + * `ModelTiepoint` sits past ±180° (e.g. `−204°`). */ export function antimeridianCut( cornerLngs: CornerLongitudes, diff --git a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts index b1b5b87e..4c040260 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { antimeridianCut } from "../../src/raster-tileset/antimeridian-cut.js"; -// cornerLngs are WGS84 longitudes normalized to (−180, 180], as returned by -// descriptor.projectTo4326(corner)[0]. +// cornerLngs are WGS84 longitudes as returned by +// descriptor.projectTo4326(corner)[0] — native and NOT normalized to +// (−180, 180]. For a north-up geotransform, west < east always (proj4 +// 4326→4326 is identity), so a crossing tile shows up as e.g. (−204, −162), +// not (156, −162). describe("antimeridianCut", () => { - it("returns undefined for a non-crossing tile (west < east)", () => { + it("returns undefined for a non-crossing tile (west < east, no seam inside)", () => { expect( antimeridianCut({ topLeft: 10, @@ -15,28 +18,61 @@ describe("antimeridianCut", () => { ).toBeUndefined(); }); - it("locates a vertical cut for an axis-aligned crossing tile", () => { - // antimeridian.tif: west edge lng −204° → normalized 156°; east edge −162°. + it("locates a vertical cut for an axis-aligned crossing tile (native un-normalized lngs)", () => { + // antimeridian.tif: ModelTiepoint origin lng −204°, east edge −162°. const cut = antimeridianCut({ - topLeft: 156, + topLeft: -204, topRight: -162, - bottomLeft: 156, + bottomLeft: -204, bottomRight: -162, }); expect(cut).toBeDefined(); - // from 156° east to +180° is 24°; from −180° to −162° is 18°; total 42°. + // The −180° seam is at (−180 − (−204)) / (−162 − (−204)) = 24/42 of the + // edge span. expect(cut?.uCut).toBeCloseTo(24 / 42, 9); }); + it("locates the cut equivalently for an in-range crossing span (170, 190)", () => { + const cut = antimeridianCut({ + topLeft: 170, + topRight: 190, + bottomLeft: 170, + bottomRight: 190, + }); + expect(cut?.uCut).toBeCloseTo(0.5, 9); + }); + it("returns undefined for a slanted (non-vertical) crossing cut", () => { - // top and bottom edges cross the antimeridian at different u → not vertical. + // Top edge crosses at u = 24/42 ≈ 0.571; bottom edge at u = 10/40 = 0.25. expect( antimeridianCut({ - topLeft: 156, + topLeft: -204, topRight: -162, - bottomLeft: 170, + bottomLeft: -190, bottomRight: -150, }), ).toBeUndefined(); }); + + it("returns undefined when a corner lies exactly on the antimeridian (boundary, non-crossing)", () => { + // Strict-inequality seam-finding treats edges that *touch* ±180 as + // non-crossing (the seam is not strictly interior), avoiding a degenerate + // uCut of 0 or 1. + expect( + antimeridianCut({ + topLeft: 160, + topRight: 180, + bottomLeft: 160, + bottomRight: 180, + }), + ).toBeUndefined(); + expect( + antimeridianCut({ + topLeft: -180, + topRight: -160, + bottomLeft: -180, + bottomRight: -160, + }), + ).toBeUndefined(); + }); }); From be59f10c363f637ec98f6103806ffbf6a8bccdc5 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 11:51:44 -0400 Subject: [PATCH 06/13] feat(deck.gl-raster): compute _antimeridianCut on tile metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tileset/raster-tileset-2d.ts | 24 ++++++++ .../raster-tileset-2d-antimeridian.test.ts | 60 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 6b051801..6c042ba3 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -16,6 +16,8 @@ import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import { transformBounds } from "@developmentseed/proj"; import type { InitialTriangulation } from "@developmentseed/raster-reproject"; import type { Matrix4 } from "@math.gl/core"; +import type { AntimeridianCut } from "./antimeridian-cut.js"; +import { antimeridianCut } from "./antimeridian-cut.js"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import { getTileIndices, @@ -119,6 +121,15 @@ export type RasterTileMetadata = { * full mesh. See {@link createInitialWebMercatorTriangulation}. */ _webMercatorInitialTriangulation?: InitialTriangulation; + + /** + * Vertical cut at which this tile crosses ±180°, or `undefined` if the tile + * does not cross the antimeridian (or crosses with a slanted/curved cut that + * the MVP does not yet handle). Consumed by `RasterTileLayer._renderSubLayers` + * in the Web Mercator branch to split the tile into a west + east piece. See + * {@link antimeridianCut}. + */ + _antimeridianCut?: AntimeridianCut; }; /** @@ -420,6 +431,18 @@ export class RasterTileset2D extends Tileset2D { bottomRight: cornerLat(bottomRight), }); + // Detect whether this tile crosses ±180° and locate the vertical cut. + // Corner longitudes are native (as proj4 returns them — un-normalized for a + // 4326 source with an origin past ±180°). See {@link antimeridianCut}. + const cornerLng = (corner: [number, number]) => + this.descriptor.projectTo4326(corner[0], corner[1])[0]; + const _antimeridianCut = antimeridianCut({ + topLeft: cornerLng(topLeft), + topRight: cornerLng(topRight), + bottomLeft: cornerLng(bottomLeft), + bottomRight: cornerLng(bottomRight), + }); + return { bbox: { west, @@ -442,6 +465,7 @@ export class RasterTileset2D extends Tileset2D { _unprojectPosition: this.unprojectPosition, _projectPositionWrapped: this.projectPositionWrapped, _webMercatorInitialTriangulation, + _antimeridianCut, }; } } diff --git a/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts new file mode 100644 index 00000000..71f57d21 --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts @@ -0,0 +1,60 @@ +import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; +import { compose, scale, translation } from "@developmentseed/affine"; +import { describe, expect, it } from "vitest"; +import { AffineTileset } from "../../src/raster-tileset/affine-tileset.js"; +import { AffineTilesetLevel } from "../../src/raster-tileset/affine-tileset-level.js"; +import { RasterTileset2D } from "../../src/raster-tileset/raster-tileset-2d.js"; + +const identity = (x: number, y: number): [number, number] => [x, y]; + +const PROJECTIONS = { + projectTo3857: identity, + projectFrom3857: identity, + projectTo4326: identity, + projectFrom4326: identity, +}; + +function tilesetProps(): Tileset2DProps { + return { getTileData: () => new Promise(() => {}) } as Tileset2DProps; +} + +describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { + it("returns _antimeridianCut on a tile whose native lngs cross ±180 (antimeridian.tif shape)", () => { + // antimeridian.tif: rasterio.from_origin(-204, 24, 1, 1), 42×42 EPSG:4326. + // One tile covers the whole image, with native lngs (−204, −162) crossing + // −180° at u = 24/42. + const level = new AffineTilesetLevel({ + affine: compose(translation(-204, 24), scale(1, -1)), + arrayWidth: 42, + arrayHeight: 42, + tileWidth: 42, + tileHeight: 42, + mpu: 1, + }); + const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + + const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(metadata._antimeridianCut).toBeDefined(); + expect(metadata._antimeridianCut?.uCut).toBeCloseTo(24 / 42, 9); + }); + + it("does NOT set _antimeridianCut on a non-crossing tile", () => { + // A tile entirely east of the antimeridian: native lngs (0, 170). + const level = new AffineTilesetLevel({ + affine: compose(translation(0, 90), scale(1, -1)), + arrayWidth: 170, + arrayHeight: 180, + tileWidth: 170, + tileHeight: 180, + mpu: 1, + }); + const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + + const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(metadata._antimeridianCut).toBeUndefined(); + }); +}); From d7186ddbb072a234bd70c20330a0b35fa929290a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 12:00:36 -0400 Subject: [PATCH 07/13] feat(deck.gl-raster): split antimeridian-crossing tiles into two RasterLayers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../raster-tile-layer/raster-tile-layer.ts | 128 ++++++++++++------ 1 file changed, 89 insertions(+), 39 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 8981f843..73a584bd 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -1,9 +1,4 @@ -import type { - CompositeLayerProps, - CoordinateSystem, - DefaultProps, - Layer, -} from "@deck.gl/core"; +import type { CompositeLayerProps, DefaultProps, Layer } from "@deck.gl/core"; import { CompositeLayer } from "@deck.gl/core"; import type { _Tile2DHeader as Tile2DHeader, @@ -13,6 +8,7 @@ import type { } from "@deck.gl/geo-layers"; import { TileLayer } from "@deck.gl/geo-layers"; import type { ReprojectionFns } from "@developmentseed/raster-reproject"; +import { triangulateRectangle } from "@developmentseed/raster-reproject"; import type { Device } from "@luma.gl/core"; import { renderDebugTileOutline } from "../layer-utils.js"; import type { RenderTileResult } from "../raster-layer.js"; @@ -402,55 +398,109 @@ export class RasterTileLayer< const { width, height } = props.data; const isGlobe = this.context.viewport.resolution !== undefined; - let reprojectionFns: ReprojectionFns; - let coordinateSystem: CoordinateSystem; + + const baseRasterProps = { + width, + height, + // Passing `image: undefined` explicitly would trip isAsyncPropLoading + // and cause a transient black flash (see issue #376). + ...(image !== undefined && { image }), + renderPipeline, + maxError, + debug, + debugOpacity, + }; + if (isGlobe) { - // Globe view - reprojectionFns = { + // Globe view: full mesh in lng/lat, no antimeridian split (the globe + // shows the ±180° seam as a normal interior edge, not a discontinuity). + const reprojectionFns: ReprojectionFns = { forwardTransform, inverseTransform, forwardReproject: descriptor.projectTo4326, inverseReproject: descriptor.projectFrom4326, }; - coordinateSystem = "lnglat"; - } else { - // Web Mercator: render the mesh directly in deck.gl common space. - // - // The tile's `_projectPosition` maps source CRS → common space, support - // high precision with fp64 emulation. - // - // `_projectPosition`/`_unprojectPosition` must be reference-stable across - // renders to avoid regenerating the mesh and recompiling the shader every - // frame. - const { _projectPosition, _unprojectPosition } = tile; - reprojectionFns = { + const rasterLayer = new RasterLayer( + this.getSubLayerProps({ + ...baseRasterProps, + id: `${props.id}-raster`, + reprojectionFns, + coordinateSystem: "lnglat", + }), + ); + return [rasterLayer, ...debugLayers]; + } + + // Web Mercator: render the mesh directly in deck.gl common space. + // + // The tile's `_projectPosition` maps source CRS → common space and + // supports high precision with fp64 emulation. + // + // `_projectPosition`/`_unprojectPosition` must be reference-stable across + // renders to avoid regenerating the mesh and recompiling the shader every + // frame. + const { + _projectPosition, + _unprojectPosition, + _projectPositionWrapped, + _antimeridianCut, + } = tile; + + if (_antimeridianCut) { + // Antimeridian-crossing tile: split into west + east pieces at the cut + // and render each as its own mesh. Both pieces use the *wrapped* + // projection (folds any common-x past +max into the (−256, 256] world + // copy) so they meet continuously at common-x 0 rather than either + // piece spanning the entire world. The split itself comes from each + // piece's `triangulateRectangle` seed — the reprojector only refines + // existing triangles, so confining the seed to a sub-rectangle of UV + // space confines the mesh. + const reprojectionFns: ReprojectionFns = { forwardTransform, inverseTransform, - forwardReproject: _projectPosition, + forwardReproject: _projectPositionWrapped, inverseReproject: _unprojectPosition, }; - coordinateSystem = "cartesian"; + const { uCut } = _antimeridianCut; + return [ + new RasterLayer( + this.getSubLayerProps({ + ...baseRasterProps, + id: `${props.id}-raster-west`, + reprojectionFns, + initialTriangulation: triangulateRectangle(0, 0, uCut, 1), + coordinateSystem: "cartesian", + }), + ), + new RasterLayer( + this.getSubLayerProps({ + ...baseRasterProps, + id: `${props.id}-raster-east`, + reprojectionFns, + initialTriangulation: triangulateRectangle(uCut, 0, 1, 1), + coordinateSystem: "cartesian", + }), + ), + ...debugLayers, + ]; } + // Non-crossing tile: single mesh with the stock projection. The + // `_webMercatorInitialTriangulation` seed clamps the mesh to the valid + // latitude band for tiles past ±85.051°. + const reprojectionFns: ReprojectionFns = { + forwardTransform, + inverseTransform, + forwardReproject: _projectPosition, + inverseReproject: _unprojectPosition, + }; const rasterLayer = new RasterLayer( this.getSubLayerProps({ + ...baseRasterProps, id: `${props.id}-raster`, - width, - height, - // Passing `image: undefined` explicitly would trip isAsyncPropLoading - // and cause a transient black flash (see issue #376). - ...(image !== undefined && { image }), - renderPipeline, - maxError, reprojectionFns, - // Web Mercator: clamp the mesh to the valid latitude band for tiles - // past ±85.051°. Globe renders the full mesh (it shows the poles). - initialTriangulation: isGlobe - ? undefined - : tile._webMercatorInitialTriangulation, - debug, - debugOpacity, - coordinateSystem, + initialTriangulation: tile._webMercatorInitialTriangulation, + coordinateSystem: "cartesian", }), ); return [rasterLayer, ...debugLayers]; From e7d3eae60f3234cd5969c52b0c96c0c1d290aec7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 12:12:05 -0400 Subject: [PATCH 08/13] refactor(deck.gl-raster): extract _renderNormalTile / _renderAntimeridianTile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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) --- .../raster-tile-layer/raster-tile-layer.ts | 226 ++++++++++-------- 1 file changed, 128 insertions(+), 98 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 73a584bd..89a6b929 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -376,9 +376,8 @@ export class RasterTileLayer< descriptor: RasterTilesetDescriptor, renderTile: NonNullable["renderTile"]>, ): Layer[] { - const { maxError, debug, debugOpacity } = this.props; + const { debug } = this.props; const tile = props.tile as Tile2DHeader & RasterTileMetadata; - const debugLayers = debug ? this._renderDebug(tile, props.data ?? null) : []; @@ -386,22 +385,46 @@ export class RasterTileLayer< if (!props.data) { return debugLayers; } - - // Access forwardTransform/inverseTransform from tile metadata so that - // reference equality holds across renders. - const { forwardTransform, inverseTransform } = tile; const tileResult = renderTile(props.data); if (!tileResult) { return debugLayers; } - const { image, renderPipeline } = tileResult; - const { width, height } = props.data; const isGlobe = this.context.viewport.resolution !== undefined; + const rasterLayers = + !isGlobe && tile._antimeridianCut + ? this._renderAntimeridianTile({ + baseId: props.id, + tile, + data: props.data, + tileResult, + uCut: tile._antimeridianCut.uCut, + }) + : this._renderNormalTile({ + baseId: props.id, + tile, + data: props.data, + tileResult, + descriptor, + isGlobe, + }); + return [...rasterLayers, ...debugLayers]; + } - const baseRasterProps = { - width, - height, + /** + * Shared base props for every `RasterLayer` produced from a tile — the + * geometry + render-pipeline pieces that don't depend on globe-vs-mercator + * or whether the tile crosses the antimeridian. + */ + private _baseRasterProps( + data: NonNullable, + tileResult: RenderTileResult, + ) { + const { maxError, debug, debugOpacity } = this.props; + const { image, renderPipeline } = tileResult; + return { + width: data.width, + height: data.height, // Passing `image: undefined` explicitly would trip isAsyncPropLoading // and cause a transient black flash (see issue #376). ...(image !== undefined && { image }), @@ -410,99 +433,106 @@ export class RasterTileLayer< debug, debugOpacity, }; + } + + /** + * Build the one `RasterLayer` for a tile that renders as a single mesh — + * either the globe path (lng/lat coordinate system, descriptor-level + * projection) or the Web Mercator non-crossing path (cartesian common + * space, per-tile projection with the optional ±85.051° clamp seed). + */ + private _renderNormalTile(opts: { + baseId: string; + tile: Tile2DHeader & RasterTileMetadata; + data: NonNullable; + tileResult: RenderTileResult; + descriptor: RasterTilesetDescriptor; + isGlobe: boolean; + }): Layer[] { + const { baseId, tile, data, tileResult, descriptor, isGlobe } = opts; + const { forwardTransform, inverseTransform } = tile; - if (isGlobe) { - // Globe view: full mesh in lng/lat, no antimeridian split (the globe - // shows the ±180° seam as a normal interior edge, not a discontinuity). - const reprojectionFns: ReprojectionFns = { - forwardTransform, - inverseTransform, - forwardReproject: descriptor.projectTo4326, - inverseReproject: descriptor.projectFrom4326, - }; - const rasterLayer = new RasterLayer( + // Web Mercator: render in deck.gl common space using the tile's + // reference-stable `_projectPosition`/`_unprojectPosition` so the mesh + // does not regenerate (and the shader does not recompile) every frame. + // Globe: render in lng/lat using the descriptor's 4326 projection. + const reprojectionFns: ReprojectionFns = isGlobe + ? { + forwardTransform, + inverseTransform, + forwardReproject: descriptor.projectTo4326, + inverseReproject: descriptor.projectFrom4326, + } + : { + forwardTransform, + inverseTransform, + forwardReproject: tile._projectPosition, + inverseReproject: tile._unprojectPosition, + }; + + return [ + new RasterLayer( this.getSubLayerProps({ - ...baseRasterProps, - id: `${props.id}-raster`, + ...this._baseRasterProps(data, tileResult), + id: `${baseId}-raster`, reprojectionFns, - coordinateSystem: "lnglat", + coordinateSystem: isGlobe ? "lnglat" : "cartesian", + // Globe shows the poles; Web Mercator clamps tiles past ±85.051° + // to the valid latitude band via the seed (undefined when no clamp + // is needed). + initialTriangulation: isGlobe + ? undefined + : tile._webMercatorInitialTriangulation, }), - ); - return [rasterLayer, ...debugLayers]; - } - - // Web Mercator: render the mesh directly in deck.gl common space. - // - // The tile's `_projectPosition` maps source CRS → common space and - // supports high precision with fp64 emulation. - // - // `_projectPosition`/`_unprojectPosition` must be reference-stable across - // renders to avoid regenerating the mesh and recompiling the shader every - // frame. - const { - _projectPosition, - _unprojectPosition, - _projectPositionWrapped, - _antimeridianCut, - } = tile; - - if (_antimeridianCut) { - // Antimeridian-crossing tile: split into west + east pieces at the cut - // and render each as its own mesh. Both pieces use the *wrapped* - // projection (folds any common-x past +max into the (−256, 256] world - // copy) so they meet continuously at common-x 0 rather than either - // piece spanning the entire world. The split itself comes from each - // piece's `triangulateRectangle` seed — the reprojector only refines - // existing triangles, so confining the seed to a sub-rectangle of UV - // space confines the mesh. - const reprojectionFns: ReprojectionFns = { - forwardTransform, - inverseTransform, - forwardReproject: _projectPositionWrapped, - inverseReproject: _unprojectPosition, - }; - const { uCut } = _antimeridianCut; - return [ - new RasterLayer( - this.getSubLayerProps({ - ...baseRasterProps, - id: `${props.id}-raster-west`, - reprojectionFns, - initialTriangulation: triangulateRectangle(0, 0, uCut, 1), - coordinateSystem: "cartesian", - }), - ), - new RasterLayer( - this.getSubLayerProps({ - ...baseRasterProps, - id: `${props.id}-raster-east`, - reprojectionFns, - initialTriangulation: triangulateRectangle(uCut, 0, 1, 1), - coordinateSystem: "cartesian", - }), - ), - ...debugLayers, - ]; - } + ), + ]; + } - // Non-crossing tile: single mesh with the stock projection. The - // `_webMercatorInitialTriangulation` seed clamps the mesh to the valid - // latitude band for tiles past ±85.051°. + /** + * Build the two `RasterLayer`s for a Web-Mercator tile that crosses ±180°: + * a west piece (UV `[0, uCut]`) and an east piece (UV `[uCut, 1]`). Both + * pieces share one `reprojectionFns` object built on + * `_projectPositionWrapped`, which folds any common-x past +max into the + * `(−256, 256]` world copy so the two pieces meet continuously at common-x + * 0 rather than either piece spanning the whole world. The split itself + * lives entirely in each piece's `triangulateRectangle` seed — the + * reprojector only refines existing triangles, so confining the seed to a + * sub-rectangle of UV space confines the mesh. + */ + private _renderAntimeridianTile(opts: { + baseId: string; + tile: Tile2DHeader & RasterTileMetadata; + data: NonNullable; + tileResult: RenderTileResult; + uCut: number; + }): Layer[] { + const { baseId, tile, data, tileResult, uCut } = opts; const reprojectionFns: ReprojectionFns = { - forwardTransform, - inverseTransform, - forwardReproject: _projectPosition, - inverseReproject: _unprojectPosition, + forwardTransform: tile.forwardTransform, + inverseTransform: tile.inverseTransform, + forwardReproject: tile._projectPositionWrapped, + inverseReproject: tile._unprojectPosition, }; - const rasterLayer = new RasterLayer( - this.getSubLayerProps({ - ...baseRasterProps, - id: `${props.id}-raster`, - reprojectionFns, - initialTriangulation: tile._webMercatorInitialTriangulation, - coordinateSystem: "cartesian", - }), - ); - return [rasterLayer, ...debugLayers]; + const baseProps = { + ...this._baseRasterProps(data, tileResult), + reprojectionFns, + coordinateSystem: "cartesian" as const, + }; + return [ + new RasterLayer( + this.getSubLayerProps({ + ...baseProps, + id: `${baseId}-raster-west`, + initialTriangulation: triangulateRectangle(0, 0, uCut, 1), + }), + ), + new RasterLayer( + this.getSubLayerProps({ + ...baseProps, + id: `${baseId}-raster-east`, + initialTriangulation: triangulateRectangle(uCut, 0, 1, 1), + }), + ), + ]; } } From 7ac2e7452f4a289403b764d418154ae2114dbdea Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 12:20:15 -0400 Subject: [PATCH 09/13] example(cog-basic): add antimeridian.tif crossing fixture (dev only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- examples/cog-basic/src/App.tsx | 7 ++++ examples/cog-basic/vite.config.ts | 63 ++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/examples/cog-basic/src/App.tsx b/examples/cog-basic/src/App.tsx index 0cee06a9..1db1620b 100644 --- a/examples/cog-basic/src/App.tsx +++ b/examples/cog-basic/src/App.tsx @@ -15,6 +15,13 @@ import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [ + { + // Dev-only fixture served by examples/cog-basic/vite.config.ts from the + // geotiff-test-data submodule. EPSG:4326, bbox (−204, −18, −162, 24); + // crosses native −180° at u ≈ 24/42 — exercises antimeridian split. + title: "Antimeridian fixture (dev only)", + url: "/__fixtures/antimeridian.tif", + }, { title: "Sentinel-2 True Color Image (New York, 2026)", url: "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif", diff --git a/examples/cog-basic/vite.config.ts b/examples/cog-basic/vite.config.ts index 6b32cedb..8955ea55 100644 --- a/examples/cog-basic/vite.config.ts +++ b/examples/cog-basic/vite.config.ts @@ -1,8 +1,69 @@ +import { createReadStream, promises as fsp } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; +import type { Plugin } from "vite"; import { defineConfig } from "vite"; +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.resolve( + here, + "../../fixtures/geotiff-test-data/rasterio_generated/fixtures", +); + +/** + * Dev-only middleware that serves files under `examples/cog-basic` at + * `/__fixtures/` from the vendored geotiff-test-data submodule. + * + * Honors HTTP Range requests with 206 + Content-Range so the COGLayer + * behaves identically to a production COG bucket — important for any + * fixture used to validate range-read code paths. + */ +function localFixtures(): Plugin { + return { + name: "local-fixtures", + configureServer(server) { + server.middlewares.use("/__fixtures/", (req, res, next) => { + const requested = decodeURIComponent(req.url ?? "").replace(/^\/+/, ""); + const filePath = path.resolve(fixturesDir, requested); + if ( + filePath !== fixturesDir && + !filePath.startsWith(fixturesDir + path.sep) + ) { + res.statusCode = 403; + res.end(); + return; + } + fsp + .stat(filePath) + .then((stat) => { + res.setHeader("Content-Type", "image/tiff"); + res.setHeader("Accept-Ranges", "bytes"); + const range = req.headers.range; + const m = range ? /^bytes=(\d+)-(\d*)$/.exec(String(range)) : null; + if (m) { + const start = Number.parseInt(m[1]!, 10); + const end = m[2] ? Number.parseInt(m[2], 10) : stat.size - 1; + res.statusCode = 206; + res.setHeader( + "Content-Range", + `bytes ${start}-${end}/${stat.size}`, + ); + res.setHeader("Content-Length", String(end - start + 1)); + createReadStream(filePath, { start, end }).pipe(res); + return; + } + res.setHeader("Content-Length", String(stat.size)); + createReadStream(filePath).pipe(res); + }) + .catch(next); + }); + }, + }; +} + export default defineConfig({ - plugins: [react()], + plugins: [react(), localFixtures()], base: "/deck.gl-raster/examples/cog-basic/", worker: { format: "es" }, server: { From 370eb5fc73a322598aac003df555ed60d7af85d7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 12:47:20 -0400 Subject: [PATCH 10/13] fix(deck.gl-raster): use per-piece geotransform shift instead of wrapped projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../raster-tile-layer/raster-tile-layer.ts | 26 ++--- .../src/raster-tileset/raster-tileset-2d.ts | 108 +++++++++++++----- .../raster-tileset-2d-antimeridian.test.ts | 89 +++++++++++++++ 3 files changed, 180 insertions(+), 43 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 89a6b929..66363f67 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -490,14 +490,15 @@ export class RasterTileLayer< /** * Build the two `RasterLayer`s for a Web-Mercator tile that crosses ±180°: - * a west piece (UV `[0, uCut]`) and an east piece (UV `[uCut, 1]`). Both - * pieces share one `reprojectionFns` object built on - * `_projectPositionWrapped`, which folds any common-x past +max into the - * `(−256, 256]` world copy so the two pieces meet continuously at common-x - * 0 rather than either piece spanning the whole world. The split itself - * lives entirely in each piece's `triangulateRectangle` seed — the - * reprojector only refines existing triangles, so confining the seed to a - * sub-rectangle of UV space confines the mesh. + * a west piece (UV `[0, uCut]`) and an east piece (UV `[uCut, 1]`). Each + * piece uses its own `ReprojectionFns` bundle from the tile metadata — + * the bundle composes a `+k·360°` longitude shift into the geotransform + * so the piece's native lngs stay inside proj4's valid range, and pairs + * it with the stock `_projectPosition`/`_unprojectPosition` so the + * forward/inverse round-trip cleanly. The two pieces thus render in + * different world copies; deck.gl `repeat: true` + world-copy traversal + * (#518) bring them together visually. The split itself lives in each + * piece's `triangulateRectangle` seed. */ private _renderAntimeridianTile(opts: { baseId: string; @@ -507,15 +508,8 @@ export class RasterTileLayer< uCut: number; }): Layer[] { const { baseId, tile, data, tileResult, uCut } = opts; - const reprojectionFns: ReprojectionFns = { - forwardTransform: tile.forwardTransform, - inverseTransform: tile.inverseTransform, - forwardReproject: tile._projectPositionWrapped, - inverseReproject: tile._unprojectPosition, - }; const baseProps = { ...this._baseRasterProps(data, tileResult), - reprojectionFns, coordinateSystem: "cartesian" as const, }; return [ @@ -523,6 +517,7 @@ export class RasterTileLayer< this.getSubLayerProps({ ...baseProps, id: `${baseId}-raster-west`, + reprojectionFns: tile._westReprojection!, initialTriangulation: triangulateRectangle(0, 0, uCut, 1), }), ), @@ -530,6 +525,7 @@ export class RasterTileLayer< this.getSubLayerProps({ ...baseProps, id: `${baseId}-raster-east`, + reprojectionFns: tile._eastReprojection!, initialTriangulation: triangulateRectangle(uCut, 0, 1, 1), }), ), diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 6c042ba3..26378dec 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -14,7 +14,10 @@ import type { } from "@deck.gl/geo-layers"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import { transformBounds } from "@developmentseed/proj"; -import type { InitialTriangulation } from "@developmentseed/raster-reproject"; +import type { + InitialTriangulation, + ReprojectionFns, +} from "@developmentseed/raster-reproject"; import type { Matrix4 } from "@math.gl/core"; import type { AntimeridianCut } from "./antimeridian-cut.js"; import { antimeridianCut } from "./antimeridian-cut.js"; @@ -23,7 +26,6 @@ import { getTileIndices, rescaleCommonSpaceToEPSG3857, rescaleEPSG3857ToCommonSpace, - TILE_SIZE, } from "./raster-tile-traversal.js"; import { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js"; import type { RasterTilesetDescriptor } from "./tileset-interface.js"; @@ -105,15 +107,6 @@ export type RasterTileMetadata = { */ _unprojectPosition: ProjectionFunction; - /** - * Source CRS → common space, but with the +180° seam flipped to the negative - * side (common-x ≈ 0). Used as the `forwardReproject` for the east/wrapped - * piece of an antimeridian-crossing tile so that piece does not span the - * whole world. Same stability guarantees as - * {@link RasterTileMetadata._projectPosition}. - */ - _projectPositionWrapped: ProjectionFunction; - /** * Seed triangulation that clamps this tile's reprojection mesh to the valid * Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed. @@ -130,6 +123,23 @@ export type RasterTileMetadata = { * {@link antimeridianCut}. */ _antimeridianCut?: AntimeridianCut; + + /** + * Reprojection bundle for the west piece of an antimeridian-crossing tile, + * or `undefined` for non-crossing tiles. The piece's `forwardTransform` + * composes a `+k·360°` longitude shift onto the original geotransform so + * the piece's native longitudes land inside proj4's valid `(−180°, 180°]` + * range — letting the stock `_projectPosition` / `_unprojectPosition` + * round-trip cleanly (which the reprojector's error metric relies on). + * The visual side effect is that the west piece renders in the world-copy + * where its lngs end up after the shift; world-copy traversal places it + * adjacent to the east piece. Built once in `getTileMetadata` for + * reference stability across renders. + */ + _westReprojection?: ReprojectionFns; + + /** East piece counterpart of {@link RasterTileMetadata._westReprojection}. */ + _eastReprojection?: ReprojectionFns; }; /** @@ -175,7 +185,6 @@ export class RasterTileset2D extends Tileset2D { private getPixelRatio: () => number; private boundingVolumeCache: BoundingVolumeCache; private projectPosition: ProjectionFunction; - private projectPositionWrapped: ProjectionFunction; private unprojectPosition: ProjectionFunction; /** * Projection mode of the viewport on the previous `getTileIndices` call. @@ -209,19 +218,6 @@ export class RasterTileset2D extends Tileset2D { return descriptor.projectFrom3857(mx, my); }; - // Wrapped variant for the negative-side (east) piece of an - // antimeridian-crossing tile. proj4 normalizes the ±180° seam to +180°, so - // the seam vertex projects to common-x ≈ TILE_SIZE (the +max boundary) - // while the rest of that piece sits near 0 — making the piece span the - // whole world and diverge. Flip any vertex that comes back on the positive - // side (common-x > TILE_SIZE / 2) by one world-width so the seam lands at - // ≈ 0. Only the seam vertex is affected; output-sign-based and - // deterministic. See dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md. - this.projectPositionWrapped = (x, y) => { - const [cx, cy] = this.projectPosition(x, y); - return cx > TILE_SIZE / 2 ? [cx - TILE_SIZE, cy] : [cx, cy]; - }; - const rawBounds = transformBounds( this.descriptor.projectTo4326, ...this.descriptor.projectedBounds, @@ -436,13 +432,36 @@ export class RasterTileset2D extends Tileset2D { // 4326 source with an origin past ±180°). See {@link antimeridianCut}. const cornerLng = (corner: [number, number]) => this.descriptor.projectTo4326(corner[0], corner[1])[0]; + const tileWestLng = cornerLng(topLeft); + const tileEastLng = cornerLng(topRight); const _antimeridianCut = antimeridianCut({ - topLeft: cornerLng(topLeft), - topRight: cornerLng(topRight), + topLeft: tileWestLng, + topRight: tileEastLng, bottomLeft: cornerLng(bottomLeft), bottomRight: cornerLng(bottomRight), }); + // For each piece of a crossing tile, compose a `+k·360°` longitude shift + // into the geotransform so the piece's native lngs sit inside proj4's + // valid range. The reprojector's error metric uses `inverseReproject` + // round-trip, which only works when proj4 doesn't have to normalize. + let _westReprojection: ReprojectionFns | undefined; + let _eastReprojection: ReprojectionFns | undefined; + if (_antimeridianCut) { + const { uCut } = _antimeridianCut; + const lngAtCut = tileWestLng + uCut * (tileEastLng - tileWestLng); + _westReprojection = this.buildPieceReprojection( + forwardTransform, + inverseTransform, + (tileWestLng + lngAtCut) / 2, + ); + _eastReprojection = this.buildPieceReprojection( + forwardTransform, + inverseTransform, + (lngAtCut + tileEastLng) / 2, + ); + } + return { bbox: { west, @@ -463,9 +482,42 @@ export class RasterTileset2D extends Tileset2D { inverseTransform, _projectPosition: this.projectPosition, _unprojectPosition: this.unprojectPosition, - _projectPositionWrapped: this.projectPositionWrapped, _webMercatorInitialTriangulation, _antimeridianCut, + _westReprojection, + _eastReprojection, + }; + } + + /** + * Build a per-piece reprojection bundle for an antimeridian-crossing tile. + * Picks the `k·360°` longitude shift that brings the piece's native lngs + * (identified by `pieceMidLng`) into proj4's valid range, composes that + * shift into the geotransform, and pairs it with the stock projection + * pair. The composed closures are stable for the tile's lifetime. + */ + private buildPieceReprojection( + forwardTransform: ProjectionFunction, + inverseTransform: ProjectionFunction, + pieceMidLng: number, + ): ReprojectionFns { + const lngShift = -Math.round(pieceMidLng / 360) * 360; + if (lngShift === 0) { + return { + forwardTransform, + inverseTransform, + forwardReproject: this.projectPosition, + inverseReproject: this.unprojectPosition, + }; + } + return { + forwardTransform: (px, py) => { + const [x, y] = forwardTransform(px, py); + return [x + lngShift, y]; + }, + inverseTransform: (x, y) => inverseTransform(x - lngShift, y), + forwardReproject: this.projectPosition, + inverseReproject: this.unprojectPosition, }; } } diff --git a/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts index 71f57d21..e6be8f9f 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts @@ -56,5 +56,94 @@ describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); expect(metadata._antimeridianCut).toBeUndefined(); + expect(metadata._westReprojection).toBeUndefined(); + expect(metadata._eastReprojection).toBeUndefined(); + }); + + it("shifts the west piece's geotransform by +360° so its native lngs round-trip through proj4", () => { + // antimeridian.tif shape again. West piece native lngs (−204°, −180°) are + // outside proj4's valid range. The piece's forwardTransform must add 360° + // to land in [+156°, +180°]; the matching inverseTransform must subtract + // 360° so pixel coords round-trip cleanly. + const level = new AffineTilesetLevel({ + affine: compose(translation(-204, 24), scale(1, -1)), + arrayWidth: 42, + arrayHeight: 42, + tileWidth: 42, + tileHeight: 42, + mpu: 1, + }); + const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + + const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(metadata._westReprojection).toBeDefined(); + const { forwardTransform: westFwd, inverseTransform: westInv } = + metadata._westReprojection!; + + // Pixel (0, 0) → native (−204, 24) → shifted (+156, 24) + const [wx0, wy0] = westFwd(0, 0); + expect(wx0).toBeCloseTo(156, 9); + expect(wy0).toBeCloseTo(24, 9); + + // Inverse round-trip: shifted (+156, 24) → pixel (0, 0) + const [wpx0, wpy0] = westInv(156, 24); + expect(wpx0).toBeCloseTo(0, 9); + expect(wpy0).toBeCloseTo(0, 9); + }); + + it("leaves the east piece's geotransform unshifted (its lngs already in range)", () => { + const level = new AffineTilesetLevel({ + affine: compose(translation(-204, 24), scale(1, -1)), + arrayWidth: 42, + arrayHeight: 42, + tileWidth: 42, + tileHeight: 42, + mpu: 1, + }); + const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + + const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(metadata._eastReprojection).toBeDefined(); + const { forwardTransform: eastFwd } = metadata._eastReprojection!; + // East piece native lng midpoint = −171° ∈ [−180°, 180°] → no shift. + // Seam pixel (col 24, row 0) → native (−180, 24), unchanged. + const [ex0, ey0] = eastFwd(24, 0); + expect(ex0).toBeCloseTo(-180, 9); + expect(ey0).toBeCloseTo(24, 9); + }); + + it("shifts the east piece by −360° for a fixture crossing native +180° (e.g. native [170, 190])", () => { + // Mirror of the antimeridian.tif case: tile native lngs (170, 190). East + // piece (180, 190) is outside proj4's range and must shift by −360°. + const level = new AffineTilesetLevel({ + affine: compose(translation(170, 10), scale(1, -1)), + arrayWidth: 20, + arrayHeight: 20, + tileWidth: 20, + tileHeight: 20, + mpu: 1, + }); + const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + + const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(metadata._antimeridianCut?.uCut).toBeCloseTo(0.5, 9); + expect(metadata._eastReprojection).toBeDefined(); + const { forwardTransform: eastFwd, inverseTransform: eastInv } = + metadata._eastReprojection!; + // East piece native (180, 190) − 360° = (−180, −170). Right pixel col 20 → + // native 190 → shifted −170. + const [ex, ey] = eastFwd(20, 0); + expect(ex).toBeCloseTo(-170, 9); + expect(ey).toBeCloseTo(10, 9); + // Round-trip. + const [epx, epy] = eastInv(-170, 10); + expect(epx).toBeCloseTo(20, 9); + expect(epy).toBeCloseTo(0, 9); }); }); From 67c33ba9b752c87f0d166a9899a06c6c8fdcadd8 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 13:09:22 -0400 Subject: [PATCH 11/13] fix(deck.gl-raster): scale uCut from geographic fraction to reprojector UV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tile-layer/raster-tile-layer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 66363f67..53366707 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -507,7 +507,15 @@ export class RasterTileLayer< tileResult: RenderTileResult; uCut: number; }): Layer[] { - const { baseId, tile, data, tileResult, uCut } = opts; + const { baseId, tile, data, tileResult, uCut: uCutGeographic } = opts; + // `antimeridianCut` returns the seam location as a fraction of the tile's + // geographic span (0..1 over the full west→east lng range). The reprojector + // maps its UV [0,1] to pixel-INDEX [0, W-1] (delatin.ts:470), so the same + // fraction has to be scaled by W/(W-1) to land on the seam pixel. Without + // this, the east piece's left edge sits ~0.5 pixel west of native −180°, + // where proj4 still treats lng as canonical positive — the piece then + // spans the whole world via the prime meridian and diverges. + const uCut = (uCutGeographic * tile.tileWidth) / (tile.tileWidth - 1); const baseProps = { ...this._baseRasterProps(data, tileResult), coordinateSystem: "cartesian" as const, From c799a27eae08b69fc8cafeb0b53ff3d28669ddeb Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 13:29:39 -0400 Subject: [PATCH 12/13] fix(deck.gl-raster): use data.width (image dims) for uCut scaling, not tile.tileWidth (block size) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tile-layer/raster-tile-layer.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 53366707..7acea16c 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -510,12 +510,13 @@ export class RasterTileLayer< const { baseId, tile, data, tileResult, uCut: uCutGeographic } = opts; // `antimeridianCut` returns the seam location as a fraction of the tile's // geographic span (0..1 over the full west→east lng range). The reprojector - // maps its UV [0,1] to pixel-INDEX [0, W-1] (delatin.ts:470), so the same - // fraction has to be scaled by W/(W-1) to land on the seam pixel. Without - // this, the east piece's left edge sits ~0.5 pixel west of native −180°, - // where proj4 still treats lng as canonical positive — the piece then - // spans the whole world via the prime meridian and diverges. - const uCut = (uCutGeographic * tile.tileWidth) / (tile.tileWidth - 1); + // maps its UV [0, 1] to pixel-INDEX [0, W-1] (delatin.ts:470), so the same + // fraction has to be scaled by W/(W-1) to land on the seam pixel. W here + // is the actual image data width (`data.width`), not `tile.tileWidth` — + // for a COG, `tile.tileWidth` is the block size (e.g. 64 for + // antimeridian.tif), but the data passed to the reprojector is the + // (smaller) image size (42), and the reprojector keys off that. + const uCut = (uCutGeographic * data.width) / (data.width - 1); const baseProps = { ...this._baseRasterProps(data, tileResult), coordinateSystem: "cartesian" as const, From fe288021b0540b83761786c742c4bd66baa121aa Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 2 Jun 2026 16:10:23 -0400 Subject: [PATCH 13/13] =?UTF-8?q?fix(deck.gl-raster):=20drop=20uCut=20scal?= =?UTF-8?q?ing=20=E2=80=94=20RasterLayer=20adds=20+1=20to=20width=20intern?= =?UTF-8?q?ally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tile-layer/raster-tile-layer.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 7acea16c..b6579a3e 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -507,16 +507,12 @@ export class RasterTileLayer< tileResult: RenderTileResult; uCut: number; }): Layer[] { - const { baseId, tile, data, tileResult, uCut: uCutGeographic } = opts; + const { baseId, tile, data, tileResult, uCut } = opts; // `antimeridianCut` returns the seam location as a fraction of the tile's - // geographic span (0..1 over the full west→east lng range). The reprojector - // maps its UV [0, 1] to pixel-INDEX [0, W-1] (delatin.ts:470), so the same - // fraction has to be scaled by W/(W-1) to land on the seam pixel. W here - // is the actual image data width (`data.width`), not `tile.tileWidth` — - // for a COG, `tile.tileWidth` is the block size (e.g. 64 for - // antimeridian.tif), but the data passed to the reprojector is the - // (smaller) image size (42), and the reprojector keys off that. - const uCut = (uCutGeographic * data.width) / (data.width - 1); + // geographic span (0..1 over the full west→east lng range). `RasterLayer` + // constructs its reprojector with `width + 1` (raster-layer.ts:252), so + // the reprojector's UV*(W-1) = pixel-index maps UV `(seamCol / W)` + // exactly onto the seam pixel — no scaling needed here. const baseProps = { ...this._baseRasterProps(data, tileResult), coordinateSystem: "cartesian" as const,