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 f8e83932..26af2bd7 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
@@ -2,7 +2,7 @@
- **Date:** 2026-05-27
- **Issues:** [#171](https://github.com/developmentseed/deck.gl-raster/issues/171), [#366](https://github.com/developmentseed/deck.gl-raster/issues/366)
-- **Status:** Proposed
+- **Status:** Implemented as unwrap + clip (2026-10-01), superseding the cut-in-two MVP.
- **Prerequisite (merged):** [#517](https://github.com/developmentseed/deck.gl-raster/issues/517) / [#518](https://github.com/developmentseed/deck.gl-raster/pull/518) — multi-world-copy tile traversal
- **Related:** [#182](https://github.com/developmentseed/deck.gl-raster/issues/182), [#351](https://github.com/developmentseed/deck.gl-raster/pull/351) (reprojector sub-domain / cutline), [`dev-docs/coordinate-systems.md`](../coordinate-systems.md), [`dev-docs/world-copies.md`](../world-copies.md)
- **Informed by (not the basis):** [#353](https://github.com/developmentseed/deck.gl-raster/pull/353) (rejected: global proj4 `+over` hack), [#374](https://github.com/developmentseed/deck.gl-raster/pull/374) and [#269](https://github.com/developmentseed/deck.gl-raster/pull/269) (AI-generated unwrap attempts)
@@ -29,112 +29,19 @@ projectPosition = (x, y) => rescaleEPSG3857ToCommonSpace(descriptor.projectTo385
**Unwrapping in source-longitude space does not work:** proj4 re-normalizes any longitude you hand it (190° → −170°), re-introducing the jump (dcherian, [#269](https://github.com/developmentseed/deck.gl-raster/pull/269)). Any unwrap must therefore act at/after the transform output — which is what makes the "keep it as one tile" approaches fragile.
-## Approach: cut the tile in two
+## Current approach (2026-10-01): unwrap in the reprojector, clip the mesh
-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 cut-in-two MVP only handled **vertical** seams: it estimated where the seam was from four corner longitudes and rejected anything else. Slanted (rotated geotransform), curved (e.g. UTM 60N) and world-spanning tiles fell back to a full mesh and diverged again. It was replaced by two operations that make no assumption about seam shape or CRS:
-- 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.
+1. **Unwrap in the reprojector.** `RasterReprojector`'s `wrapX` option (the output x period; `TILE_SIZE` for Web Mercator) snaps each new vertex's exact x to the copy nearest its parent triangle's *interpolated* x (`snapToCopy`), which Delatin already computes for its error check. That reference is off only by the reprojection error, far inside the ±half-period tolerance. Seed vertices have no parent, so each is unwrapped along the uv path from the previous one (`unwrapAlong`, bisecting until each step is under a quarter period — needed so a −180..180 tile's corners, x = 0 ≡ 512, don't collapse).
+2. **Clip at x = k·512.** `clipMeshToWorld` (deck.gl-raster) clips the finished mesh against each world boundary and shifts every part back into `[0, 512]`, interpolating uv linearly along the cut edge — the same piecewise-linear texture mapping. That boundary *is* the discontinuity, so the cut is exact for any seam shape. One mesh, one layer per tile. The clip (not just the unwrap) is needed because deck.gl only draws the world copies the viewport's bounds cover: a mesh at x ≈ 512..530 vanishes when zoomed tight on the east side alone.
-The antimeridian becomes *a tile boundary*, which the pipeline already handles, instead of a coordinate-space discontinuity.
+Supporting changes:
-### Seam handling
+- **Projection.** `_projectPosition` goes via the source's own lng/lat (`projectTo4326` → `lngLatToWorld`), so a 4326 source's native longitude (e.g. −204°) maps continuously without wrapping at all. `_unprojectPosition` accepts any copy: the stock 3857 inverse inside `[0, 512]`; outside, x maps linearly to an un-normalized longitude through `projectFrom4326` (passed through by a 4326 source, folded by proj4's `adjlon` for a projected one).
+- **Traversal.** Bounding-volume reference points are unwrapped against the tile center with `unwrapAlong`; the dataset bounds pre-filter unwraps the dataset's corners along its perimeter. The two are unwrapped from different anchors (tile centre vs dataset corner), so they can land a world apart — `insideBounds` therefore tests overlap with x periodic (±1 world).
+- **Edge tiles.** COGLayer fetches edge tiles clipped to the image (`boundless: false`), so the reprojector's uv already spans only data — a 360° image's 36 px overview never meshes its 640° nominal tile. (MultiCOGLayer fetches boundless; a world-spanning source there would mesh padding.)
-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.
+Tests: `raster-reproject/tests/wrap-x.test.ts`, `deck.gl-raster/tests/clip-mesh-to-world.test.ts`, `deck.gl-raster/tests/raster-tileset/raster-tileset-2d-antimeridian.test.ts`, and `deck.gl-geotiff/tests/antimeridian-fixtures.test.ts` (every tile of the vertical, rotated, UTM 60N and 360° fixtures through real proj4).
-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.
-
-## Locating the cut
-
-Compute the cut generally by **inverse-projecting the antimeridian into source space**: sample `(180°, lat)` for `lat ∈ [−90, 90]`, run each point through `descriptor.projectFrom4326` (WGS84 → source CRS) then the inverse geotransform → a polyline in source pixel / UV space. This is robust to rotated geotransforms and arbitrary CRS (it does not assume the cut is the `lng = 180°` pixel column).
-
-The cut's **shape** determines feasibility:
-
-- **Straight cut** (axis-aligned EPSG:4326 → vertical; rotated geotransform → slanted): a straight line splits the unit square into two **convex** pieces.
-- **Curved cut** (curved-meridian CRS): at least one piece is **concave**.
-
-The MVP handles **any straight cut** — vertical (axis-aligned EPSG:4326) *and* slanted (rotated geotransform) — since both yield convex pieces that delaunator triangulates exactly. It **errors clearly** only when the inverse-projected meridian is *curved* (concave pieces; curved-meridian CRS), which is deferred.
-
-## Architecture
-
-The split lives in **one place** — the per-tile sublayer factory — and every other component keeps its single-mesh contract.
-
-```
-RasterTileLayer._renderSubLayers (per tile) ← the only split point
- ├─ normal tile → 1 RasterLayer → 1 RasterReprojector → 1 mesh → 1 MeshTextureLayer
- └─ crossing tile → 2 RasterLayers → (each) 1 reprojector → 1 mesh → 1 MeshTextureLayer
-```
-
-- **`RasterReprojector`** ([`delatin.ts`](../../packages/raster-reproject/src/delatin.ts)) — one mesh, always. Gains an optional **initial-triangulation seed** `{ uvs, triangles, halfedges }` (delaunator's shape), defaulting to today's unit-square 2-triangle seed. The refinement core (`_step`, `_legalize`, `_findReprojectionCandidate`, the error queue) is already seed-agnostic; only the constructor's hardcoded init changes. Refinement only ever *splits existing triangles*, so a seed covering `[0, u_cut]×[0,1]` keeps the whole mesh in that sub-region. `width`/`height` stay the full image, so sub-domain UVs index the full texture — no texture re-windowing.
-- **Seed building (no shipped wrapper)** — `raster-reproject` exposes only the `InitialTriangulation` type, not a builder. Wrapping delaunator is a one-liner, so its docstring documents the pattern instead (`uvs`/`triangles`/`halfedges` = delaunator's `coords`/`triangles`/`halfedges`). delaunator is a **dev/test dependency** of `raster-reproject` — used by tests to validate winding compatibility — *not* a runtime dependency, so nothing is shipped to consumers. The deck.gl-raster cut builder (follow-up) constructs each convex-piece seed at runtime; whether that uses delaunator (a runtime dep there) or a hand-rolled convex-fan triangulation is decided in the integration plan.
-- **Cut builder** (deck.gl-raster) — computes the cut (inverse-project the antimeridian) → 1 or 2 sub-domain seeds. Lives in the tileset's `getTileMetadata` and is stored on tile metadata (per the "tile state on the tile" convention), so it is computed once and shared by both the render and the bounding volume.
-- **`RasterLayer`** ([`raster-layer.ts`](../../packages/deck.gl-raster/src/raster-layer.ts)) — one mesh, one `MeshTextureLayer`, unchanged except a new `initialTriangulation` prop (default: full square) passed to its reprojector.
-- **`RasterTileLayer._renderSubLayers`** — reads the tile's cut info and emits 1 or 2 `RasterLayer`s. Both crossing sub-layers share the **same** `reprojectionFns` (the tile's `_projectPosition`); they differ only in `initialTriangulation` and sublayer id (`…-raster-west` / `…-raster-east`).
-- **Traversal** — a **two-box bounding volume** for a crossing tile (west ≈ `[510,512]`, east ≈ `[0,2]`), each a normal `[0,512]` box, mapping 1:1 to the two `RasterLayer`s and composing with the world-copy traversal's per-offset selection (a crossing tile natively occupies two world bands at offset 0).
-
-## Transparency to end users
-
-The split is entirely below the tile-data boundary:
-
-- **`getTileData` is unchanged.** A crossing tile is one tile index `(x, y, z)` and a single *contiguous* source-pixel fetch — the discontinuity appears only when projecting to 3857, after fetch. Any data source (COG, zarr, user-supplied) needs zero antimeridian awareness, and the tile is decoded once (both pieces sample the one texture).
-- **`_renderSubLayers` is library-internal** — standard `COGLayer` / `RasterTileLayer` users never write it.
-
-Caveat: a user who *subclasses* and overrides `_renderSubLayers` would bypass the split.
-
-## Unification
-
-The initial-triangulation seed subsumes several pending needs into one primitive — *the caller hands the reprojector a seed*:
-
-- Normal tile → full unit square → 1 layer (unchanged behavior).
-- Antimeridian crossing → west + east seeds → 2 layers.
-- Pole clamp (#182) / `uvBounds` (#351) → one clamped-rectangle seed → 1 layer (data beyond ±85.051° is not meshed).
-- Collar cutline → one inset-domain seed → 1 layer.
-
-## Scope
-
-**In scope:**
-- Web Mercator viewport.
-- Straight cut (convex pieces): axis-aligned EPSG:4326 (vertical) *and* rotated geotransforms (slanted).
-- Test datasets:
- - **Primary, deterministic:** the [`antimeridian.tif`](https://github.com/developmentseed/geotiff-test-data/blob/3c7ceb9ec2ed23b0ba71c2222ac4d5e6f31db0ec/rasterio_generated/fixtures/antimeridian.tif) fixture, already vendored via the `fixtures/geotiff-test-data` submodule (`fixtures/geotiff-test-data/rasterio_generated/fixtures/antimeridian.tif`). 42×42, EPSG:4326, bbox (−204, −18, −162, 24) → crosses −180° with a clean vertical cut at pixel column 24 (lng −204 ≡ +156 wrapped).
- - **Global / edge-overhang variant:** a global EPSG:4326 COG that triggers #366 — e.g. WorldPop `ppp_2020_1km_Aggregated.tif` (from the issue) or the GEDTM30 global DEM (from #353).
-
-**Out of scope (deferred):**
-- Globe view (separate prototype).
-- Curved-meridian / polar CRS (concave pieces). delaunator fills the convex hull, so a concave piece would gain triangles across the seam; handling needs centroid-filtering or constrained Delaunay, or the render-as-one fallback. The MVP errors on a non-straight cut.
-
-## Edge cases & risks
-
-- **Degenerate slivers:** the half-pixel-overhang case (`−180.0012°`) splits into a sub-pixel sliver + a main piece. Skip pieces below an ε UV width so we don't emit a degenerate mesh.
-- **Seam between pieces:** west's cut edge lands at common-x 512, east's at 0 ≡ 512 in the +1 world copy — they abut across the world-copy boundary. Encode the shared edge bit-identically (same discipline as adjacent tiles, [`coordinate-systems.md`](../coordinate-systems.md)).
-- **delaunator ↔ delatin orientation:** this repo's delatin works in UV (y-down). Verify winding/`inCircle` compatibility with a test (delaunator on the 4 unit-square corners → seed → delatin refines identically to the current hardcoded init).
-- **Texture upload:** both sublayers reference the same tile image; without a shared luma `Texture` it uploads twice. Negligible for the prototype (dateline tiles are a thin strip); optimize later if needed.
-
-## Test plan
-
-**Unit**
-- Reprojector seeded with a delaunator-built sub-rectangle (the documented pattern) converges and adds no vertices outside the seed domain; a delaunator unit-square seed refines validly (winding compatibility), equivalent to the current default.
-- Cut location: inverse-projecting the antimeridian yields the expected cut line — a vertical UV column for axis-aligned EPSG:4326 (the `antimeridian.tif` fixture cuts at column 24 / `u ≈ 0.571`), a slanted line for a rotated geotransform; a *curved* cut is detected and errors.
-- Two-box bounding volume for a crossing tile (west/east boxes; correct selection under the world-copy traversal).
-
-**Integration / visual (cog-basic)**
-- The `antimeridian.tif` fixture renders as a single contiguous image across ±180° (west piece near +180°, east piece near −180°), staying continuous when panning across the seam.
-- A global EPSG:4326 COG (WorldPop / GEDTM30) renders correctly at the dateline (no `error=43200` divergence; no mislocated rectangles).
-- Before/after comparison against current main.
-
-## Implementation stages (high level)
-
-1. `RasterReprojector` accepts an `InitialTriangulation` seed (default unchanged); `InitialTriangulation` docstring documents the delaunator pattern; delaunator added as a dev dependency; tests use a delaunator-built seed to validate winding + sub-domain confinement. **(Done.)**
-2. Cut location (inverse-project the antimeridian) + convexity check (error on a curved/concave cut), on tile metadata.
-3. Two-box bounding volume in traversal for crossing tiles.
-4. `RasterLayer` `initialTriangulation` prop; `_renderSubLayers` emits 1 or 2 `RasterLayer`s, each seeded from its cut sub-domain.
-5. Example wiring + visual validation in cog-basic.
-
-(Detailed task breakdown lives in the implementation plan, not here.)
+Limit: the refinement snap needs each coarse triangle's interpolation error under half a period (180°). A seed triangle spanning a wildly nonlinear region (a polar stereographic tile over the pole) could break that; seed a denser grid if a polar fixture shows it.
diff --git a/dev-docs/world-copies.md b/dev-docs/world-copies.md
index e5908fb6..d3c322dc 100644
--- a/dev-docs/world-copies.md
+++ b/dev-docs/world-copies.md
@@ -14,10 +14,23 @@ so panning across the antimeridian evicts visible tiles. See
- A tile is identified by `(x, y, z)`. Its data and primary-world common-space
position are computed once (see `RasterTileNode.getBoundingVolume`) and
cached.
-- For a viewport with `subViewports.length > 1` (Web Mercator + `repeat: true`
- whose bounds straddle ±180°), the traversal additionally tests each tile's
- bounding volume **translated** by `worldOffset * 512` along common-space X
- for `worldOffset` in `±1, ±2, … ±MAX_MAPS`.
+- Whenever repeat mode is active at all (`viewport.subViewports != null` — Web
+ Mercator + `repeat: true`, regardless of whether the *viewport's own*
+ current bounds happen to straddle ±180°), the traversal additionally tests
+ each tile's bounding volume **translated** by `worldOffset * 512` along
+ common-space X for `worldOffset` in `±1, ±2, … ±MAX_MAPS`.
+ - Originally gated on `subViewports.length > 1` instead — that only tests
+ whether the *viewport* currently straddles a seam, which is unrelated to
+ whether a *tile's own* position is on a different world-copy frame than
+ the viewport's canonical one. This wrongly skipped the offset search the
+ moment a user zoomed in tight to one side, away from the seam, even
+ though a tile could still need a shifted pass to be found — see the
+ antimeridian design doc's ["Locating and selecting a crossing tile in the
+ traversal"](specs/2026-05-27-antimeridian-crossing-tile-design.md#locating-and-selecting-a-crossing-tile-in-the-traversal)
+ for the regression this caused and the fix. The equivalent gate in
+ `MosaicTileset2D.getTileIndices` (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts`)
+ — a structurally different Flatbush/lng-lat-bbox search, not an OBB — was
+ changed the same way for the same reason.
- A tile is selected if it passes the frustum test at **any** offset.
- Selected tile indices are returned as `(x, y, z)` triples — the rendering
pipeline already handles drawing each tile in every visible world copy.
@@ -67,6 +80,18 @@ The traversal walks offset = ±1, ±2, ... until either:
Walk-until-empty terminates quickly in practice. The cap exists to bound
worst-case behavior at extreme zoom-outs and aspect ratios.
+An antimeridian-crossing tile's two pieces (see the antimeridian design doc's
+["Seam handling"](specs/2026-05-27-antimeridian-crossing-tile-design.md#seam-handling))
+rely on this same offset search to be found and drawn when only one piece is
+in view — each piece's mesh sits at its own natural common-space position
+(west near `TILE_SIZE`, east near `0`) rather than a shared frame, so the
+ordinary per-tile offset passes above are what place it correctly, no
+antimeridian-specific traversal logic needed. This holds regardless of how
+wide a piece is: each piece's corrected `x` is bounded by construction to
+`[0, TILE_SIZE]` for any tile under the design doc's `MAX_TOTAL_SPAN_DEG`
+self-overlap limit, so a wide piece never needs more offset passes than an
+ordinary tile — `MAX_MAPS = 3` did not need to scale with piece width.
+
## Prior art
The pattern matches `@deck.gl/geo-layers`'s OSM tile traversal:
diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md
new file mode 100644
index 00000000..792a7b94
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/README.md
@@ -0,0 +1,23 @@
+# Antimeridian: Mosaic + MultiCOG Example
+
+Digital Earth Pacific GeoMAD composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite).
+
+## Setup
+
+1. Install dependencies from the repository root:
+ ```bash
+ pnpm install
+ ```
+
+2. Build the packages:
+ ```bash
+ pnpm build
+ ```
+
+3. Run the development server:
+ ```bash
+ cd examples/antimeridian-mosaic-multi-example
+ pnpm dev
+ ```
+
+4. Open your browser to http://localhost:3000
diff --git a/examples/antimeridian-mosaic-multi-example/index.html b/examples/antimeridian-mosaic-multi-example/index.html
new file mode 100644
index 00000000..84bf2285
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Antimeridian Mosaic + MultiCOG Example
+
+
+
+
+
+
+
diff --git a/examples/antimeridian-mosaic-multi-example/package.json b/examples/antimeridian-mosaic-multi-example/package.json
new file mode 100644
index 00000000..4fab875e
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "deck.gl-antimeridian-mosaic-multi-example",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "publish": "pnpm build && gh-pages -d dist -b gh-pages -e examples/antimeridian-mosaic-multi-example"
+ },
+ "dependencies": {
+ "@chakra-ui/react": "^3.34.0",
+ "@deck.gl/core": "^9.3.2",
+ "@deck.gl/geo-layers": "^9.3.2",
+ "@deck.gl/layers": "^9.3.2",
+ "@deck.gl/mapbox": "^9.3.2",
+ "@deck.gl/mesh-layers": "^9.3.2",
+ "@developmentseed/deck.gl-geotiff": "workspace:^",
+ "@developmentseed/deck.gl-raster": "workspace:^",
+ "@developmentseed/geotiff": "workspace:^",
+ "@emotion/react": "^11.14.0",
+ "@luma.gl/core": "^9.3.2",
+ "deck.gl-raster-examples-shared": "workspace:*",
+ "hyparquet": "^1.31.2",
+ "hyparquet-compressors": "^1.1.2",
+ "maplibre-gl": "^5.24.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "react-map-gl": "^8.1.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "gh-pages": "^6.3.0",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx
new file mode 100644
index 00000000..409edeb2
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx
@@ -0,0 +1,102 @@
+import { Text } from "@chakra-ui/react";
+import { PathLayer } from "@deck.gl/layers";
+import { MosaicLayer, MultiCOGLayer } from "@developmentseed/deck.gl-geotiff";
+import { LinearRescale } from "@developmentseed/deck.gl-raster/gpu-modules";
+import type { DebugState } from "deck.gl-raster-examples-shared";
+import {
+ ControlPanel,
+ DebugControls,
+ DeckGlOverlay,
+ ExternalLink,
+} from "deck.gl-raster-examples-shared";
+import "maplibre-gl/dist/maplibre-gl.css";
+import { useEffect, useState } from "react";
+import { Map as MaplibreMap } from "react-map-gl/maplibre";
+import type { GeomadItem } from "./data.js";
+import { fetchGeomadItems } from "./data.js";
+
+export default function App() {
+ const [geomadItems, setGeomadItems] = useState([]);
+ const [debugState, setDebugState] = useState({
+ debug: false,
+ debugOpacity: 0.5,
+ debugLevel: 1,
+ });
+
+ useEffect(() => {
+ fetchGeomadItems().then(setGeomadItems);
+ }, []);
+
+ const mosaicLayer = new MosaicLayer({
+ id: "geomad-mosaic",
+ sources: geomadItems,
+ renderSource: (source) =>
+ new MultiCOGLayer({
+ id: `geomad-${source.id}`,
+ sources: {
+ red: { url: source.assets.red },
+ green: { url: source.assets.green },
+ blue: { url: source.assets.blue },
+ },
+ composite: { r: "red", g: "green", b: "blue" },
+ renderPipeline: [
+ {
+ module: LinearRescale,
+ props: { rescaleMin: 7200 / 65535, rescaleMax: 12000 / 65535 },
+ },
+ ],
+ debug: debugState.debug,
+ debugOpacity: debugState.debugOpacity,
+ debugLevel: debugState.debugLevel,
+ }),
+ });
+
+ // Reference line at the antimeridian (±180°).
+ const antimeridianLine = new PathLayer({
+ id: "antimeridian-line",
+ data: [
+ [
+ [180, 85],
+ [180, -85],
+ ],
+ ],
+ getPath: (d: [number, number][]) => d,
+ getColor: [255, 0, 200, 255],
+ getWidth: 2,
+ widthUnits: "pixels",
+ });
+
+ return (
+
+
+
+
+
+
+
+ Digital Earth Pacific GeoMAD composed through `MosaicLayer` +{" "}
+ `MultiCOGLayer` (R/G/B band composite).
+
+
+
+ Digital Earth Pacific
+ {" "}
+ Landsat GeoMAD mosaic.
+
+
+
+
+ );
+}
diff --git a/examples/antimeridian-mosaic-multi-example/src/data.ts b/examples/antimeridian-mosaic-multi-example/src/data.ts
new file mode 100644
index 00000000..c60e1ec3
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/src/data.ts
@@ -0,0 +1,23 @@
+import { fetchStacGeoparquetItems } from "./stac-geoparquet.js";
+
+export type GeomadItem = {
+ id: string;
+ bbox: [number, number, number, number];
+ assets: { red: string; green: string; blue: string };
+};
+
+const PARQUET_URL =
+ "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet";
+
+export async function fetchGeomadItems(): Promise {
+ const items = await fetchStacGeoparquetItems(PARQUET_URL);
+ return items.map(({ id, bbox, assets }) => ({
+ id,
+ bbox,
+ assets: {
+ red: assets.red!.href,
+ green: assets.green!.href,
+ blue: assets.blue!.href,
+ },
+ }));
+}
diff --git a/examples/antimeridian-mosaic-multi-example/src/main.tsx b/examples/antimeridian-mosaic-multi-example/src/main.tsx
new file mode 100644
index 00000000..171b36eb
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/src/main.tsx
@@ -0,0 +1,12 @@
+import { ExampleProvider } from "deck.gl-raster-examples-shared";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.js";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/examples/antimeridian-mosaic-multi-example/src/stac-geoparquet.ts b/examples/antimeridian-mosaic-multi-example/src/stac-geoparquet.ts
new file mode 100644
index 00000000..c20fc5cd
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/src/stac-geoparquet.ts
@@ -0,0 +1,33 @@
+import { asyncBufferFromUrl, parquetReadObjects } from "hyparquet";
+import { compressors } from "hyparquet-compressors";
+
+export type StacAssets = Record;
+
+export type StacGeoparquetItem = {
+ id: string;
+ bbox: [number, number, number, number];
+ assets: StacAssets;
+};
+
+export async function fetchStacGeoparquetItems(
+ url: string,
+): Promise {
+ const file = await asyncBufferFromUrl({ url });
+ const rows = await parquetReadObjects({
+ file,
+ columns: ["id", "bbox", "assets"],
+ compressors,
+ });
+ return rows.map((row) => {
+ const { id, bbox, assets } = row as {
+ id: string;
+ bbox: { xmin: number; ymin: number; xmax: number; ymax: number };
+ assets: StacAssets;
+ };
+ return {
+ id,
+ bbox: [bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax],
+ assets,
+ };
+ });
+}
diff --git a/examples/antimeridian-mosaic-multi-example/src/vite-env.d.ts b/examples/antimeridian-mosaic-multi-example/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/antimeridian-mosaic-multi-example/tsconfig.json b/examples/antimeridian-mosaic-multi-example/tsconfig.json
new file mode 100644
index 00000000..4bd6962d
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../tsconfig.base.json",
+ "include": ["src"]
+}
diff --git a/examples/antimeridian-mosaic-multi-example/vite.config.ts b/examples/antimeridian-mosaic-multi-example/vite.config.ts
new file mode 100644
index 00000000..32b2eef5
--- /dev/null
+++ b/examples/antimeridian-mosaic-multi-example/vite.config.ts
@@ -0,0 +1,11 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react()],
+ base: "/deck.gl-raster/examples/antimeridian-mosaic-multi-example/",
+ worker: { format: "es" },
+ server: {
+ port: 3000,
+ },
+});
diff --git a/examples/cog-basic/src/App.tsx b/examples/cog-basic/src/App.tsx
index 1db1620b..768aabb4 100644
--- a/examples/cog-basic/src/App.tsx
+++ b/examples/cog-basic/src/App.tsx
@@ -1,4 +1,5 @@
import { NativeSelect, Text } from "@chakra-ui/react";
+import { PathLayer } from "@deck.gl/layers";
import { COGLayer } from "@developmentseed/deck.gl-geotiff";
import type { DebugState } from "deck.gl-raster-examples-shared";
import {
@@ -18,9 +19,25 @@ 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",
+ // crosses native −180° at u ≈ 24/42 — exercises antimeridian unwrap + clip.
+ title: "Antimeridian fixture (dev only) (4326)",
+ url: `${window.location.origin}/__fixtures/antimeridian.tif`,
+ },
+ {
+ title: "Antimeridian fixture (dev only) (3832)",
+ url: "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/066/022/2025/dep_ls_geomad_066_022_2025_red.tif",
+ },
+ {
+ title: "Antimeridian fixture (dev only) (UTM Zone 60)",
+ url: `${window.location.origin}/__fixtures/antimeridian_utm60.tif`,
+ },
+ {
+ title: "Antimeridian fixture (dev only) (4326 rotated)",
+ url: `${window.location.origin}/__fixtures/antimeridian_rotated.tif`,
+ },
+ {
+ title: "Antimeridian fixture (dev only) (360°)",
+ url: `${window.location.origin}/__fixtures/antimeridian_360.tif`,
},
{
title: "Sentinel-2 True Color Image (New York, 2026)",
@@ -97,6 +114,21 @@ const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [
},
];
+// Reference line at the antimeridian (±180°).
+const antimeridianLine = new PathLayer({
+ id: "antimeridian-line",
+ data: [
+ [
+ [180, 85],
+ [180, -85],
+ ],
+ ],
+ getPath: (d: [number, number][]) => d,
+ getColor: [255, 0, 200, 255],
+ getWidth: 2,
+ widthUnits: "pixels",
+});
+
export default function App() {
const mapRef = useRef(null);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -141,7 +173,7 @@ export default function App() {
}}
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
>
-
+
diff --git a/examples/cog-basic/vite.config.ts b/examples/cog-basic/vite.config.ts
index 8955ea55..547aa1f7 100644
--- a/examples/cog-basic/vite.config.ts
+++ b/examples/cog-basic/vite.config.ts
@@ -43,7 +43,10 @@ function localFixtures(): Plugin {
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;
+ const requestedEnd = m[2]
+ ? Number.parseInt(m[2], 10)
+ : stat.size - 1;
+ const end = Math.min(requestedEnd, stat.size - 1);
res.statusCode = 206;
res.setHeader(
"Content-Range",
diff --git a/fixtures/geotiff-test-data b/fixtures/geotiff-test-data
index 7fa07ac1..cd755360 160000
--- a/fixtures/geotiff-test-data
+++ b/fixtures/geotiff-test-data
@@ -1 +1 @@
-Subproject commit 7fa07ac10f9d54b2ac2e557117432cf8f2f80eef
+Subproject commit cd755360e7cbd327070a12511a0e39c3886fbe82
diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts
index 79084286..0b362996 100644
--- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts
+++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts
@@ -127,6 +127,19 @@ const defaultProps: Partial = {
sources: [],
};
+/**
+ * A source's bbox may be GeoJSON-flipped (RFC 7946 §5.2: `minX > maxX` marks
+ * an antimeridian crossing) — unwrap it onto a continuous frame so the
+ * Flatbush spatial index and every downstream consumer (priority queue
+ * distance, `MosaicTileset2D`'s viewport search and tile metadata) agree on
+ * one bbox per source.
+ */
+function normalizeSourceBbox(source: T): T {
+ const [minX, minY, maxX, maxY] = source.bbox;
+ const east = maxX < minX ? maxX + 360 : maxX;
+ return { ...source, bbox: [minX, minY, east, maxY] };
+}
+
/**
* Build the limiter `getPriority` callback for one mosaic source: euclidean
* distance from the source's bbox center to the current viewport center, in
@@ -181,6 +194,9 @@ export class MosaicLayer<
declare state: {
// The index can be null if sources are empty
index: Flatbush | null;
+ // `props.sources` with each bbox normalized via `normalizeSourceBbox` —
+ // see that function's doc comment.
+ sources: MosaicT[];
};
override initializeState(context: LayerContext): void {
@@ -199,9 +215,9 @@ export class MosaicLayer<
}
private _buildSpatialIndex(): void {
- const { sources } = this.props;
+ const sources = this.props.sources.map(normalizeSourceBbox);
if (sources.length === 0) {
- this.setState({ index: null });
+ this.setState({ index: null, sources });
return;
}
@@ -211,7 +227,7 @@ export class MosaicLayer<
}
index.finish();
- this.setState({ index });
+ this.setState({ index, sources });
}
renderTileLayer(
@@ -235,7 +251,7 @@ export class MosaicLayer<
// Arrow functions bind to the MosaicLayer instance, which deck.gl reuses
// across prop updates — so `this.props` and `this.state` always reflect
// the latest values when the tileset reads them.
- const getSources = () => this.props.sources;
+ const getSources = () => this.state.sources;
const getIndex = () => this.state.index;
class MosaicTileset2DFactory extends MosaicTileset2D {
constructor(opts: any) {
diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts
index 2486a431..cb7efd7c 100644
--- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts
+++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts
@@ -46,6 +46,9 @@ export type MosaicSource = {
* cache identifier is always defined. */
type ResolvedSource = TileIndex & MosaicT & { id: string };
+/** Matches raster-tile-traversal.ts's world-copy pass count. */
+const MAX_MAPS = 3;
+
export class MosaicTileset2D extends Tileset2D {
/** Closure returning the parent layer's current sources array. Re-evaluated
* on each `getTileIndices` call so updates to the layer's `sources` prop
@@ -109,12 +112,51 @@ export class MosaicTileset2D extends Tileset2D {
}
const viewportBounds = viewport.getBounds();
- const indices = index.search(...viewportBounds);
+ const matchedIndices = new Set(index.search(...viewportBounds));
+
+ // World-copy passes: whenever repeat mode is active (`subViewports` is
+ // non-null), a source's bbox may be indexed in a different world-copy
+ // frame than `viewport.getBounds()` reports — e.g. an antimeridian-
+ // crossing source's bbox is normalized/unwrapped onto a continuous frame
+ // (see `normalizeSourceBbox`), so it's only found by a query shifted
+ // ±360°, ±720°… Gating on `subViewports.length > 1` (the viewport's own
+ // bounds currently straddling a ±180° multiple) wrongly skips this once
+ // zoomed in tight to one side alone — the viewport no longer straddles a
+ // seam, but the source's own indexed position can still be a world copy
+ // away from the query. Matches raster-tile-traversal.ts's gate. Walk each
+ // direction until an offset comes back empty — see dev-docs/world-copies.md.
+ if (viewport.subViewports != null) {
+ for (let worldOffset = -1; worldOffset >= -MAX_MAPS; worldOffset--) {
+ if (
+ !searchAtOffset(index, viewportBounds, worldOffset, matchedIndices)
+ ) {
+ break;
+ }
+ }
+ for (let worldOffset = 1; worldOffset <= MAX_MAPS; worldOffset++) {
+ if (
+ !searchAtOffset(index, viewportBounds, worldOffset, matchedIndices)
+ ) {
+ break;
+ }
+ }
+ }
+ // `index` and `sources` are read from two separate closures backed by
+ // the same MosaicLayer instance; a matched index can transiently point
+ // past the end of `sources` for one frame if `getTileIndices` runs
+ // between the layer's own `sources` prop updating and its Flatbush index
+ // finishing its rebuild for the new array (e.g. right after an async
+ // `sources` load populates the layer for the first time). Skip rather
+ // than crash — the next tick's rebuilt index resolves it.
const sources = this.getSources();
- const selectedSources = indices.map((sourceIndex) => {
- const source = sources[sourceIndex]!;
- return {
+ const selectedSources: ResolvedSource[] = [];
+ for (const sourceIndex of matchedIndices) {
+ const source = sources[sourceIndex];
+ if (source === undefined) {
+ continue;
+ }
+ selectedSources.push({
// Remove once https://github.com/visgl/deck.gl/pull/10299
// is merged and released
x: 0,
@@ -122,8 +164,8 @@ export class MosaicTileset2D extends Tileset2D {
z: 0,
...source,
id: source.id ?? String(sourceIndex),
- };
- });
+ });
+ }
const { maxRequests } = this.opts;
if (selectedSources.length <= maxRequests) {
@@ -140,3 +182,29 @@ export class MosaicTileset2D extends Tileset2D {
);
}
}
+
+/**
+ * Query `index` with `bounds` shifted by `worldOffset * 360°` of longitude,
+ * adding any matched source indices into `matched`. Returns `true` if this
+ * offset matched anything, signaling the caller to keep walking further from
+ * the primary world; `false` stops that direction's walk (the offset has
+ * moved past the visible range).
+ */
+function searchAtOffset(
+ index: Flatbush,
+ bounds: [number, number, number, number],
+ worldOffset: number,
+ matched: Set,
+): boolean {
+ const shift = worldOffset * 360;
+ const found = index.search(
+ bounds[0] + shift,
+ bounds[1],
+ bounds[2] + shift,
+ bounds[3],
+ );
+ for (const i of found) {
+ matched.add(i);
+ }
+ return found.length > 0;
+}
diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts
index 8472054e..a8c72e77 100644
--- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts
+++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts
@@ -47,6 +47,7 @@ import {
metersPerUnit,
parseWkt,
} from "@developmentseed/proj";
+import { snapToCopy } from "@developmentseed/raster-reproject";
import type { Device, Texture, TextureFormat } from "@luma.gl/core";
import proj4 from "proj4";
import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js";
@@ -861,36 +862,46 @@ export class MultiCOGLayer extends RasterTileLayer<
}
// --- Primary tile outline and label ---
- const primaryCrsCorners = primaryLevel.projectedTileCorners(x, y);
- const { path: primaryPath, center: primaryCenter } = cornersToWgs84Path(
- primaryCrsCorners,
- forwardTo4326,
- );
+ const primaryBoxes = [
+ {
+ ...cornersToWgs84Path(
+ primaryLevel.projectedTileCorners(x, y),
+ forwardTo4326,
+ ),
+ labelSuffix: "",
+ },
+ ];
const primaryColor = DEBUG_COLORS[0]!;
layers.push(
new PathLayer({
id: `${tileId}-debug-primary-outline`,
- data: [primaryPath],
+ data: primaryBoxes.map((box) => box.path),
getPath: (d) => d,
getColor: primaryColor.outline,
getWidth: 2,
widthUnits: "pixels",
pickable: false,
+ // Split the outline of a tile crossing ±180° at the antimeridian.
+ wrapLongitude: true,
}),
);
// Build primary label text
- let primaryText = `x=${x} y=${y} z=${z}`;
+ let primaryDetail = "";
if (debugLevel >= 2) {
- primaryText += ` ${data.width}x${data.height}`;
+ primaryDetail += ` ${data.width}x${data.height}`;
}
if (debugLevel >= 3) {
- primaryText += ` ${primaryLevel.metersPerPixel.toFixed(1)}m/px`;
+ primaryDetail += ` ${primaryLevel.metersPerPixel.toFixed(1)}m/px`;
}
- // Count total label lines for vertical stacking
+ // Secondary labels are anchored to the primary box's center and stacked
+ // below it.
+ const primaryCenter = primaryBoxes[0]!.center;
+
+ // Count total label lines stacked at `primaryCenter` for vertical spacing.
const secondaryNames = data.debugInfo
? [...data.debugInfo.bands.keys()]
: [];
@@ -901,15 +912,14 @@ export class MultiCOGLayer extends RasterTileLayer<
layers.push(
new TextLayer({
id: `${tileId}-debug-primary-label`,
- data: [
- {
- position: primaryCenter,
- text: primaryText,
- },
- ],
+ data: primaryBoxes.map((box) => ({
+ position: box.center,
+ text: `x=${x} y=${y} z=${z}${box.labelSuffix}${primaryDetail}`,
+ })),
getColor: primaryColor.text,
getSize: 14,
- getPixelOffset: [0, -topOffset],
+ getPixelOffset: (_d: unknown, { index }: { index: number }) =>
+ index === 0 ? [0, -topOffset] : [0, 0],
sizeUnits: "pixels",
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
@@ -943,6 +953,7 @@ export class MultiCOGLayer extends RasterTileLayer<
getWidth: 2,
widthUnits: "pixels",
pickable: false,
+ wrapLongitude: true,
}),
);
}
@@ -1057,8 +1068,9 @@ function cornersToWgs84Path(
);
return {
path: [topLeft, topRight, bottomRight, bottomLeft, topLeft],
+ // Unwrap so a tile crossing ±180° gets its label at the seam.
center: [
- (topLeft[0] + bottomRight[0]) / 2,
+ (topLeft[0] + snapToCopy(bottomRight[0], topLeft[0], 360)) / 2,
(topLeft[1] + bottomRight[1]) / 2,
],
};
diff --git a/packages/deck.gl-geotiff/tests/antimeridian-fixtures.test.ts b/packages/deck.gl-geotiff/tests/antimeridian-fixtures.test.ts
new file mode 100644
index 00000000..22951ac9
--- /dev/null
+++ b/packages/deck.gl-geotiff/tests/antimeridian-fixtures.test.ts
@@ -0,0 +1,160 @@
+import { WebMercatorViewport } from "@deck.gl/core";
+import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers";
+import { RasterTileset2D } from "@developmentseed/deck.gl-raster";
+import { makeClampedForwardTo3857 } from "@developmentseed/proj";
+import { RasterReprojector } from "@developmentseed/raster-reproject";
+import proj4 from "proj4";
+import { describe, expect, it } from "vitest";
+import { geoTiffToDescriptor } from "../src/geotiff-tileset.js";
+import { loadGeoTIFF } from "./helpers.js";
+
+// Every tile of every level of each antimeridian fixture, through the real
+// proj4 projections COGLayer builds, must mesh continuously across ±180°.
+const FIXTURES = {
+ antimeridian: "EPSG:4326", // vertical seam
+ antimeridian_rotated: "EPSG:4326", // slanted seam
+ antimeridian_utm60: "EPSG:32660", // curved seam
+ antimeridian_360: "EPSG:4326", // world-spanning
+};
+
+const TILE_SIZE = 512;
+
+const SELECT_LAT: Record = {
+ antimeridian: 3,
+ antimeridian_rotated: 10,
+ antimeridian_utm60: 52,
+ antimeridian_360: 0,
+};
+const SELECT_VIEWS: Record = {
+ antimeridian: [
+ [180, 3],
+ [175, 6],
+ [-175, 6],
+ ],
+ antimeridian_rotated: [
+ [180, 3],
+ [175, 6],
+ [-175, 6],
+ ],
+ antimeridian_utm60: [
+ [180, 3],
+ [177, 7],
+ [-177, 7],
+ ],
+ antimeridian_360: [
+ [0, 1],
+ [180, 3],
+ [-179, 6],
+ [179, 6],
+ ],
+};
+
+describe("antimeridian fixtures mesh across ±180°", () => {
+ for (const [name, crs] of Object.entries(FIXTURES)) {
+ it(name, async () => {
+ const geotiff = await loadGeoTIFF(name, "rasterio");
+ const c4326 = proj4(crs, "EPSG:4326");
+ const c3857 = proj4(crs, "EPSG:3857");
+ const projectTo4326 = (x: number, y: number) =>
+ c4326.forward<[number, number]>([x, y], false);
+ const descriptor = geoTiffToDescriptor(geotiff, {
+ projectTo4326,
+ projectFrom4326: (x, y) =>
+ c4326.inverse<[number, number]>([x, y], false),
+ projectTo3857: makeClampedForwardTo3857(
+ (x, y) => c3857.forward<[number, number]>([x, y], false),
+ projectTo4326,
+ ),
+ projectFrom3857: (x, y) =>
+ c3857.inverse<[number, number]>([x, y], false),
+ mpu: 1,
+ });
+ const tileset = new RasterTileset2D(
+ { getTileData: () => new Promise(() => {}) } as Tileset2DProps,
+ descriptor,
+ );
+
+ // Selected when centered on the seam, and on either side alone.
+ for (const [longitude, zoom] of SELECT_VIEWS[name]!) {
+ const viewport = new WebMercatorViewport({
+ longitude,
+ latitude: SELECT_LAT[name]!,
+ zoom,
+ width: 800,
+ height: 600,
+ repeat: true,
+ });
+ const selected = tileset.getTileIndices({ viewport, zRange: null });
+ expect(
+ selected.length,
+ `${name} @ lng ${longitude} z${zoom}`,
+ ).toBeGreaterThan(0);
+ }
+
+ // Same level order as geoTiffToDescriptor: coarsest first.
+ const images = [...[...geotiff.overviews].reverse(), geotiff];
+ descriptor.levels.forEach((level, z) => {
+ const image = images[z]!;
+ for (let y = 0; y < level.matrixHeight; y++) {
+ for (let x = 0; x < level.matrixWidth; x++) {
+ const md = tileset.getTileMetadata({ x, y, z });
+ // COGLayer fetches edge tiles clipped to the image.
+ const width = Math.min(
+ md.tileWidth,
+ image.width - x * md.tileWidth,
+ );
+ const height = Math.min(
+ md.tileHeight,
+ image.height - y * md.tileHeight,
+ );
+ const r = new RasterReprojector(
+ {
+ forwardTransform: md.forwardTransform,
+ inverseTransform: md.inverseTransform,
+ forwardReproject: md._projectPosition,
+ inverseReproject: md._unprojectPosition,
+ },
+ width + 1,
+ height + 1,
+ {
+ initialTriangulation: md._webMercatorInitialTriangulation(
+ width,
+ height,
+ ),
+ wrapX: TILE_SIZE,
+ },
+ );
+ r.run(0.125, { maxIterations: 3000 });
+ const where = `${name} z=${z} x=${x} y=${y}`;
+ expect(r.getMaxError(), where).toBeLessThanOrEqual(0.125);
+ const { triangles, exactOutputPositions: p } = r;
+ // A wrapped triangle would span ~a whole world. The 360° image
+ // legitimately has triangles that do, so it may reach exactly one.
+ // The mesh covers the whole tile: its clipped x-extent matches
+ // the tile's own longitude span (no gap near the seam).
+ const lngAt = (px: number, py: number) =>
+ projectTo4326(...md.forwardTransform(px, py))[0];
+ const xs = p.filter((_, i) => i % 2 === 0);
+ const meshSpanDeg =
+ ((Math.max(...xs) - Math.min(...xs)) / TILE_SIZE) * 360;
+ const west = lngAt(0, 0);
+ const east = lngAt(width, 0);
+ const tileSpanDeg = east > west ? east - west : east + 360 - west;
+ expect(meshSpanDeg, where).toBeGreaterThanOrEqual(
+ Math.min(tileSpanDeg, 360) - 1e-6,
+ );
+ const maxSpan =
+ name === "antimeridian_360" ? TILE_SIZE + 1e-6 : TILE_SIZE / 2;
+ for (let i = 0; i < triangles.length; i += 3) {
+ const xs = [0, 1, 2].map((j) => p[2 * triangles[i + j]!]!);
+ expect(
+ Math.max(...xs) - Math.min(...xs),
+ where,
+ ).toBeLessThanOrEqual(maxSpan);
+ }
+ }
+ }
+ });
+ });
+ }
+});
diff --git a/packages/deck.gl-geotiff/tests/mosaic-layer.test.ts b/packages/deck.gl-geotiff/tests/mosaic-layer.test.ts
index 920a4119..cd340ac1 100644
--- a/packages/deck.gl-geotiff/tests/mosaic-layer.test.ts
+++ b/packages/deck.gl-geotiff/tests/mosaic-layer.test.ts
@@ -9,7 +9,7 @@ const A: Item = { id: "A", bbox: [0, 0, 10, 10] };
const B: Item = { id: "B", bbox: [20, 0, 30, 10] };
const C: Item = { id: "C", bbox: [40, 0, 50, 10] };
-type LayerState = { index: Flatbush | null };
+type LayerState = { index: Flatbush | null; sources: Item[] };
type LayerInternals = {
initializeState: () => void;
@@ -34,7 +34,7 @@ function makeBareLayer(sources: Item[]) {
sources,
renderSource: () => null,
});
- const state: LayerState = { index: null };
+ const state: LayerState = { index: null, sources: [] };
Object.assign(layer as object, { state });
Object.assign(layer as object, {
setState: (updates: Partial) => Object.assign(state, updates),
@@ -134,3 +134,25 @@ describe("MosaicLayer spatial index lifecycle", () => {
expect(state.index?.numItems).toBe(2);
});
});
+
+describe("MosaicLayer bbox normalization", () => {
+ it("unwraps a GeoJSON-flipped bbox before indexing it", () => {
+ // RFC 7946 §5.2: crosses ±180° → xmin > xmax.
+ const crossing: Item = { id: "crossing", bbox: [179.97, -5, -179.17, 5] };
+ const { state, internals } = makeBareLayer([crossing]);
+ internals.initializeState();
+
+ expect(state.sources).toEqual([
+ { id: "crossing", bbox: [179.97, -5, 180.83, 5] },
+ ]);
+ // Findable via a continuous-frame query straddling the seam, which a
+ // still-flipped bbox (xmin > xmax) could never match in Flatbush.
+ expect(state.index?.search(179, -5, 181, 5)).toEqual([0]);
+ });
+
+ it("leaves a non-crossing bbox unchanged", () => {
+ const { state, internals } = makeBareLayer([A]);
+ internals.initializeState();
+ expect(state.sources).toEqual([A]);
+ });
+});
diff --git a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts
index ed4089b1..aa4fdd17 100644
--- a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts
+++ b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts
@@ -8,12 +8,15 @@ import { MosaicTileset2D } from "../src/mosaic-layer/mosaic-tileset-2d.js";
function makeViewport(
bounds: [number, number, number, number],
zoom = 5,
+ subViewportCount = 0,
): Viewport {
return {
equals: () => false,
resolution: undefined,
zoom,
getBounds: () => bounds,
+ subViewports:
+ subViewportCount > 1 ? new Array(subViewportCount).fill({}) : undefined,
} as unknown as Viewport;
}
@@ -125,6 +128,55 @@ describe("MosaicTileset2D center-out ordering", () => {
});
});
+describe("MosaicTileset2D world-copy passes", () => {
+ it("finds a source only reachable via a shifted world copy when subViewports > 1", () => {
+ const source: MosaicSource = { bbox: [-175, -5, -170, 5] };
+ const tileset = makeTileset([source]);
+ // Base bounds sit in the +360 world copy; the source's bbox only exists
+ // in the native (-175..-170) copy.
+ const result = tileset.getTileIndices({
+ viewport: makeViewport([185, -5, 195, 5], 5, 2),
+ });
+ expect(result).toHaveLength(1);
+ });
+
+ it("does not search other world copies when subViewports <= 1", () => {
+ const source: MosaicSource = { bbox: [-175, -5, -170, 5] };
+ const tileset = makeTileset([source]);
+ const result = tileset.getTileIndices({
+ viewport: makeViewport([185, -5, 195, 5], 5, 1),
+ });
+ expect(result).toHaveLength(0);
+ });
+
+ it("dedupes a source matched by more than one world-copy pass", () => {
+ const source: MosaicSource = { bbox: [-350, -5, 15, 5] };
+ const tileset = makeTileset([source]);
+ const result = tileset.getTileIndices({
+ viewport: makeViewport([-1, -5, 11, 5], 5, 2),
+ });
+ expect(result).toHaveLength(1);
+ });
+});
+
+describe("MosaicTileset2D stale index resilience", () => {
+ it("skips a matched index that points past the end of a since-shrunk sources array, instead of throwing", () => {
+ // Simulates the index being built for a larger `sources` array than
+ // `getSources()` currently returns — e.g. one frame after an async
+ // `sources` load where the layer's index rebuild hasn't caught up yet.
+ const index = buildIndex([A, B, C])!;
+ const tileset = new MosaicTileset2D- (
+ () => [A], // getSources() now returns fewer items than the index knows about
+ () => index,
+ { getTileData: () => new Promise(() => {}) } as unknown as Tileset2DProps,
+ );
+ const result = tileset.getTileIndices({
+ viewport: makeViewport([-1, -1, 51, 11]),
+ });
+ expect(result.map((s) => s.name)).toEqual(["A"]);
+ });
+});
+
describe("MosaicTileset2D tile ids", () => {
it("defaults each source's tile-cache id to its array position", () => {
const tileset = makeTileset([A, B, C]);
diff --git a/packages/deck.gl-raster/src/clip-mesh-to-world.ts b/packages/deck.gl-raster/src/clip-mesh-to-world.ts
new file mode 100644
index 00000000..26965e39
--- /dev/null
+++ b/packages/deck.gl-raster/src/clip-mesh-to-world.ts
@@ -0,0 +1,106 @@
+/** A 2D triangle mesh: flat `[x, y, ...]` positions, `[u, v, ...]` uvs. */
+export interface Mesh2D {
+ positions: number[];
+ uvs: number[];
+ triangles: number[];
+}
+
+/** One clipped-polygon vertex: position + uv. */
+type Vertex = [x: number, y: number, u: number, v: number];
+
+/**
+ * Clip a mesh whose x was unwrapped across a periodic seam (see
+ * `RasterReprojector`'s `wrapX`) at every multiple of `period`, and shift each
+ * part back into `[0, period]`.
+ *
+ * Each world boundary is the mesh's real discontinuity, so this is exact for
+ * any seam shape. New vertices on a boundary get uv by linear interpolation
+ * along the cut edge — the same piecewise-linear texture mapping the unclipped
+ * triangle had. A mesh that already lies in one copy comes back unchanged
+ * apart from that copy's shift.
+ */
+export function clipMeshToWorld(mesh: Mesh2D, period: number): Mesh2D {
+ const { positions, uvs, triangles } = mesh;
+ const out: Mesh2D = { positions: [], uvs: [], triangles: [] };
+ // Unclipped vertices are reused per (vertex, copy) to keep the mesh indexed.
+ const shared = new Map();
+
+ const pushVertex = ([x, y, u, v]: Vertex, shift: number): number => {
+ out.positions.push(x - shift, y);
+ out.uvs.push(u, v);
+ return out.positions.length / 2 - 1;
+ };
+ const sharedVertex = (i: number, k: number): number => {
+ // Fold the copy into the key; meshes never span anywhere near 2^20 copies.
+ const key = i * 2048 + (k + 1024);
+ let index = shared.get(key);
+ if (index === undefined) {
+ index = pushVertex(vertexAt(i), k * period);
+ shared.set(key, index);
+ }
+ return index;
+ };
+ const vertexAt = (i: number): Vertex => [
+ positions[2 * i]!,
+ positions[2 * i + 1]!,
+ uvs[2 * i]!,
+ uvs[2 * i + 1]!,
+ ];
+
+ for (let t = 0; t < triangles.length; t += 3) {
+ const idx = [triangles[t]!, triangles[t + 1]!, triangles[t + 2]!];
+ const xs = idx.map((i) => positions[2 * i]!);
+ const kMin = Math.floor(Math.min(...xs) / period);
+ // A triangle that only touches its copy's upper boundary isn't crossing.
+ const kMax = Math.max(kMin, Math.ceil(Math.max(...xs) / period) - 1);
+
+ if (kMin === kMax) {
+ for (const i of idx) {
+ out.triangles.push(sharedVertex(i, kMin));
+ }
+ continue;
+ }
+
+ for (let k = kMin; k <= kMax; k++) {
+ let polygon = idx.map(vertexAt);
+ polygon = clipX(polygon, k * period, 1);
+ polygon = clipX(polygon, (k + 1) * period, -1);
+ if (polygon.length < 3) {
+ continue;
+ }
+ const ring = polygon.map((p) => pushVertex(p, k * period));
+ // Clipping a triangle by half-planes leaves a convex polygon: fan it.
+ for (let j = 1; j < ring.length - 1; j++) {
+ out.triangles.push(ring[0]!, ring[j]!, ring[j + 1]!);
+ }
+ }
+ }
+
+ return out;
+}
+
+/**
+ * Sutherland–Hodgman against one vertical line: keep `x >= edge` when
+ * `side = 1`, `x <= edge` when `side = -1`.
+ */
+function clipX(polygon: Vertex[], edge: number, side: 1 | -1): Vertex[] {
+ const inside = (p: Vertex) => side * (p[0] - edge) >= 0;
+ const result: Vertex[] = [];
+ for (let i = 0; i < polygon.length; i++) {
+ const a = polygon[i]!;
+ const b = polygon[(i + 1) % polygon.length]!;
+ if (inside(a)) {
+ result.push(a);
+ }
+ if (inside(a) !== inside(b)) {
+ const s = (edge - a[0]) / (b[0] - a[0]);
+ result.push([
+ edge,
+ a[1] + s * (b[1] - a[1]),
+ a[2] + s * (b[2] - a[2]),
+ a[3] + s * (b[3] - a[3]),
+ ]);
+ }
+ }
+ return result;
+}
diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts
index f103aba7..83f6fb61 100644
--- a/packages/deck.gl-raster/src/layer-utils.ts
+++ b/packages/deck.gl-raster/src/layer-utils.ts
@@ -1,42 +1,54 @@
import type { _Tile2DHeader as Tile2DHeader } from "@deck.gl/geo-layers";
import { PathLayer, TextLayer } from "@deck.gl/layers";
import type { ReprojectionFns } from "@developmentseed/raster-reproject";
+import { snapToCopy } from "@developmentseed/raster-reproject";
import type { RasterTileMetadata } from "./raster-tileset/index.js";
+// Samples per outline edge; keeps each step well under 180° so a tile
+// spanning the whole world (or crossing ±180°) unwraps correctly.
+const EDGE_SAMPLES = 16;
+
export function renderDebugTileOutline(
id: string,
tile: Tile2DHeader & RasterTileMetadata,
forwardTo4326: ReprojectionFns["forwardReproject"],
+ /** Pixel size of the tile's data; edge tiles are smaller than nominal. */
+ size?: { width: number; height: number } | null,
) {
- const { projectedCorners } = tile;
-
- // Create a closed path in WGS84 projection around the tile bounds
+ // Outline the tile's *data*, not its nominal footprint: an edge tile (e.g. a
+ // 72×4 image in a 512×512 tile) would otherwise extend hundreds of degrees
+ // past the data.
//
- // The tile has a `bbox` field which is already the bounding box in WGS84,
- // but that uses `transformBounds` and densifies edges. So the corners of
- // the bounding boxes don't line up with each other.
- //
- // In this case in the debug mode, it looks better if we ignore the actual
- // non-linearities of the edges and just draw a box connecting the
- // reprojected corners. In any case, the _image itself_ will be densified
- // on the edges as a feature of the mesh generation.
- const { topLeft, topRight, bottomRight, bottomLeft } = projectedCorners;
- const topLeftWgs84 = forwardTo4326(topLeft[0], topLeft[1]);
- const topRightWgs84 = forwardTo4326(topRight[0], topRight[1]);
- const bottomRightWgs84 = forwardTo4326(bottomRight[0], bottomRight[1]);
- const bottomLeftWgs84 = forwardTo4326(bottomLeft[0], bottomLeft[1]);
-
- const path = [
- topLeftWgs84,
- topRightWgs84,
- bottomRightWgs84,
- bottomLeftWgs84,
- topLeftWgs84,
+ // Each edge is sampled in pixel space and unwrapped point-to-point, so a
+ // 360°-wide tile draws as a full band instead of collapsing to zero width.
+ // `wrapLongitude` then splits the path at ±180°.
+ const w = size?.width ?? tile.tileWidth;
+ const h = size?.height ?? tile.tileHeight;
+ const ring: [number, number][] = [
+ [0, 0],
+ [w, 0],
+ [w, h],
+ [0, h],
+ [0, 0],
];
+ const path: number[][] = [];
+ for (let e = 0; e < 4; e++) {
+ const [x0, y0] = ring[e]!;
+ const [x1, y1] = ring[e + 1]!;
+ for (let i = e === 0 ? 0 : 1; i <= EDGE_SAMPLES; i++) {
+ const t = i / EDGE_SAMPLES;
+ const [lon, lat] = forwardTo4326(
+ ...tile.forwardTransform(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t),
+ );
+ const prev = path[path.length - 1];
+ path.push([prev ? snapToCopy(lon, prev[0]!, 360) : lon, lat]);
+ }
+ }
+ // Label at the middle of the unwrapped tile.
const center = [
- (topLeftWgs84[0] + bottomRightWgs84[0]) / 2,
- (topLeftWgs84[1] + bottomRightWgs84[1]) / 2,
+ (path[0]![0]! + path[2 * EDGE_SAMPLES]![0]!) / 2,
+ (path[0]![1]! + path[2 * EDGE_SAMPLES]![1]!) / 2,
];
const labelLayer = new TextLayer({
id: `${id}-label`,
@@ -62,6 +74,8 @@ export function renderDebugTileOutline(
getWidth: 2,
widthUnits: "pixels",
pickable: false,
+ // Split the outline of a tile crossing ±180° at the antimeridian.
+ wrapLongitude: true,
});
return [outlineLayer, labelLayer];
diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts
index 6534c7c1..6d2fd6f0 100644
--- a/packages/deck.gl-raster/src/raster-layer.ts
+++ b/packages/deck.gl-raster/src/raster-layer.ts
@@ -12,6 +12,8 @@ import type {
ReprojectionFns,
} from "@developmentseed/raster-reproject";
import { RasterReprojector } from "@developmentseed/raster-reproject";
+import type { Mesh2D } from "./clip-mesh-to-world.js";
+import { clipMeshToWorld } from "./clip-mesh-to-world.js";
import { splitFloat64Array } from "./fp64.js";
import { buildUniformGridMesh } from "./globe-grid-mesh.js";
import type { RasterModule } from "./gpu-modules/types.js";
@@ -45,7 +47,7 @@ const DEBUG_COLORS: [number, number, number][] = [
];
type DebugData = {
- reprojector: RasterReprojector;
+ mesh: Mesh2D;
length: number;
};
@@ -87,6 +89,16 @@ export interface RasterLayerProps extends CompositeLayerProps {
*/
initialTriangulation?: InitialTriangulation;
+ /**
+ * Period of the output x axis (one world width in common space) when the
+ * output wraps, e.g. Web Mercator. When set, the reprojector unwraps the
+ * mesh across the antimeridian and the finished mesh is clipped at each
+ * world boundary — so a tile crossing ±180° along any seam shape renders
+ * as one continuous mesh. See `RasterReprojector`'s `wrapX` option and
+ * {@link clipMeshToWorld}.
+ */
+ wrapX?: number;
+
/**
* The image to display. Accepts any luma.gl `TextureSource` (e.g. a URL,
* `HTMLImageElement`, `ImageData`, etc.). deck.gl manages the texture
@@ -145,6 +157,8 @@ export class RasterLayer extends CompositeLayer {
declare state: {
reprojector?: RasterReprojector;
+ /** The rendered (clipped) mesh in 2D, for the debug wireframe. */
+ debugMesh?: Mesh2D;
/**
* Mesh in the exact shape SimpleMeshLayer expects.
*
@@ -199,7 +213,8 @@ export class RasterLayer extends CompositeLayer {
props.height !== oldProps.height ||
reprojectionFnsChanged ||
props.maxError !== oldProps.maxError ||
- props.initialTriangulation !== oldProps.initialTriangulation;
+ props.initialTriangulation !== oldProps.initialTriangulation ||
+ props.wrapX !== oldProps.wrapX;
if (needsMeshUpdate) {
this._generateMesh();
@@ -212,6 +227,7 @@ export class RasterLayer extends CompositeLayer {
height,
reprojectionFns,
initialTriangulation,
+ wrapX,
maxError = DEFAULT_MAX_ERROR,
} = this.props;
@@ -228,6 +244,7 @@ export class RasterLayer extends CompositeLayer {
buildUniformGridMesh(reprojectionFns, width + 1, height + 1);
this.setState({
reprojector: undefined,
+ debugMesh: undefined,
mesh: {
indices: { value: indices, size: 1 },
attributes: {
@@ -251,14 +268,15 @@ export class RasterLayer extends CompositeLayer {
reprojectionFns,
width + 1,
height + 1,
- { initialTriangulation },
+ { initialTriangulation, wrapX },
);
reprojector.run(maxError);
- const { indices, positions64High, positions64Low, texCoords } =
- reprojectorToMesh(reprojector);
+ const { indices, positions64High, positions64Low, texCoords, mesh2D } =
+ reprojectorToMesh(reprojector, wrapX);
this.setState({
reprojector,
+ debugMesh: mesh2D,
mesh: {
indices: { value: indices, size: 1 },
attributes: {
@@ -271,10 +289,10 @@ export class RasterLayer extends CompositeLayer {
}
renderDebugLayer(): Layer | null {
- const { reprojector } = this.state;
+ const { debugMesh } = this.state;
const { debugOpacity } = this.props;
- if (!reprojector) {
+ if (!debugMesh) {
return null;
}
@@ -283,7 +301,7 @@ export class RasterLayer extends CompositeLayer {
id: "polygon",
// https://deck.gl/docs/developer-guide/performance#supply-binary-blobs-to-the-data-prop
// This `data` gets passed into `getPolygon` with the row index.
- data: { reprojector, length: reprojector.triangles.length / 3 },
+ data: { mesh: debugMesh, length: debugMesh.triangles.length / 3 },
getPolygon: (
_: any,
{
@@ -294,8 +312,7 @@ export class RasterLayer extends CompositeLayer {
data: DebugData;
},
) => {
- const triangles = data.reprojector.triangles;
- const positions = reprojector.exactOutputPositions;
+ const { triangles, positions } = data.mesh;
const a = triangles[index * 3]!;
const b = triangles[index * 3 + 1]!;
@@ -369,19 +386,31 @@ export class RasterLayer extends CompositeLayer {
}
}
-function reprojectorToMesh(reprojector: RasterReprojector): {
+function reprojectorToMesh(
+ reprojector: RasterReprojector,
+ wrapX?: number,
+): {
indices: Uint32Array;
positions64High: Float32Array;
positions64Low: Float32Array;
texCoords: Float32Array;
+ mesh2D: Mesh2D;
} {
- const numVertices = reprojector.uvs.length / 2;
- const texCoords = new Float32Array(reprojector.uvs);
+ const mesh = {
+ positions: reprojector.exactOutputPositions,
+ uvs: reprojector.uvs,
+ triangles: reprojector.triangles,
+ };
+ const mesh2D = wrapX === undefined ? mesh : clipMeshToWorld(mesh, wrapX);
+ const { positions: xy, uvs, triangles } = mesh2D;
+
+ const numVertices = uvs.length / 2;
+ const texCoords = new Float32Array(uvs);
const positions = new Float64Array(numVertices * 3);
for (let i = 0; i < numVertices; i++) {
- positions[i * 3] = reprojector.exactOutputPositions[i * 2]!;
- positions[i * 3 + 1] = reprojector.exactOutputPositions[i * 2 + 1]!;
+ positions[i * 3] = xy[i * 2]!;
+ positions[i * 3 + 1] = xy[i * 2 + 1]!;
// z (flat on the ground)
positions[i * 3 + 2] = 0;
}
@@ -391,12 +420,13 @@ function reprojectorToMesh(reprojector: RasterReprojector): {
const [positions64Low, positions64High] = splitFloat64Array(positions);
// TODO: Consider using 16-bit indices if the mesh is small enough
- const indices = new Uint32Array(reprojector.triangles);
+ const indices = new Uint32Array(triangles);
return {
indices,
positions64High,
positions64Low,
texCoords,
+ mesh2D,
};
}
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 b6579a3e..81fab5d9 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
@@ -8,13 +8,13 @@ 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";
import { RasterLayer } from "../raster-layer.js";
import type { RasterTilesetDescriptor } from "../raster-tileset/index.js";
import { RasterTileset2D } from "../raster-tileset/index.js";
+import { TILE_SIZE } from "../raster-tileset/raster-tile-traversal.js";
import type { RasterTileMetadata } from "../raster-tileset/raster-tileset-2d.js";
/**
@@ -238,7 +238,7 @@ export class RasterTileLayer<
*/
protected _renderDebug(
tile: Tile2DHeader,
- _data: DataT | null,
+ data: DataT | null,
): Layer[] {
const descriptor = this._tilesetDescriptor();
if (!descriptor) {
@@ -251,6 +251,7 @@ export class RasterTileLayer<
`${this.id}-${tile.id}-bounds`,
tile as Tile2DHeader & RasterTileMetadata,
descriptor.projectTo4326,
+ data,
);
}
@@ -391,23 +392,14 @@ export class RasterTileLayer<
}
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,
- });
+ const rasterLayers = this._renderNormalTile({
+ baseId: props.id,
+ tile,
+ data: props.data,
+ tileResult,
+ descriptor,
+ isGlobe,
+ });
return [...rasterLayers, ...debugLayers];
}
@@ -482,56 +474,10 @@ export class RasterTileLayer<
// is needed).
initialTriangulation: isGlobe
? undefined
- : tile._webMercatorInitialTriangulation,
- }),
- ),
- ];
- }
-
- /**
- * 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]`). 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;
- tile: Tile2DHeader & RasterTileMetadata;
- data: NonNullable;
- tileResult: RenderTileResult;
- uCut: number;
- }): Layer[] {
- 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). `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,
- };
- return [
- new RasterLayer(
- this.getSubLayerProps({
- ...baseProps,
- id: `${baseId}-raster-west`,
- reprojectionFns: tile._westReprojection!,
- initialTriangulation: triangulateRectangle(0, 0, uCut, 1),
- }),
- ),
- new RasterLayer(
- this.getSubLayerProps({
- ...baseProps,
- id: `${baseId}-raster-east`,
- reprojectionFns: tile._eastReprojection!,
- initialTriangulation: triangulateRectangle(uCut, 0, 1, 1),
+ : tile._webMercatorInitialTriangulation(data.width, data.height),
+ // Web Mercator wraps at one world width: unwrap + clip the mesh so
+ // a tile crossing ±180° (along any seam shape) stays continuous.
+ wrapX: isGlobe ? undefined : TILE_SIZE,
}),
),
];
diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts
deleted file mode 100644
index ccd6c348..00000000
--- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * 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;
- 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;
-}
-
-/**
- * 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. 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 {
- // 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;
- }
- // 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 (seam - westLng) / (eastLng - westLng);
-}
-
-/**
- * Detect whether a tile crosses the antimeridian and, if so, locate the cut.
- *
- * 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) 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,
-): AntimeridianCut | undefined {
- const { topLeft, topRight, bottomLeft, bottomRight } = cornerLngs;
-
- const topUCut = edgeUCut(topLeft, topRight);
- const bottomUCut = edgeUCut(bottomLeft, bottomRight);
- if (topUCut === undefined || bottomUCut === undefined) {
- return undefined;
- }
- // 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;
- }
- return { uCut: (topUCut + bottomUCut) / 2 };
-}
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 da286d8e..1358ff79 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
@@ -20,6 +20,7 @@
import type { Viewport } from "@deck.gl/core";
import { _GlobeViewport as GlobeViewport } from "@deck.gl/core";
import { transformBounds } from "@developmentseed/proj";
+import { unwrapAlong } from "@developmentseed/raster-reproject";
import { Vector3 } from "@math.gl/core";
import {
CullingVolume,
@@ -124,7 +125,7 @@ const EPSG_3857_CIRCUMFERENCE = 2 * Math.PI * WGS84_ELLIPSOID_A;
const EPSG_3857_HALF_CIRCUMFERENCE = EPSG_3857_CIRCUMFERENCE / 2;
// Maximum latitude representable in Web Mercator (EPSG:3857), in degrees.
-const MAX_WEB_MERCATOR_LAT = 85.05112877980659;
+export const MAX_WEB_MERCATOR_LAT = 85.05112877980659;
/**
* Raster Tile Node - represents a single tile in a tileset pyramid.
@@ -426,10 +427,19 @@ export class RasterTileNode {
const [minX, minY, maxX, maxY] = bounds;
const [tileMinX, tileMinY, tileMaxX, tileMaxY] = commonSpaceBounds;
- const inside =
- tileMinX < maxX && tileMaxX > minX && tileMinY < maxY && tileMaxY > minY;
-
- return inside;
+ if (!(tileMinY < maxY && tileMaxY > minY)) {
+ return false;
+ }
+ // X is periodic: a box unwrapped across ±180° may sit a world copy away
+ // from the dataset bounds (each is unwrapped from its own anchor), so
+ // test overlap in every copy either could be in.
+ for (let k = -1; k <= 1; k++) {
+ const shift = k * TILE_SIZE;
+ if (tileMinX + shift < maxX && tileMaxX + shift > minX) {
+ return true;
+ }
+ }
+ return false;
}
/**
@@ -528,16 +538,36 @@ export class RasterTileNode {
const tileCorners = this.level.projectedTileCorners(this.x, this.y);
- const refPointsEPSG3857 = sampleReferencePointsInEPSG3857(
- REF_POINTS_9,
- tileCorners,
- this.descriptor.projectTo3857,
- this.descriptor.projectTo4326,
- );
-
- const commonSpacePositions = refPointsEPSG3857.map((xy) =>
- rescaleEPSG3857ToCommonSpace(xy),
- );
+ // proj4's forward-to-3857 wraps longitudes into (-180°, 180°], so a tile
+ // crossing ±180° has reference points on both edges of common space.
+ // Unwrap each against the tile center, walking the uv path between them
+ // (see `unwrapAlong`), so the box is one tight volume around the seam —
+ // matching the unwrapped mesh `RasterLayer` renders.
+ const [center, ...rest] = REF_POINTS_9;
+ const commonSpaceAt = (relX: number, relY: number): [number, number] =>
+ rescaleEPSG3857ToCommonSpace(
+ sampleReferencePointsInEPSG3857(
+ [[relX, relY]],
+ tileCorners,
+ this.descriptor.projectTo3857,
+ this.descriptor.projectTo4326,
+ )[0]!,
+ );
+ const centerPosition = commonSpaceAt(center![0], center![1]);
+ const commonSpacePositions: [number, number][] = [
+ centerPosition,
+ ...rest.map(([relX, relY]): [number, number] => {
+ const lerp = (t: number) =>
+ commonSpaceAt(
+ center![0] + t * (relX - center![0]),
+ center![1] + t * (relY - center![1]),
+ );
+ return [
+ unwrapAlong((t) => lerp(t)[0], centerPosition[0], TILE_SIZE),
+ lerp(1)[1],
+ ];
+ }),
+ ];
const refPointPositions: [number, number, number][] = [];
for (const p of commonSpacePositions) {
@@ -953,6 +983,17 @@ export function getTileIndices(
const [minLng, minLat, maxLng, maxLat] = wgs84Bounds;
const bottomLeft = lngLatToWorld([minLng, minLat]);
const topRight = lngLatToWorld([maxLng, maxLat]);
+
+ // `wgs84Bounds` is a plain min/max over densified samples, which for a
+ // dataset crossing ±180° (projected source, normalized longitudes) spans
+ // nearly the whole world. Unwrap the dataset's corners along its perimeter
+ // instead and keep whichever x-range is narrower.
+ const [unwrappedWest, unwrappedEast] = unwrappedLngRange(descriptor);
+ if (unwrappedEast - unwrappedWest < maxLng - minLng) {
+ bottomLeft[0] = lngLatToWorld([unwrappedWest, minLat])[0];
+ topRight[0] = lngLatToWorld([unwrappedEast, maxLat])[0];
+ }
+
const bounds: Bounds = [
bottomLeft[0],
bottomLeft[1],
@@ -983,13 +1024,15 @@ export function getTileIndices(
root.update(traversalParams);
}
- // World-copy passes: when the viewport spans multiple world copies (e.g.
- // WebMercatorViewport with repeat: true panned across the antimeridian),
+ // World-copy passes: whenever repeat mode is active (`subViewports` is
+ // non-null — not just when the viewport's own bounds currently straddle a
+ // ±180° multiple, which says nothing about whether some tile's position
+ // is on a different world-copy frame than the viewport's canonical one),
// re-run the traversal with the tile bounding volumes shifted by ±1, ±2…
// world copies along common-space X. A tile is selected if any pass selects
- // it. See dev-docs/world-copies.md.
- const subViewportCount = viewport.subViewports?.length ?? 0;
- if (subViewportCount > 1) {
+ // it. The early-break below keeps this cheap when nothing is near a seam.
+ // See dev-docs/world-copies.md.
+ if (viewport.subViewports != null) {
for (let offset = -1; offset >= -MAX_MAPS; offset--) {
if (!runOffsetPass(roots, traversalParams, offset)) {
break;
@@ -1172,3 +1215,42 @@ function bilerpPoint(
p00[1] * w00 + p10[1] * w10 + p01[1] * w01 + p11[1] * w11,
];
}
+
+/**
+ * West/east longitude of a dataset's corners, unwrapped continuously along
+ * its perimeter (top-left → top-right → bottom-right → bottom-left).
+ *
+ * ponytail: corners only; a curved edge whose longitude extreme is mid-edge
+ * gets a slightly tight box. Densify if a dataset is ever culled by it.
+ */
+function unwrappedLngRange(
+ descriptor: RasterTilesetDescriptor,
+): [number, number] {
+ const [minX, minY, maxX, maxY] = descriptor.projectedBounds;
+ const path: [number, number][] = [
+ [minX, maxY],
+ [maxX, maxY],
+ [maxX, minY],
+ [minX, minY],
+ ];
+ const lngAt = ([x, y]: [number, number]) => descriptor.projectTo4326(x, y)[0];
+ const lngs = [lngAt(path[0]!)];
+ for (let i = 1; i < path.length; i++) {
+ const [ax, ay] = path[i - 1]!;
+ const [bx, by] = path[i]!;
+ lngs.push(
+ unwrapAlong(
+ (t) => lngAt([ax + t * (bx - ax), ay + t * (by - ay)]),
+ lngs[i - 1]!,
+ 360,
+ ),
+ );
+ }
+ const [topLeft, topRight, bottomRight, bottomLeft] = lngs as [
+ number,
+ number,
+ number,
+ number,
+ ];
+ return [Math.min(topLeft, bottomLeft), Math.max(topRight, bottomRight)];
+}
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 26378dec..0281a7cb 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,18 +14,15 @@ import type {
} from "@deck.gl/geo-layers";
import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers";
import { transformBounds } from "@developmentseed/proj";
-import type {
- InitialTriangulation,
- ReprojectionFns,
-} from "@developmentseed/raster-reproject";
+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 { lngLatToWorld, worldToLngLat } from "@math.gl/web-mercator";
import { BoundingVolumeCache } from "./bounding-volume-cache.js";
import {
getTileIndices,
+ MAX_WEB_MERCATOR_LAT,
rescaleCommonSpaceToEPSG3857,
- rescaleEPSG3857ToCommonSpace,
+ TILE_SIZE,
} from "./raster-tile-traversal.js";
import { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js";
import type { RasterTilesetDescriptor } from "./tileset-interface.js";
@@ -112,34 +109,16 @@ export type RasterTileMetadata = {
* Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed.
* Consumed only by the Web Mercator render path; the globe path renders the
* full mesh. See {@link createInitialWebMercatorTriangulation}.
+ *
+ * A function of the fetched data's size, since the reprojector's uv spans
+ * the *data* — an edge tile fetched clipped to the image covers less than
+ * the nominal tile. Memoized, so the result is reference-stable across
+ * renders for a given size.
*/
- _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;
-
- /**
- * 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;
+ _webMercatorInitialTriangulation: (
+ dataWidth: number,
+ dataHeight: number,
+ ) => InitialTriangulation | undefined;
};
/**
@@ -211,11 +190,32 @@ export class RasterTileset2D extends Tileset2D {
// the tile to keep `RasterLayer`'s reprojection-equality check stable
// across renders (deck.gl recreates the layer instance every render, so
// per-render-derived closures would regenerate the mesh every frame).
- this.projectPosition = (x, y) =>
- rescaleEPSG3857ToCommonSpace(descriptor.projectTo3857(x, y));
+ //
+ // Forward goes via the source's own lng/lat, *not* normalized: proj4
+ // passes a 4326 source's native longitude through (e.g. −204°), so its
+ // mesh lands continuous in common space with no wrap at all. A projected
+ // source's longitude comes back normalized to (−180°, 180°], so a tile
+ // crossing the antimeridian wraps — `RasterLayer`'s `wrapX` unwraps that.
+ //
+ // Inverse must then accept x from any world copy. Inside the canonical
+ // copy it is the stock 3857 inverse; outside, x maps linearly to an
+ // un-normalized longitude, which `projectFrom4326` passes straight
+ // through for a 4326 source and proj4's `adjlon` folds for a projected
+ // one.
+ this.projectPosition = (x, y) => {
+ const [lng, lat] = descriptor.projectTo4326(x, y);
+ return lngLatToWorld([
+ lng,
+ Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat)),
+ ]);
+ };
this.unprojectPosition = (cx, cy) => {
- const [mx, my] = rescaleCommonSpaceToEPSG3857([cx, cy]);
- return descriptor.projectFrom3857(mx, my);
+ if (cx >= 0 && cx <= TILE_SIZE) {
+ const [mx, my] = rescaleCommonSpaceToEPSG3857([cx, cy]);
+ return descriptor.projectFrom3857(mx, my);
+ }
+ const [lng, lat] = worldToLngLat([cx, cy]);
+ return descriptor.projectFrom4326(lng, lat);
};
const rawBounds = transformBounds(
@@ -226,13 +226,12 @@ export class RasterTileset2D extends Tileset2D {
// downstream tile traversal calls `lngLatToWorld` on these bounds which
// asserts against that range. Global data at ±90° (e.g. reanalysis grids)
// would otherwise crash tile selection. Clamp here; any polar rows beyond
- // ±MAX_LAT are unreachable on a Mercator map anyway.
- const MAX_LAT = 85.0511287798066;
+ // ±MAX_WEB_MERCATOR_LAT are unreachable on a Mercator map anyway.
this.wgs84Bounds = [
rawBounds[0],
- Math.max(rawBounds[1], -MAX_LAT),
+ Math.max(rawBounds[1], -MAX_WEB_MERCATOR_LAT),
rawBounds[2],
- Math.min(rawBounds[3], MAX_LAT),
+ Math.min(rawBounds[3], MAX_WEB_MERCATOR_LAT),
];
}
@@ -416,51 +415,26 @@ export class RasterTileset2D extends Tileset2D {
// Clamp the reprojection mesh to the valid Web Mercator latitude band for
// tiles that extend past ±85.051° (e.g. a global EPSG:4326 image reaching
- // ±90°). Computed once here so the reference is stable across renders.
- const cornerLat = (corner: [number, number]) =>
- this.descriptor.projectTo4326(corner[0], corner[1])[1];
- const _webMercatorInitialTriangulation =
- createInitialWebMercatorTriangulation({
- topLeft: cornerLat(topLeft),
- topRight: cornerLat(topRight),
- bottomLeft: cornerLat(bottomLeft),
- 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 tileWestLng = cornerLng(topLeft);
- const tileEastLng = cornerLng(topRight);
- const _antimeridianCut = antimeridianCut({
- 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,
- );
- }
+ // ±90°). Latitudes come from the data's own corners, not the nominal
+ // tile's: for a clipped edge tile (e.g. a 36×2 overview in a 64×64 tile)
+ // the nominal corners can be hundreds of degrees past the data.
+ const latAt = (px: number, py: number) =>
+ this.descriptor.projectTo4326(...forwardTransform(px, py))[1];
+ let memoKey: string | undefined;
+ let memoSeed: InitialTriangulation | undefined;
+ const _webMercatorInitialTriangulation = (w: number, h: number) => {
+ const key = `${w}x${h}`;
+ if (key !== memoKey) {
+ memoKey = key;
+ memoSeed = createInitialWebMercatorTriangulation({
+ topLeft: latAt(0, 0),
+ topRight: latAt(w, 0),
+ bottomLeft: latAt(0, h),
+ bottomRight: latAt(w, h),
+ });
+ }
+ return memoSeed;
+ };
return {
bbox: {
@@ -483,41 +457,6 @@ export class RasterTileset2D extends Tileset2D {
_projectPosition: this.projectPosition,
_unprojectPosition: this.unprojectPosition,
_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/clip-mesh-to-world.test.ts b/packages/deck.gl-raster/tests/clip-mesh-to-world.test.ts
new file mode 100644
index 00000000..5c3e4e85
--- /dev/null
+++ b/packages/deck.gl-raster/tests/clip-mesh-to-world.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import type { Mesh2D } from "../src/clip-mesh-to-world.js";
+import { clipMeshToWorld } from "../src/clip-mesh-to-world.js";
+
+const P = 512;
+
+function area(positions: number[], triangles: number[]): number {
+ let sum = 0;
+ for (let t = 0; t < triangles.length; t += 3) {
+ const [a, b, c] = [0, 1, 2].map((j) => 2 * triangles[t + j]!);
+ sum +=
+ ((positions[b!]! - positions[a!]!) *
+ (positions[c! + 1]! - positions[a! + 1]!) -
+ (positions[c!]! - positions[a!]!) *
+ (positions[b! + 1]! - positions[a! + 1]!)) /
+ 2;
+ }
+ return sum;
+}
+
+describe("clipMeshToWorld", () => {
+ // Unit square in uv, positioned across x = 512 (500..530).
+ const mesh: Mesh2D = {
+ positions: [500, 0, 530, 0, 500, 10, 530, 10],
+ uvs: [0, 0, 1, 0, 0, 1, 1, 1],
+ triangles: [3, 0, 2, 0, 3, 1],
+ };
+ const out = clipMeshToWorld(mesh, P);
+
+ it("preserves signed area and keeps every position in [0, period]", () => {
+ expect(area(out.positions, out.triangles)).toBeCloseTo(
+ area(mesh.positions, mesh.triangles),
+ 9,
+ );
+ for (let i = 0; i < out.positions.length; i += 2) {
+ expect(out.positions[i]!).toBeGreaterThanOrEqual(0);
+ expect(out.positions[i]!).toBeLessThanOrEqual(P);
+ }
+ });
+
+ it("interpolates uv linearly at the cut (x = 512 ↔ u = 12/30)", () => {
+ for (let i = 0; i < out.positions.length; i += 2) {
+ const x = out.positions[i]!;
+ const unshifted = x < 100 ? x + P : x;
+ expect(out.uvs[i]!).toBeCloseTo((unshifted - 500) / 30, 9);
+ }
+ });
+
+ it("only shifts a mesh that lies in one copy", () => {
+ const shifted: Mesh2D = {
+ ...mesh,
+ positions: mesh.positions.map((v, i) => (i % 2 === 0 ? v - 530 : v)),
+ };
+ const result = clipMeshToWorld(shifted, P);
+ expect(result.triangles.length).toBe(mesh.triangles.length);
+ const xs = result.positions.filter((_, i) => i % 2 === 0);
+ expect(Math.min(...xs)).toBeCloseTo(P - 30, 9);
+ expect(Math.max(...xs)).toBeCloseTo(P, 9);
+ });
+});
diff --git a/packages/deck.gl-raster/tests/layer-utils.test.ts b/packages/deck.gl-raster/tests/layer-utils.test.ts
new file mode 100644
index 00000000..eea358b8
--- /dev/null
+++ b/packages/deck.gl-raster/tests/layer-utils.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { renderDebugTileOutline } from "../src/layer-utils.js";
+
+describe("renderDebugTileOutline", () => {
+ it("outlines a 72×4 global band in a 512×512 tile as a 360° equatorial box", () => {
+ // EPSG:4326, 5° pixels, origin (−180, 10): the antimeridian_360 fixture.
+ const tile = {
+ index: { x: 0, y: 0, z: 0 },
+ tileWidth: 512,
+ tileHeight: 512,
+ forwardTransform: (x: number, y: number) => [-180 + 5 * x, 10 - 5 * y],
+ } as unknown as Parameters[1];
+ // Real projections hand back lon normalized to [−180, 180).
+ const to4326 = (x: number, y: number): [number, number] => [
+ ((((x + 180) % 360) + 360) % 360) - 180,
+ y,
+ ];
+
+ const [outline] = renderDebugTileOutline("t", tile, to4326, {
+ width: 72,
+ height: 4,
+ });
+ const path = (outline!.props.data as number[][][])[0]!;
+ const lons = path.map((p) => p[0]!);
+ const lats = path.map((p) => p[1]!);
+ expect(Math.max(...lons) - Math.min(...lons)).toBeCloseTo(360);
+ expect(Math.min(...lats)).toBe(-10);
+ expect(Math.max(...lats)).toBe(10);
+ });
+});
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
deleted file mode 100644
index 4c040260..00000000
--- a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { antimeridianCut } from "../../src/raster-tileset/antimeridian-cut.js";
-
-// 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, no seam inside)", () => {
- expect(
- antimeridianCut({
- topLeft: 10,
- topRight: 20,
- bottomLeft: 10,
- bottomRight: 20,
- }),
- ).toBeUndefined();
- });
-
- 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: -204,
- topRight: -162,
- bottomLeft: -204,
- bottomRight: -162,
- });
- expect(cut).toBeDefined();
- // 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 edge crosses at u = 24/42 ≈ 0.571; bottom edge at u = 10/40 = 0.25.
- expect(
- antimeridianCut({
- topLeft: -204,
- topRight: -162,
- 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();
- });
-});
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 e6be8f9f..700579b2 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
@@ -1,149 +1,241 @@
import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers";
-import { compose, scale, translation } from "@developmentseed/affine";
+import type { Affine } from "@developmentseed/affine";
+import { compose, rotation, scale, translation } from "@developmentseed/affine";
+import { RasterReprojector } from "@developmentseed/raster-reproject";
import { describe, expect, it } from "vitest";
+import { clipMeshToWorld } from "../../src/clip-mesh-to-world.js";
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,
+import type { RasterTilesetDescriptor } from "../../src/raster-tileset/tileset-interface.js";
+
+// End-to-end: tile metadata → RasterReprojector (wrapX) → clipMeshToWorld,
+// for each antimeridian seam shape in
+// fixtures/geotiff-test-data/rasterio_generated/fixtures/.
+
+const TILE_SIZE = 512;
+const HALF = 20037508.342789244;
+const RAD = Math.PI / 180;
+
+/** proj4 normalizes longitude to (−180°, 180°]. */
+const wrapLng = (lng: number) => lng - 360 * Math.ceil((lng - 180) / 360);
+const toMerc = (lng: number, lat: number): [number, number] => [
+ (lng / 180) * HALF,
+ Math.log(Math.tan(Math.PI / 4 + (lat * RAD) / 2)) * (HALF / Math.PI),
+];
+const fromMerc = (x: number, y: number): [number, number] => [
+ (x / HALF) * 180,
+ (2 * Math.atan(Math.exp((y / HALF) * Math.PI)) - Math.PI / 2) / RAD,
+];
+
+/**
+ * An EPSG:4326 source: proj4 4326→4326 passes native longitudes through
+ * un-normalized (e.g. −204°), but 4326→3857 wraps them.
+ */
+const GEOGRAPHIC = {
+ projectTo4326: (x: number, y: number): [number, number] => [x, y],
+ projectFrom4326: (x: number, y: number): [number, number] => [x, y],
+ projectTo3857: (x: number, y: number) => toMerc(wrapLng(x), y),
+ projectFrom3857: fromMerc,
};
-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 });
+/**
+ * A projected source with *curved* meridians (sinusoidal, central meridian
+ * 177° like UTM 60N). Its lng/lat output is normalized; its inverse folds
+ * longitude against the central meridian (proj4's `adjlon`).
+ */
+const K = 1e5;
+const CENTRAL = 177;
+const sinusoidalTo4326 = (x: number, y: number): [number, number] => {
+ const lat = y / K;
+ return [wrapLng(CENTRAL + x / (K * Math.cos(lat * RAD))), lat];
+};
+const sinusoidalFrom4326 = (lng: number, lat: number): [number, number] => [
+ wrapLng(lng - CENTRAL) * K * Math.cos(lat * RAD),
+ lat * K,
+];
+const SINUSOIDAL = {
+ projectTo4326: sinusoidalTo4326,
+ projectFrom4326: sinusoidalFrom4326,
+ projectTo3857: (x: number, y: number) => toMerc(...sinusoidalTo4326(x, y)),
+ projectFrom3857: (x: number, y: number) =>
+ sinusoidalFrom4326(...fromMerc(x, y)),
+};
- expect(metadata._antimeridianCut).toBeUndefined();
- expect(metadata._westReprojection).toBeUndefined();
- expect(metadata._eastReprojection).toBeUndefined();
+function tileset(
+ projections: typeof GEOGRAPHIC,
+ affine: Affine,
+ arrayWidth: number,
+ arrayHeight: number,
+ tileSize: number,
+): { tileset: RasterTileset2D; descriptor: RasterTilesetDescriptor } {
+ const level = new AffineTilesetLevel({
+ affine,
+ arrayWidth,
+ arrayHeight,
+ tileWidth: tileSize,
+ tileHeight: tileSize,
+ mpu: 1,
});
+ const descriptor = new AffineTileset({ levels: [level], ...projections });
+ const props = { getTileData: () => new Promise(() => {}) } as Tileset2DProps;
+ return { tileset: new RasterTileset2D(props, descriptor), descriptor };
+}
- 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);
+/**
+ * Mesh tile (0, 0, 0). `dataSize` is the fetched data's size — COGLayer
+ * fetches edge tiles clipped to the image, so the mesh spans only the data.
+ */
+function meshTile(t: RasterTileset2D, dataSize?: [number, number]) {
+ const md = t.getTileMetadata({ x: 0, y: 0, z: 0 });
+ const [width, height] = dataSize ?? [md.tileWidth, md.tileHeight];
+ const reprojector = new RasterReprojector(
+ {
+ forwardTransform: md.forwardTransform,
+ inverseTransform: md.inverseTransform,
+ forwardReproject: md._projectPosition,
+ inverseReproject: md._unprojectPosition,
+ },
+ width + 1,
+ height + 1,
+ {
+ initialTriangulation: md._webMercatorInitialTriangulation(width, height),
+ wrapX: 512,
+ },
+ );
+ reprojector.run(0.125, { maxIterations: 2000 });
+ const clipped = clipMeshToWorld(
+ {
+ positions: reprojector.exactOutputPositions,
+ uvs: reprojector.uvs,
+ triangles: reprojector.triangles,
+ },
+ TILE_SIZE,
+ );
+ return { md, reprojector, clipped };
+}
- // 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);
- });
+const SHAPES = {
+ // antimeridian.tif: lng −204..−162, cut at column 24.
+ vertical: () =>
+ tileset(
+ GEOGRAPHIC,
+ compose(translation(-204, 24), scale(1, -1)),
+ 42,
+ 42,
+ 42,
+ ),
+ // antimeridian_rotated.tif: rotated geotransform → straight slanted seam.
+ slanted: () =>
+ tileset(
+ GEOGRAPHIC,
+ compose(translation(-204, 24), compose(rotation(20), scale(1, -1))),
+ 42,
+ 42,
+ 42,
+ ),
+ // antimeridian_utm60.tif-like: ~174°E..173°W, 48°N..56°N, curved seam.
+ curved: () =>
+ tileset(
+ SINUSOIDAL,
+ compose(translation(-2e5, 56e5), scale(2e4, -2e4)),
+ 42,
+ 42,
+ 42,
+ ),
+};
- 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,
+describe("antimeridian crossing: unwrap + clip", () => {
+ for (const [name, make] of Object.entries(SHAPES)) {
+ describe(name, () => {
+ const { descriptor, tileset: t } = make();
+ const { md, reprojector, clipped } = meshTile(t);
+
+ it("converges", () => {
+ expect(reprojector.getMaxError()).toBeLessThanOrEqual(0.125);
+ });
+
+ it("is continuous: no triangle jumps across the seam", () => {
+ const { triangles, exactOutputPositions: p } = reprojector;
+ for (let i = 0; i < triangles.length; i += 3) {
+ const xs = [0, 1, 2].map((j) => p[2 * triangles[i + j]!]!);
+ // A wrapped triangle would span ~a whole world (512).
+ expect(Math.max(...xs) - Math.min(...xs)).toBeLessThan(TILE_SIZE / 2);
+ }
+ });
+
+ it("actually straddles a world boundary, and clips into [0, 512]", () => {
+ const xs = reprojector.exactOutputPositions.filter(
+ (_, i) => i % 2 === 0,
+ );
+ const copies = new Set(xs.map((x) => Math.floor(x / TILE_SIZE)));
+ expect(copies.size).toBe(2);
+ for (let i = 0; i < clipped.positions.length; i += 2) {
+ expect(clipped.positions[i]!).toBeGreaterThanOrEqual(-1e-9);
+ expect(clipped.positions[i]!).toBeLessThanOrEqual(TILE_SIZE + 1e-9);
+ }
+ });
+
+ it("places every vertex at its true longitude (mod one world)", () => {
+ const { uvs, exactOutputPositions: p } = reprojector;
+ for (let i = 0; i < uvs.length / 2; i++) {
+ const [sx, sy] = md.forwardTransform(
+ uvs[2 * i]! * md.tileWidth,
+ uvs[2 * i + 1]! * md.tileHeight,
+ );
+ const [lng] = descriptor.projectTo4326(sx, sy);
+ const expected = ((lng + 180) / 360) * TILE_SIZE;
+ const diff = (p[2 * i]! - expected) / TILE_SIZE;
+ expect(Math.abs(diff - Math.round(diff))).toBeLessThan(1e-9);
+ }
+ });
+
+ it("round-trips forward/inverse from either world copy", () => {
+ for (const [px, py] of [
+ [1, 1],
+ [21, 21],
+ [41, 41],
+ [41, 1],
+ [1, 41],
+ ] as const) {
+ const [sx, sy] = md.forwardTransform(px, py);
+ const [cx, cy] = md._projectPosition(sx, sy);
+ for (const shift of [0, TILE_SIZE, -TILE_SIZE]) {
+ const [rx, ry] = md._unprojectPosition(cx + shift, cy);
+ const [rpx, rpy] = md.inverseTransform(rx, ry);
+ if (shift === 0 || name === "curved") {
+ // A projected source folds any copy back; a 4326 source only
+ // round-trips from the copy forward placed it in — the only
+ // one the mesh ever asks about.
+ expect(rpx).toBeCloseTo(px, 6);
+ expect(rpy).toBeCloseTo(py, 6);
+ }
+ }
+ }
+ });
});
- 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,
+ }
+
+ describe("world-spanning (antimeridian_360.tif overview)", () => {
+ // 36×2 px at 10°/px in a 64 px tile, fetched clipped: −180..180.
+ const { tileset: t } = tileset(
+ GEOGRAPHIC,
+ compose(translation(-180, 10), scale(10, -10)),
+ 36,
+ 2,
+ 64,
+ );
+ const { reprojector, clipped } = meshTile(t, [36, 2]);
+
+ it("converges and covers exactly one world, with no gap", () => {
+ expect(reprojector.getMaxError()).toBeLessThanOrEqual(0.125);
+ const xs = clipped.positions.filter((_, i) => i % 2 === 0);
+ expect(Math.min(...xs)).toBeCloseTo(0, 6);
+ expect(Math.max(...xs)).toBeCloseTo(TILE_SIZE, 6);
+ for (let i = 0; i < clipped.positions.length; i += 2) {
+ expect(clipped.positions[i]!).toBeGreaterThanOrEqual(-1e-9);
+ expect(clipped.positions[i]!).toBeLessThanOrEqual(TILE_SIZE + 1e-9);
+ }
});
- 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);
});
});
diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts
index 6183e20e..f2d9e17c 100644
--- a/packages/raster-reproject/src/delatin.ts
+++ b/packages/raster-reproject/src/delatin.ts
@@ -29,6 +29,49 @@ const SAMPLE_POINTS: [number, number, number][] = [
const DEFAULT_MAX_ERROR = 0.125;
+/**
+ * Max bisection depth when unwrapping a seed vertex along the path from the
+ * previous one (see {@link RasterReprojector}'s `wrapX` option).
+ */
+const MAX_UNWRAP_DEPTH = 12;
+
+/**
+ * Shift `x` by a whole number of `period`s to the copy nearest `ref`.
+ *
+ * Standard phase unwrapping: exact whenever the true value is within
+ * `period / 2` of `ref`.
+ */
+export function snapToCopy(x: number, ref: number, period: number): number {
+ return x + Math.round((ref - x) / period) * period;
+}
+
+/**
+ * Unwrap the periodic value at the end of a path against its already-unwrapped
+ * start: `xAt(t)` gives the raw (any-copy) value at `t ∈ [0, 1]`, `x0` the
+ * unwrapped value at `t = 0`. Always samples the midpoint and bisects until
+ * each step is under a quarter period, so endpoints exactly one period apart
+ * (a 360°-wide tile) resolve correctly.
+ */
+export function unwrapAlong(
+ xAt: (t: number) => number,
+ x0: number,
+ period: number,
+ t0 = 0,
+ t1 = 1,
+ depth = 0,
+): number {
+ const tm = (t0 + t1) / 2;
+ let xm = snapToCopy(xAt(tm), x0, period);
+ if (Math.abs(xm - x0) >= period / 4 && depth < MAX_UNWRAP_DEPTH) {
+ xm = unwrapAlong(xAt, x0, period, t0, tm, depth + 1);
+ }
+ const x1 = snapToCopy(xAt(t1), xm, period);
+ if (Math.abs(x1 - xm) >= period / 4 && depth < MAX_UNWRAP_DEPTH) {
+ return unwrapAlong(xAt, xm, period, tm, t1, depth + 1);
+ }
+ return x1;
+}
+
/**
* A seed triangulation for {@link RasterReprojector}, in delaunator's data
* shape. All UV coordinates must lie in `[0, 1]`. The triangulation must be a
@@ -177,6 +220,12 @@ export class RasterReprojector {
* of just the uv coordinates?
*/
private _candidatesUV: number[];
+ /**
+ * Interpolated output x at each triangle's candidate point — the reference a
+ * new vertex's exact x is snapped to when `wrapX` is set.
+ */
+ private _candidatesOutX: number[];
+ private _wrapX: number | undefined;
private _queueIndices: number[];
private _queue: number[];
@@ -188,7 +237,20 @@ export class RasterReprojector {
reprojectors: ReprojectionFns,
width: number,
height: number = width,
- options: { initialTriangulation?: InitialTriangulation } = {},
+ options: {
+ initialTriangulation?: InitialTriangulation;
+ /**
+ * Period of the output x axis (e.g. one world width), if the output
+ * CRS wraps. When set, every vertex's exact output x is snapped to the
+ * copy nearest a reference, so the mesh stays continuous across the
+ * wrap: a refined vertex snaps to its parent triangle's interpolated
+ * x (off only by reprojection error); a seed vertex is unwrapped along
+ * the uv path from the previous seed vertex. `forwardReproject` may
+ * return any copy; `inverseReproject` must accept any copy. Output x
+ * is then unbounded — callers clip the mesh at multiples of the period.
+ */
+ wrapX?: number;
+ } = {},
) {
this.reprojectors = reprojectors;
this.width = width;
@@ -201,6 +263,8 @@ export class RasterReprojector {
// additional triangle data
this._halfedges = [];
this._candidatesUV = [];
+ this._candidatesOutX = [];
+ this._wrapX = options.wrapX;
this._queueIndices = [];
this._queue = []; // queue of added triangles
@@ -220,7 +284,23 @@ export class RasterReprojector {
*/
private _seed(seed: InitialTriangulation): void {
for (let i = 0; i < seed.uvs.length; i += 2) {
- this._addPoint(seed.uvs[i]!, seed.uvs[i + 1]!);
+ const u = seed.uvs[i]!;
+ const v = seed.uvs[i + 1]!;
+ if (this._wrapX === undefined || i === 0) {
+ this._addPoint(u, v);
+ continue;
+ }
+ // No parent triangle to reference yet: unwrap along the path from the
+ // previous seed vertex instead.
+ const u0 = seed.uvs[i - 2]!;
+ const v0 = seed.uvs[i - 1]!;
+ const x0 = this.exactOutputPositions[i - 2]!;
+ const refX = unwrapAlong(
+ (t) => this._project(u0 + t * (u - u0), v0 + t * (v - v0))[0],
+ x0,
+ this._wrapX,
+ );
+ this._addPoint(u, v, refX);
}
for (let i = 0; i < seed.triangles.length; i++) {
this.triangles[i] = seed.triangles[i]!;
@@ -322,6 +402,7 @@ export class RasterReprojector {
// Note that upstream also initializes the point of max error to [0, 0]
let maxErrorU: number = 0;
let maxErrorV: number = 0;
+ let maxErrorOutX: number = 0;
// Recall that the sample point is in barycentric coordinates
for (const samplePoint of SAMPLE_POINTS) {
@@ -390,6 +471,7 @@ export class RasterReprojector {
maxError = err;
maxErrorU = uvSampleU;
maxErrorV = uvSampleV;
+ maxErrorOutX = outSampleX;
}
}
@@ -411,6 +493,7 @@ export class RasterReprojector {
// update triangle metadata
this._candidatesUV[2 * t] = maxErrorU;
this._candidatesUV[2 * t + 1] = maxErrorV;
+ this._candidatesOutX[t] = maxErrorOutX;
// add triangle to priority queue
this._queuePush(t, maxError);
@@ -438,7 +521,7 @@ export class RasterReprojector {
const pu = this._candidatesUV[2 * t]!;
const pv = this._candidatesUV[2 * t + 1]!;
- const pn = this._addPoint(pu, pv);
+ const pn = this._addPoint(pu, pv, this._candidatesOutX[t]);
if (orient(au, av, bu, bv, pu, pv) === 0) {
this._handleCollinear(pn, e0);
@@ -461,22 +544,29 @@ export class RasterReprojector {
}
}
- // add coordinates for a new vertex
- private _addPoint(u: number, v: number): number {
- const i = this.uvs.length >> 1;
- this.uvs.push(u, v);
-
- // compute and store exact output position via reprojection
+ // exact output position of a uv point via reprojection
+ private _project(u: number, v: number): [number, number] {
const pixelX = u * (this.width - 1);
const pixelY = v * (this.height - 1);
const inputPosition = this.reprojectors.forwardTransform(pixelX, pixelY);
- const exactOutputPosition = this.reprojectors.forwardReproject(
+ return this.reprojectors.forwardReproject(
inputPosition[0],
inputPosition[1],
);
+ }
+
+ // add coordinates for a new vertex
+ private _addPoint(u: number, v: number, refX?: number): number {
+ const i = this.uvs.length >> 1;
+ this.uvs.push(u, v);
+
+ // compute and store exact output position via reprojection
+ const [x, y] = this._project(u, v);
this.exactOutputPositions.push(
- exactOutputPosition[0]!,
- exactOutputPosition[1]!,
+ this._wrapX === undefined || refX === undefined
+ ? x
+ : snapToCopy(x, refX, this._wrapX),
+ y,
);
return i;
diff --git a/packages/raster-reproject/src/index.ts b/packages/raster-reproject/src/index.ts
index 78df1855..13626561 100644
--- a/packages/raster-reproject/src/index.ts
+++ b/packages/raster-reproject/src/index.ts
@@ -1,2 +1,7 @@
export type { InitialTriangulation, ReprojectionFns } from "./delatin.js";
-export { RasterReprojector, triangulateRectangle } from "./delatin.js";
+export {
+ RasterReprojector,
+ snapToCopy,
+ triangulateRectangle,
+ unwrapAlong,
+} from "./delatin.js";
diff --git a/packages/raster-reproject/tests/wrap-x.test.ts b/packages/raster-reproject/tests/wrap-x.test.ts
new file mode 100644
index 00000000..71392681
--- /dev/null
+++ b/packages/raster-reproject/tests/wrap-x.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+import type { ReprojectionFns } from "../src/delatin.js";
+import { RasterReprojector, snapToCopy, unwrapAlong } from "../src/delatin.js";
+
+const WORLD = 512;
+const SIZE = 42;
+
+/** Longitude → common-space x, unwrapped (no modulo). */
+const lngToX = (lng: number) => ((lng + 180) / 360) * WORLD;
+/** What proj4 does on the way to 3857: wrap into one world. */
+const wrap = (x: number) => ((x % WORLD) + WORLD) % WORLD;
+
+/**
+ * Mimics a *projected* source CRS: source space is pixel space (continuous
+ * across the seam), the forward-to-common-space wraps like proj4's, and the
+ * inverse is periodic-safe (proj4's `adjlon` against the central meridian),
+ * so any copy of x maps back to the right source point. `lngAt` sets the
+ * seam shape.
+ */
+function fns(
+ lngAt: (px: number, py: number) => number,
+ { adjlon = true } = {},
+): ReprojectionFns {
+ const identity = (x: number, y: number): [number, number] => [x, y];
+ return {
+ forwardTransform: identity,
+ inverseTransform: identity,
+ forwardReproject: (px, py) => [wrap(lngToX(lngAt(px, py))), py],
+ inverseReproject: (x, py) => {
+ const centralLng = lngAt(SIZE / 2, py);
+ const rawLng = (x / WORLD) * 360 - 180;
+ const lng = adjlon ? snapToCopy(rawLng, centralLng, 360) : rawLng;
+ // lngAt is monotonic in px along a row: invert by bisection.
+ let lo = -SIZE;
+ let hi = 2 * SIZE;
+ for (let i = 0; i < 60; i++) {
+ const mid = (lo + hi) / 2;
+ if (lngAt(mid, py) < lng) {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ return [(lo + hi) / 2, py];
+ },
+ };
+}
+
+const SHAPES: Record number> = {
+ // antimeridian.tif: lng −204..−162, seam at column 24.
+ vertical: (px) => -204 + px,
+ // Rotated geotransform: the seam is a straight slanted line.
+ slanted: (px, py) => -204 + px + 0.4 * py,
+ // Curved meridian (UTM-like): seam position varies nonlinearly by row.
+ curved: (px, py) => -204 + px * (1 + 0.004 * py) + 0.01 * py * py,
+ // A full 360° tile: both corners wrap to the same x.
+ "world-spanning": (px) => -180 + (px * 360) / SIZE,
+};
+
+// A 360° tile's edges are one period apart, so a central-meridian `adjlon`
+// inverse can't tell them apart; model it as a native lng/lat source (inverse
+// passes the unwrapped lng straight through, like proj4 4326→4326).
+const NATIVE_INVERSE = new Set(["world-spanning"]);
+
+describe("RasterReprojector wrapX", () => {
+ for (const [name, lngAt] of Object.entries(SHAPES)) {
+ it(`unwraps a ${name} seam into one continuous mesh`, () => {
+ const projection = fns(lngAt, { adjlon: !NATIVE_INVERSE.has(name) });
+ const r = new RasterReprojector(projection, SIZE + 1, SIZE + 1, {
+ wrapX: WORLD,
+ });
+ r.run(0.125);
+ expect(r.getMaxError()).toBeLessThanOrEqual(0.125);
+
+ // Every vertex sits in the same world copy as vertex 0, i.e. the mesh
+ // is the true unwrapped geometry shifted by one constant k·WORLD.
+ const offsetOf = (i: number) => {
+ const lng = lngAt(r.uvs[2 * i]! * SIZE, r.uvs[2 * i + 1]! * SIZE);
+ return r.exactOutputPositions[2 * i]! - lngToX(lng);
+ };
+ const offset = offsetOf(0);
+ expect(
+ Math.abs(offset / WORLD - Math.round(offset / WORLD)),
+ ).toBeLessThan(1e-9);
+ for (let i = 1; i < r.uvs.length / 2; i++) {
+ expect(offsetOf(i)).toBeCloseTo(offset, 6);
+ }
+ });
+ }
+
+ it("leaves output untouched when wrapX is unset", () => {
+ const r = new RasterReprojector(fns(SHAPES.vertical!), SIZE + 1);
+ for (let i = 0; i < r.uvs.length / 2; i++) {
+ const x = r.exactOutputPositions[2 * i]!;
+ expect(x).toBeGreaterThanOrEqual(0);
+ expect(x).toBeLessThan(WORLD);
+ }
+ });
+});
+
+describe("snapToCopy / unwrapAlong", () => {
+ it("snaps to the copy nearest the reference", () => {
+ expect(snapToCopy(5, 510, WORLD)).toBe(517);
+ expect(snapToCopy(510, 5, WORLD)).toBe(-2);
+ expect(snapToCopy(100, 120, WORLD)).toBe(100);
+ });
+
+ it("resolves endpoints exactly one period apart by walking the path", () => {
+ // x goes 0 → 512 continuously, but the raw end value wraps to 0.
+ const xAt = (t: number) => wrap(t * WORLD);
+ expect(unwrapAlong(xAt, 0, WORLD)).toBeCloseTo(WORLD, 9);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 78e69562..75c34ded 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -206,6 +206,79 @@ importers:
specifier: ^8.0.0
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)
+ examples/antimeridian-mosaic-multi-example:
+ dependencies:
+ '@chakra-ui/react':
+ specifier: ^3.34.0
+ version: 3.35.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@deck.gl/core':
+ specifier: ^9.3.1
+ version: 9.3.2
+ '@deck.gl/geo-layers':
+ specifier: ^9.3.1
+ version: 9.3.2(@deck.gl/core@9.3.2)(@deck.gl/extensions@9.3.2(@deck.gl/core@9.3.2)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))))(@deck.gl/layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))))(@deck.gl/mesh-layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/gltf@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))
+ '@deck.gl/layers':
+ specifier: ^9.3.1
+ version: 9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))
+ '@deck.gl/mapbox':
+ specifier: ^9.3.1
+ version: 9.3.2(@deck.gl/core@9.3.2)(@luma.gl/core@9.3.3)(@math.gl/web-mercator@4.1.0)
+ '@deck.gl/mesh-layers':
+ specifier: ^9.3.1
+ version: 9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/gltf@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))
+ '@developmentseed/deck.gl-geotiff':
+ specifier: workspace:^
+ version: link:../../packages/deck.gl-geotiff
+ '@developmentseed/deck.gl-raster':
+ specifier: workspace:^
+ version: link:../../packages/deck.gl-raster
+ '@developmentseed/geotiff':
+ specifier: workspace:^
+ version: link:../../packages/geotiff
+ '@emotion/react':
+ specifier: ^11.14.0
+ version: 11.14.0(@types/react@19.2.14)(react@19.2.5)
+ '@luma.gl/core':
+ specifier: ^9.3.3
+ version: 9.3.3
+ deck.gl-raster-examples-shared:
+ specifier: workspace:*
+ version: link:../_shared
+ hyparquet:
+ specifier: ^1.31.2
+ version: 1.31.2
+ hyparquet-compressors:
+ specifier: ^1.1.2
+ version: 1.1.2
+ maplibre-gl:
+ specifier: ^5.24.0
+ version: 5.24.0
+ react:
+ specifier: ^19.2.5
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.5
+ version: 19.2.5(react@19.2.5)
+ react-map-gl:
+ specifier: ^8.1.1
+ version: 8.1.1(maplibre-gl@5.24.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))
+ gh-pages:
+ specifier: ^6.3.0
+ version: 6.3.0
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)
+
examples/cog-basic:
dependencies:
'@chakra-ui/react':
@@ -6270,10 +6343,19 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
+ hyparquet-compressors@1.1.2:
+ resolution: {integrity: sha512-0Hx6w0ZpVpNCefKy5xBYLTp6rKeZn/tsUzuliNzfKqSCdLcTQSfwATEoeedS703Y17k1qNS61HwRME5aOeNkPg==}
+
+ hyparquet@1.31.2:
+ resolution: {integrity: sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==}
+
hyperdyperid@1.2.0:
resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
engines: {node: '>=10.18'}
+ hysnappy@1.1.1:
+ resolution: {integrity: sha512-/V9XcN2NtRyWjR4LYMfvnvasVVF8jbT/ej0eofBQjZel91E3D813FQ3mQC6gDSMMTCq/FJh28XHeyqr3I/oBRw==}
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -16237,8 +16319,17 @@ snapshots:
human-signals@2.1.0: {}
+ hyparquet-compressors@1.1.2:
+ dependencies:
+ fzstd: 0.1.1
+ hysnappy: 1.1.1
+
+ hyparquet@1.31.2: {}
+
hyperdyperid@1.2.0: {}
+ hysnappy@1.1.1: {}
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2