Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ projectPosition = (x, y) => rescaleEPSG3857ToCommonSpace(descriptor.projectTo385

Rather than keep the crossing tile as one mesh and fight proj4 to make its coordinates continuous (the **render-as-one** family: #374 output-space shift, #269 reprojector unwrap, #353 global `+over`), **split the tile at the antimeridian into a west piece and an east piece.** Each piece lies wholly on one side of the dateline, so:

- The west piece is monotonic in 3857 (all +x → common-x up to 512); the east piece all −x → common-x from 0. **The discontinuity never exists within a piece.**
- `projectTo3857` stays stock — no unwrap, no proj4 reconfiguration, no `+over`.
- The west piece is monotonic in 3857 (all +x → common-x up to 512); the east piece all −x → common-x from 0. **The discontinuity exists only *at* the shared seam edge, not within a piece's interior** (see "Seam handling" below).
- Almost no projection change: the west piece uses stock `projectTo3857`; the east piece needs only a trivial one-line seam fix — no proj4 reconfiguration, no `+over`, no phase-unwrap.
- The `RasterReprojector` needs zero antimeridian awareness — Delatin converges normally on each piece.
- Mesh vertices stay within `[0, 512]`, so the fp64 high-zoom precision scheme ([`coordinate-systems.md`](../coordinate-systems.md)) is untouched.
- Each piece is a normal tile that the merged world-copy traversal (#518) selects and draws across copies.

The antimeridian becomes *a tile boundary*, which the pipeline already handles, instead of a coordinate-space discontinuity.

### Seam handling

Splitting at the antimeridian is *almost* enough — but not quite. proj4 normalizes ±180° to the **positive** boundary (+max_X / common-x 512). That's correct for the west piece (its right edge *is* +180°), but the east piece's left edge is also the antimeridian and must sit at common-x 0 (−max_X). With stock proj4 the east piece's seam corner lands at 512 while its interior is near 0, so its seed triangle still spans the world and the reprojector diverges — the original #366 failure.

The fix is local to the **wrapped (negative-side) piece** only: in its `forwardReproject`, **if the projected X comes back positive, subtract one world-width** (the +max boundary → −max). Within that piece the *only* vertex proj4 places on the positive side is the ±180° seam, so this single sign test flips exactly the seam corner and leaves the interior untouched. It is **output-sign-based, not an input-value test** — the seam may be lng +180° or −180° depending on the source's longitude convention, and both pieces share the same seam *input*, so only *which piece* you're rendering decides the handling (known at cut time: the wrapped piece is the one whose interior projects to negative X). `inverseReproject` is unchanged: the piece is now a clean negative range the stock inverse maps back correctly. This is **not** the general phase-unwrap that sank #374 — the piece is known a priori to be wholly on the negative side, so the rule is trivial and deterministic.

### Why not render-as-one

Render-as-one is simpler at the render layer (one mesh, one draw, no internal seam) and more CRS-general (it unwraps the output value, indifferent to source pixel geometry). But it re-attempts the exact unwrap that has failed three times: detection needs phase-unwrapping (a full-world continuous tile must not be mistaken for a crossing tile), mesh vertices leave `[0, 512]` (precision risk), and forward+inverse must stay consistent. We choose cut-in-two as the primary mechanism and keep **render-as-one as the documented fallback for curved-meridian CRS** (see Scope), where cut-in-two degrades.
Expand Down
7 changes: 7 additions & 0 deletions examples/cog-basic/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import type { MapRef } from "react-map-gl/maplibre";
import { Map as MaplibreMap } from "react-map-gl/maplibre";

const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [
{
// Dev-only fixture served by examples/cog-basic/vite.config.ts from the
// geotiff-test-data submodule. EPSG:4326, bbox (−204, −18, −162, 24);
// crosses native −180° at u ≈ 24/42 — exercises antimeridian split.
title: "Antimeridian fixture (dev only)",
url: "/__fixtures/antimeridian.tif",
},
{
title: "Sentinel-2 True Color Image (New York, 2026)",
url: "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif",
Expand Down
63 changes: 62 additions & 1 deletion examples/cog-basic/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,69 @@
import { createReadStream, promises as fsp } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import type { Plugin } from "vite";
import { defineConfig } from "vite";

const here = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.resolve(
here,
"../../fixtures/geotiff-test-data/rasterio_generated/fixtures",
);

/**
* Dev-only middleware that serves files under `examples/cog-basic` at
* `/__fixtures/<name>` from the vendored geotiff-test-data submodule.
*
* Honors HTTP Range requests with 206 + Content-Range so the COGLayer
* behaves identically to a production COG bucket — important for any
* fixture used to validate range-read code paths.
*/
function localFixtures(): Plugin {
return {
name: "local-fixtures",
configureServer(server) {
server.middlewares.use("/__fixtures/", (req, res, next) => {
const requested = decodeURIComponent(req.url ?? "").replace(/^\/+/, "");
const filePath = path.resolve(fixturesDir, requested);
if (
filePath !== fixturesDir &&
!filePath.startsWith(fixturesDir + path.sep)
) {
res.statusCode = 403;
res.end();
return;
}
fsp
.stat(filePath)
.then((stat) => {
res.setHeader("Content-Type", "image/tiff");
res.setHeader("Accept-Ranges", "bytes");
const range = req.headers.range;
const m = range ? /^bytes=(\d+)-(\d*)$/.exec(String(range)) : null;
if (m) {
const start = Number.parseInt(m[1]!, 10);
const end = m[2] ? Number.parseInt(m[2], 10) : stat.size - 1;
res.statusCode = 206;
res.setHeader(
"Content-Range",
`bytes ${start}-${end}/${stat.size}`,
);
res.setHeader("Content-Length", String(end - start + 1));
createReadStream(filePath, { start, end }).pipe(res);
return;
}
res.setHeader("Content-Length", String(stat.size));
createReadStream(filePath).pipe(res);
})
.catch(next);
});
},
};
}

export default defineConfig({
plugins: [react()],
plugins: [react(), localFixtures()],
base: "/deck.gl-raster/examples/cog-basic/",
worker: { format: "es" },
server: {
Expand Down
211 changes: 146 additions & 65 deletions packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import type {
CompositeLayerProps,
CoordinateSystem,
DefaultProps,
Layer,
} from "@deck.gl/core";
import type { CompositeLayerProps, DefaultProps, Layer } from "@deck.gl/core";
import { CompositeLayer } from "@deck.gl/core";
import type {
_Tile2DHeader as Tile2DHeader,
Expand All @@ -13,6 +8,7 @@ import type {
} from "@deck.gl/geo-layers";
import { TileLayer } from "@deck.gl/geo-layers";
import type { ReprojectionFns } from "@developmentseed/raster-reproject";
import { triangulateRectangle } from "@developmentseed/raster-reproject";
import type { Device } from "@luma.gl/core";
import { renderDebugTileOutline } from "../layer-utils.js";
import type { RenderTileResult } from "../raster-layer.js";
Expand Down Expand Up @@ -380,79 +376,164 @@ export class RasterTileLayer<
descriptor: RasterTilesetDescriptor,
renderTile: NonNullable<RasterTileLayerProps<DataT>["renderTile"]>,
): Layer[] {
const { maxError, debug, debugOpacity } = this.props;
const { debug } = this.props;
const tile = props.tile as Tile2DHeader<DataT> & RasterTileMetadata;

const debugLayers = debug
? this._renderDebug(tile, props.data ?? null)
: [];

if (!props.data) {
return debugLayers;
}

// Access forwardTransform/inverseTransform from tile metadata so that
// reference equality holds across renders.
const { forwardTransform, inverseTransform } = tile;
const tileResult = renderTile(props.data);
if (!tileResult) {
return debugLayers;
}
const { image, renderPipeline } = tileResult;
const { width, height } = props.data;

const isGlobe = this.context.viewport.resolution !== undefined;
let reprojectionFns: ReprojectionFns;
let coordinateSystem: CoordinateSystem;
if (isGlobe) {
// Globe view
reprojectionFns = {
forwardTransform,
inverseTransform,
forwardReproject: descriptor.projectTo4326,
inverseReproject: descriptor.projectFrom4326,
};
coordinateSystem = "lnglat";
} else {
// Web Mercator: render the mesh directly in deck.gl common space.
//
// The tile's `_projectPosition` maps source CRS → common space, support
// high precision with fp64 emulation.
//
// `_projectPosition`/`_unprojectPosition` must be reference-stable across
// renders to avoid regenerating the mesh and recompiling the shader every
// frame.
const { _projectPosition, _unprojectPosition } = tile;
reprojectionFns = {
forwardTransform,
inverseTransform,
forwardReproject: _projectPosition,
inverseReproject: _unprojectPosition,
};
coordinateSystem = "cartesian";
}
const rasterLayers =
!isGlobe && tile._antimeridianCut
? this._renderAntimeridianTile({
baseId: props.id,
tile,
data: props.data,
tileResult,
uCut: tile._antimeridianCut.uCut,
})
: this._renderNormalTile({
baseId: props.id,
tile,
data: props.data,
tileResult,
descriptor,
isGlobe,
});
return [...rasterLayers, ...debugLayers];
}

const rasterLayer = new RasterLayer(
this.getSubLayerProps({
id: `${props.id}-raster`,
width,
height,
// Passing `image: undefined` explicitly would trip isAsyncPropLoading
// and cause a transient black flash (see issue #376).
...(image !== undefined && { image }),
renderPipeline,
maxError,
reprojectionFns,
// Web Mercator: clamp the mesh to the valid latitude band for tiles
// past ±85.051°. Globe renders the full mesh (it shows the poles).
initialTriangulation: isGlobe
? undefined
: tile._webMercatorInitialTriangulation,
debug,
debugOpacity,
coordinateSystem,
}),
);
return [rasterLayer, ...debugLayers];
/**
* Shared base props for every `RasterLayer` produced from a tile — the
* geometry + render-pipeline pieces that don't depend on globe-vs-mercator
* or whether the tile crosses the antimeridian.
*/
private _baseRasterProps(
data: NonNullable<DataT>,
tileResult: RenderTileResult,
) {
const { maxError, debug, debugOpacity } = this.props;
const { image, renderPipeline } = tileResult;
return {
width: data.width,
height: data.height,
// Passing `image: undefined` explicitly would trip isAsyncPropLoading
// and cause a transient black flash (see issue #376).
...(image !== undefined && { image }),
renderPipeline,
maxError,
debug,
debugOpacity,
};
}

/**
* Build the one `RasterLayer` for a tile that renders as a single mesh —
* either the globe path (lng/lat coordinate system, descriptor-level
* projection) or the Web Mercator non-crossing path (cartesian common
* space, per-tile projection with the optional ±85.051° clamp seed).
*/
private _renderNormalTile(opts: {
baseId: string;
tile: Tile2DHeader<DataT> & RasterTileMetadata;
data: NonNullable<DataT>;
tileResult: RenderTileResult;
descriptor: RasterTilesetDescriptor;
isGlobe: boolean;
}): Layer[] {
const { baseId, tile, data, tileResult, descriptor, isGlobe } = opts;
const { forwardTransform, inverseTransform } = tile;

// Web Mercator: render in deck.gl common space using the tile's
// reference-stable `_projectPosition`/`_unprojectPosition` so the mesh
// does not regenerate (and the shader does not recompile) every frame.
// Globe: render in lng/lat using the descriptor's 4326 projection.
const reprojectionFns: ReprojectionFns = isGlobe
? {
forwardTransform,
inverseTransform,
forwardReproject: descriptor.projectTo4326,
inverseReproject: descriptor.projectFrom4326,
}
: {
forwardTransform,
inverseTransform,
forwardReproject: tile._projectPosition,
inverseReproject: tile._unprojectPosition,
};

return [
new RasterLayer(
this.getSubLayerProps({
...this._baseRasterProps(data, tileResult),
id: `${baseId}-raster`,
reprojectionFns,
coordinateSystem: isGlobe ? "lnglat" : "cartesian",
// Globe shows the poles; Web Mercator clamps tiles past ±85.051°
// to the valid latitude band via the seed (undefined when no clamp
// is needed).
initialTriangulation: isGlobe
? undefined
: tile._webMercatorInitialTriangulation,
}),
),
];
}

/**
* 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<DataT> & RasterTileMetadata;
data: NonNullable<DataT>;
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),
}),
),
];
}
}
Loading
Loading