From c3cd6678c88a47419066563948fdd47865327d8a Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 09:44:56 +1000 Subject: [PATCH 01/21] WIP: first working AM-crossing version Co-Authored-By: Claude Sonnet 5 --- examples/antimeridian-example/README.md | 28 +++++ examples/antimeridian-example/index.html | 22 ++++ examples/antimeridian-example/package.json | 37 +++++++ examples/antimeridian-example/src/App.tsx | 100 ++++++++++++++++++ examples/antimeridian-example/src/main.tsx | 12 +++ .../antimeridian-example/src/render-gray.ts | 90 ++++++++++++++++ .../antimeridian-example/src/vite-env.d.ts | 1 + examples/antimeridian-example/tsconfig.json | 4 + examples/antimeridian-example/vite.config.ts | 11 ++ .../antimeridian-mosaic-multi-example/todo.md | 1 + packages/deck.gl-raster/src/layer-utils.ts | 96 +++++++++++++++++ .../raster-tile-layer/raster-tile-layer.ts | 21 ++-- .../src/raster-tileset/antimeridian-cut.ts | 55 +++++++--- .../src/raster-tileset/raster-tileset-2d.ts | 80 +++++++------- .../raster-tileset/antimeridian-cut.test.ts | 60 ++++++++++- plan.md | 26 +++++ pnpm-lock.yaml | 67 ++++++++++++ 17 files changed, 638 insertions(+), 73 deletions(-) create mode 100644 examples/antimeridian-example/README.md create mode 100644 examples/antimeridian-example/index.html create mode 100644 examples/antimeridian-example/package.json create mode 100644 examples/antimeridian-example/src/App.tsx create mode 100644 examples/antimeridian-example/src/main.tsx create mode 100644 examples/antimeridian-example/src/render-gray.ts create mode 100644 examples/antimeridian-example/src/vite-env.d.ts create mode 100644 examples/antimeridian-example/tsconfig.json create mode 100644 examples/antimeridian-example/vite.config.ts create mode 100644 examples/antimeridian-mosaic-multi-example/todo.md create mode 100644 plan.md diff --git a/examples/antimeridian-example/README.md b/examples/antimeridian-example/README.md new file mode 100644 index 00000000..5d18c2fa --- /dev/null +++ b/examples/antimeridian-example/README.md @@ -0,0 +1,28 @@ +# Antimeridian Crossing Example + +Renders two `COGLayer`s from the same [Digital Earth Pacific](https://digitalearthpacific.org) Landsat GeoMAD catalog (EPSG:3832 / PDC Mercator), side by side, as-is: + +- `dep_ls_geomad_066_022_2025` — crosses the ±180° antimeridian. Its corner longitudes come out GeoJSON-flipped ([RFC 7946 §5.2](https://datatracker.ietf.org/doc/html/rfc7946#section-5.2): west > east, e.g. `179.97 → −179.17`), since `proj4`'s inverse projection normalizes output to `(−180°, 180°]`. +- `dep_ls_geomad_064_020_2025` — same catalog, doesn't cross — for comparison. + +See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575) for the antimeridian-handling design. + +## 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-example + pnpm dev + ``` + +4. Open your browser to http://localhost:3000 diff --git a/examples/antimeridian-example/index.html b/examples/antimeridian-example/index.html new file mode 100644 index 00000000..316c0191 --- /dev/null +++ b/examples/antimeridian-example/index.html @@ -0,0 +1,22 @@ + + + + + + Antimeridian Crossing Example + + + +
+ + + diff --git a/examples/antimeridian-example/package.json b/examples/antimeridian-example/package.json new file mode 100644 index 00000000..c2314c59 --- /dev/null +++ b/examples/antimeridian-example/package.json @@ -0,0 +1,37 @@ +{ + "name": "deck.gl-antimeridian-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-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:*", + "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-example/src/App.tsx b/examples/antimeridian-example/src/App.tsx new file mode 100644 index 00000000..21b54923 --- /dev/null +++ b/examples/antimeridian-example/src/App.tsx @@ -0,0 +1,100 @@ +import { 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 { + ControlPanel, + DebugControls, + DeckGlOverlay, + ExternalLink, +} from "deck.gl-raster-examples-shared"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { useState } from "react"; +import { Map as MaplibreMap } from "react-map-gl/maplibre"; +import { getTileDataGray, renderGrayWhiteToBlack } from "./render-gray.js"; + +// Two items from the same DEP Landsat GeoMAD catalog (EPSG:3832 / PDC +// Mercator), shown together as they are — no reprojection-hiding fitBounds. +// `066_022` crosses the antimeridian (GeoJSON-flipped corner lngs: xmin +// 179.97 → xmax −179.17); `064_020` doesn't, for comparison. +const CROSSING_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"; +const NON_CROSSING_URL = + "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/064/020/2025/dep_ls_geomad_064_020_2025_red.tif"; + +export default function App() { + const [debugState, setDebugState] = useState({ + debug: true, + debugOpacity: 0.25, + }); + + const layers = [CROSSING_URL, NON_CROSSING_URL].map( + (url) => + new COGLayer({ + id: `cog-layer-${url}`, + geotiff: url, + getTileData: getTileDataGray, + renderTile: renderGrayWhiteToBlack, + debug: debugState.debug, + debugOpacity: debugState.debugOpacity, + // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps + // doesn't know about it. + beforeId: "boundary_country_outline", + }), + ); + + // Reference line at the true antimeridian (±180°), for eyeballing whether + // the split pieces line up with it. + const antimeridianLine = new PathLayer({ + id: "antimeridian-line", + data: [ + [ + [180, 80], + [180, -80], + ], + ], + getPath: (d: [number, number][]) => d, + getColor: [255, 0, 200, 255], + getWidth: 2, + widthUnits: "pixels", + }); + + return ( +
+ + + + + + + Two COGs from + the same catalog: one crosses the ±180° antimeridian, one doesn't — + see{" "} + + #575 + + . + + + + Digital Earth Pacific + {" "} + Landsat GeoMAD mosaic. + + + +
+ ); +} diff --git a/examples/antimeridian-example/src/main.tsx b/examples/antimeridian-example/src/main.tsx new file mode 100644 index 00000000..171b36eb --- /dev/null +++ b/examples/antimeridian-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-example/src/render-gray.ts b/examples/antimeridian-example/src/render-gray.ts new file mode 100644 index 00000000..f7893132 --- /dev/null +++ b/examples/antimeridian-example/src/render-gray.ts @@ -0,0 +1,90 @@ +import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; +import { + CreateTexture, + LinearRescale, + WhiteIsZero, +} from "@developmentseed/deck.gl-raster/gpu-modules"; +import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; +import { texture } from "@developmentseed/deck.gl-geotiff"; +import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; +import type { Texture } from "@luma.gl/core"; + +/** Texture payload for a 1-band grayscale tile, plus its own contrast-stretch range. */ +export type GrayTileData = { + texture: Texture; + width: number; + height: number; + /** Normalized [0, 1] — inner-90% value range, per-tile. */ + rescaleMin: number; + rescaleMax: number; +}; + +/** r16unorm normalizes the raw uint16 sample by this before the shader sees it. */ +const UINT16_MAX = 65535; + +/** + * Inner-90% value range (5th/95th percentile), ignoring `nodata` (0 for this + * dataset). A simple per-tile auto-contrast-stretch — good enough for a demo, + * not a substitute for a real histogram-based stretch across the whole COG. + */ +function inner90Range(data: ArrayLike): [number, number] { + const values = Array.from(data).filter((v) => v !== 0); + if (values.length === 0) { + return [0, UINT16_MAX]; + } + values.sort((a, b) => a - b); + const lo = values[Math.floor(values.length * 0.05)]!; + const hi = values[Math.ceil(values.length * 0.95) - 1]!; + return [lo, hi]; +} + +/** + * Tile loader for the 1-band (16-bit unsigned) DEP GeoMAD bands used in this + * example. Both COGs here have no overviews, so `image` is always the base + * `GeoTIFF`; `texture.createTextureProps` infers the right WebGL format from + * its tags (`r16unorm` for this data) rather than assuming 8-bit. + */ +export async function getTileDataGray( + image: GeoTIFF | Overview, + options: GetTileDataOptions, +): Promise { + const { device, x, y, signal } = options; + const tile = await image.fetchTile(x, y, { signal, boundless: false }); + const { array } = tile; + if (array.layout === "band-separate") { + throw new Error("Expected a pixel-interleaved (1-band) COG"); + } + const { width, height, data } = array; + const props = texture.createTextureProps(image as GeoTIFF, data, { + width, + height, + }); + const [lo, hi] = inner90Range(data); + return { + texture: device.createTexture(props), + width, + height, + rescaleMin: lo / UINT16_MAX, + rescaleMax: hi / UINT16_MAX, + }; +} + +/** + * Render pipeline showing raw value as white (low) → black (high) — + * `WhiteIsZero`, matching TIFF `PhotometricInterpretation = 0` regardless of + * this data's own tag (`= 1`, BlackIsZero). `LinearRescale` runs first, using + * this tile's own inner-90% range, so `WhiteIsZero` sees an already-stretched + * [0, 1] value. + */ +export function renderGrayWhiteToBlack(data: GrayTileData): RenderTileResult { + return { + renderPipeline: [ + { module: CreateTexture, props: { textureName: data.texture } }, + { + module: LinearRescale, + props: { rescaleMin: data.rescaleMin, rescaleMax: data.rescaleMax }, + }, + { module: WhiteIsZero }, + ], + }; +} diff --git a/examples/antimeridian-example/src/vite-env.d.ts b/examples/antimeridian-example/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/antimeridian-example/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/antimeridian-example/tsconfig.json b/examples/antimeridian-example/tsconfig.json new file mode 100644 index 00000000..4bd6962d --- /dev/null +++ b/examples/antimeridian-example/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src"] +} diff --git a/examples/antimeridian-example/vite.config.ts b/examples/antimeridian-example/vite.config.ts new file mode 100644 index 00000000..b2a43968 --- /dev/null +++ b/examples/antimeridian-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-example/", + worker: { format: "es" }, + server: { + port: 3000, + }, +}); diff --git a/examples/antimeridian-mosaic-multi-example/todo.md b/examples/antimeridian-mosaic-multi-example/todo.md new file mode 100644 index 00000000..0007db39 --- /dev/null +++ b/examples/antimeridian-mosaic-multi-example/todo.md @@ -0,0 +1 @@ +# TODO: Test a combo of MosaicLayer and MultiCOGLayer which is what we actually need, not just simple COGLayer. diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts index f103aba7..5d2664ad 100644 --- a/packages/deck.gl-raster/src/layer-utils.ts +++ b/packages/deck.gl-raster/src/layer-utils.ts @@ -2,12 +2,108 @@ 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 type { RasterTileMetadata } from "./raster-tileset/index.js"; +import type { ProjectionFunction } from "./raster-tileset/types.js"; + +/** + * Closed 5-point box path for a pixel-space rectangle `[x0, 0] .. [x1, + * height]`, mapped through `forwardTransform` (pixel → source CRS) then + * `forwardReproject` (source CRS → common space). + */ +function pieceBoxPath( + forwardTransform: ProjectionFunction, + forwardReproject: ReprojectionFns["forwardReproject"], + x0: number, + x1: number, + height: number, +): [number, number][] { + const corners: [number, number][] = [ + [x0, 0], + [x1, 0], + [x1, height], + [x0, height], + [x0, 0], + ]; + return corners.map(([px, py]) => { + const [sx, sy] = forwardTransform(px!, py!); + return forwardReproject(sx, sy); + }); +} + +function boxCenter(path: [number, number][]): [number, number] { + const xs = path.map((p) => p[0]); + const ys = path.map((p) => p[1]); + return [ + (Math.min(...xs) + Math.max(...xs)) / 2, + (Math.min(...ys) + Math.max(...ys)) / 2, + ]; +} export function renderDebugTileOutline( id: string, tile: Tile2DHeader & RasterTileMetadata, forwardTo4326: ReprojectionFns["forwardReproject"], ) { + const { _antimeridianCut, _westReprojection, _eastReprojection } = tile; + + // A crossing tile renders as two `RasterLayer`s in different world + // copies (see `RasterTileLayer._renderAntimeridianTile`) — the debug + // outline needs to match, in the same common-space coordinate system, + // rather than drawing one WGS84 box whose corners (e.g. lng 179.97° and + // −179.17°) `PathLayer` would connect the long way around the globe. + if (_antimeridianCut && _westReprojection && _eastReprojection) { + const { uCut } = _antimeridianCut; + const { tileWidth, tileHeight, forwardTransform } = tile; + const cutPx = uCut * tileWidth; + const westPath = pieceBoxPath( + forwardTransform, + _westReprojection.forwardReproject, + 0, + cutPx, + tileHeight, + ); + const eastPath = pieceBoxPath( + forwardTransform, + _eastReprojection.forwardReproject, + cutPx, + tileWidth, + tileHeight, + ); + + const labelLayer = new TextLayer({ + id: `${id}-label`, + data: [ + { + position: boxCenter(westPath), + text: `x=${tile.index.x} y=${tile.index.y} z=${tile.index.z} (west)`, + }, + { + position: boxCenter(eastPath), + text: `x=${tile.index.x} y=${tile.index.y} z=${tile.index.z} (east)`, + }, + ], + getColor: [255, 255, 255, 255], + getSize: 24, + sizeUnits: "pixels", + outlineWidth: 3, + outlineColor: [0, 0, 0, 255], + fontSettings: { sdf: true }, + coordinateSystem: "cartesian", + }); + + const outlineLayer = new PathLayer({ + id, + data: [westPath, eastPath], + getPath: (d) => d, + getColor: [255, 0, 0, 255], // Red + getWidth: 2, + widthUnits: "pixels", + pickable: false, + coordinateSystem: "cartesian", + }); + + return [outlineLayer, labelLayer]; + } + const { projectedCorners } = tile; // Create a closed path in WGS84 projection around the tile bounds 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..88e5d21e 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 @@ -399,6 +399,7 @@ export class RasterTileLayer< data: props.data, tileResult, uCut: tile._antimeridianCut.uCut, + descriptor, }) : this._renderNormalTile({ baseId: props.id, @@ -491,14 +492,12 @@ export class RasterTileLayer< /** * Build the two `RasterLayer`s for a Web-Mercator tile that crosses ±180°: * a west piece (UV `[0, uCut]`) and an east piece (UV `[uCut, 1]`). 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. + * piece uses its own `ReprojectionFns` bundle from the tile metadata, + * which corrects for the antimeridian discontinuity post-projection, in + * common-space units — see `RasterTileset2D.buildPieceReprojection`. The + * two pieces thus render in adjacent 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; @@ -506,13 +505,9 @@ export class RasterTileLayer< data: NonNullable; tileResult: RenderTileResult; uCut: number; + descriptor: RasterTilesetDescriptor; }): 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, diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index ccd6c348..3e22ddf5 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -1,10 +1,16 @@ /** * 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). + * `descriptor.projectTo4326(corner)[0]`. A crossing edge is accepted in + * either of two encodings: + * - **Native un-normalized**: for an identity 4326→4326 source whose + * `ModelTiepoint` sits past ±180°, proj4 does not renormalize, so west < + * east always and a crossing edge shows up as e.g. `(−204, −162)`. + * - **GeoJSON-flipped** (RFC 7946 §5.2): for any *projected* source, proj4's + * inverse projection normalizes its output to (−180°, 180°], so a + * crossing edge instead shows up with west > east, e.g. + * `(179.97, −179.17)` — the same convention the GeoJSON spec uses for an + * antimeridian-crossing bbox. + * A non-crossing edge always has west < east. */ export interface CornerLongitudes { topLeft: number; @@ -26,29 +32,42 @@ export interface AntimeridianCut { */ const U_EPSILON = 1e-6; +/** + * Unwrap a GeoJSON-flipped edge (RFC 7946 §5.2: west > east marks a + * crossing) onto the continuous native scale `edgeUCut`'s seam search + * expects, by adding 360° to `eastLng`. A no-op for an edge already in + * native un-normalized form (west < east) or for a degenerate zero-width + * edge (west === east). + */ +export function unwrapEastLng(westLng: number, eastLng: number): number { + return eastLng < westLng ? eastLng + 360 : eastLng; +} + /** * 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. + * Returns `undefined` if the edge does not cross. Accepts either corner + * longitude encoding described on {@link CornerLongitudes} — a GeoJSON-flipped + * edge is unwrapped via {@link unwrapEastLng} before the seam search, which + * finds 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 { + const unwrappedEastLng = unwrapEastLng(westLng, eastLng); // Degenerate or non-monotonic edge — caller is expected to pass // west-then-east in the source CRS's native ordering. - if (eastLng <= westLng) { + if (unwrappedEastLng <= 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) { + if (seam <= westLng || seam >= unwrappedEastLng) { return undefined; } - return (seam - westLng) / (eastLng - westLng); + return (seam - westLng) / (unwrappedEastLng - westLng); } /** @@ -60,18 +79,19 @@ function edgeUCut(westLng: number, eastLng: number): number | undefined { * cuts (non-geographic CRSs) — but for now those return `undefined` and fall * back to a single full-mesh layer. See issue #575. * - * Assumes u increases eastward (standard north-up geotransform) 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°`). + * Assumes u increases eastward (standard north-up geotransform). Corner + * longitudes may be in either encoding described on {@link CornerLongitudes}. */ export function antimeridianCut( cornerLngs: CornerLongitudes, ): AntimeridianCut | undefined { const { topLeft, topRight, bottomLeft, bottomRight } = cornerLngs; + console.log("[antimeridianCut] corners:", cornerLngs); + const topUCut = edgeUCut(topLeft, topRight); const bottomUCut = edgeUCut(bottomLeft, bottomRight); + console.log("[antimeridianCut] topUCut:", topUCut, "bottomUCut:", bottomUCut); if (topUCut === undefined || bottomUCut === undefined) { return undefined; } @@ -81,5 +101,6 @@ export function antimeridianCut( if (Math.abs(topUCut - bottomUCut) > U_EPSILON) { return undefined; } + console.log("[antimeridianCut] uCut:", (topUCut + bottomUCut) / 2); return { uCut: (topUCut + bottomUCut) / 2 }; } 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..dd743027 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 @@ -26,6 +26,7 @@ import { getTileIndices, rescaleCommonSpaceToEPSG3857, rescaleEPSG3857ToCommonSpace, + TILE_SIZE, } from "./raster-tile-traversal.js"; import { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js"; import type { RasterTilesetDescriptor } from "./tileset-interface.js"; @@ -126,15 +127,15 @@ export type RasterTileMetadata = { /** * 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. + * or `undefined` for non-crossing tiles. `forwardTransform`/`inverseTransform` + * are the stock, unmodified pixel↔source-CRS transform — the antimeridian + * discontinuity doesn't live there (it's a property of the projection, not + * the geotransform). Instead `forwardReproject`/`inverseReproject` correct + * for it post-projection, in common-space units: any raw common-space x + * below `TILE_SIZE / 2` is on the wrapped side and gets `+TILE_SIZE`, + * placing it in the world-copy adjacent to the other piece — see + * `buildPieceReprojection`. Built once in `getTileMetadata` for reference + * stability across renders. */ _westReprojection?: ReprojectionFns; @@ -440,25 +441,19 @@ export class RasterTileset2D extends Tileset2D { 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. + // Each piece of a crossing tile needs its `forwardReproject` output + // (common space) corrected per-point, not by one constant shift — see + // `buildPieceReprojection` for why. 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, ); } @@ -491,33 +486,42 @@ export class RasterTileset2D extends Tileset2D { /** * 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. + * + * A crossing tile's west piece always has native lngs just *below* +180° + * (e.g. 179.97°), which `projectPosition` maps to common-space x just + * *below* `TILE_SIZE` (e.g. 511.95) — no correction needed. The east + * piece's *interior* has native lngs just past −180° after proj4's + * wraparound (e.g. −179.17°), mapping to common-space x just *above* 0 + * (e.g. 1.18) — this needs `+TILE_SIZE` to sit continuously past the west + * piece. But the east piece's own west edge *is* the seam itself (native + * lng exactly 180°), which `projectPosition` maps to common-space x + * exactly `TILE_SIZE` — the *same* boundary value as the west piece's + * east edge, needing *no* correction, even though it belongs to the east + * piece. + * + * So the correction can't be "shift this whole piece by one world" (a + * single piece can contain both a boundary point at exactly the seam and + * interior points on the wrapped side of it) — it has to be a per-point + * test: any raw common-space x in the lower half `[0, TILE_SIZE/2)` is on + * the wrapped side and gets `+TILE_SIZE`; everything else (including the + * exact-seam boundary, at `TILE_SIZE`) is left alone. This is symmetric + * for both pieces — the west piece's raw x values never fall below + * `TILE_SIZE/2`, so the test is a no-op there, matching its already-correct + * behavior. */ 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]; + forwardTransform, + inverseTransform, + forwardReproject: (x, y) => { + const [cx, cy] = this.projectPosition(x, y); + return cx < TILE_SIZE / 2 ? [cx + TILE_SIZE, cy] : [cx, cy]; }, - inverseTransform: (x, y) => inverseTransform(x - lngShift, y), - forwardReproject: this.projectPosition, - inverseReproject: this.unprojectPosition, + inverseReproject: (cx, cy) => + this.unprojectPosition(cx >= TILE_SIZE ? cx - TILE_SIZE : cx, cy), }; } } diff --git a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts index 4c040260..4c17755e 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from "vitest"; -import { antimeridianCut } from "../../src/raster-tileset/antimeridian-cut.js"; +import { + antimeridianCut, + unwrapEastLng, +} 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). +// descriptor.projectTo4326(corner)[0]. Two encodings are accepted for a +// crossing edge — see CornerLongitudes' doc: +// - native un-normalized (identity 4326→4326 source): west < east always, +// e.g. (−204, −162). +// - GeoJSON-flipped (RFC 7946 §5.2 — what a projected source emits, since +// proj4's inverse projection normalizes to (−180°, 180°]): west > east, +// e.g. (179.97, −179.17). describe("antimeridianCut", () => { it("returns undefined for a non-crossing tile (west < east, no seam inside)", () => { expect( @@ -75,4 +81,48 @@ describe("antimeridianCut", () => { }), ).toBeUndefined(); }); + + it("locates a vertical cut for a GeoJSON-flipped crossing tile (projected-CRS lngs)", () => { + // dep_ls_geomad_066_022_2025 (EPSG:3832, PDC Mercator): projectTo4326 + // normalizes to (−180°, 180°], so the crossing edge is flipped — + // topLeft/bottomLeft = 179.967798°, topRight/bottomRight = −179.169819°. + const cut = antimeridianCut({ + topLeft: 179.967798, + topRight: -179.169819, + bottomLeft: 179.967798, + bottomRight: -179.169819, + }); + expect(cut).toBeDefined(); + // Unwrapped span is (179.967798, 180.830181); the 180° seam sits at + // (180 − 179.967798) / (180.830181 − 179.967798) ≈ 0.0373 of the way in. + expect(cut?.uCut).toBeCloseTo(0.037344, 5); + }); + + it("returns undefined for a degenerate zero-width edge (west === east)", () => { + expect( + antimeridianCut({ + topLeft: 170, + topRight: 170, + bottomLeft: 170, + bottomRight: 170, + }), + ).toBeUndefined(); + }); +}); + +describe("unwrapEastLng", () => { + it("adds 360° to a GeoJSON-flipped eastLng (west > east)", () => { + expect(unwrapEastLng(179.967798, -179.169819)).toBeCloseTo( + 180.830181, + 9, + ); + }); + + it("passes through a native un-normalized eastLng unchanged (west < east)", () => { + expect(unwrapEastLng(-204, -162)).toBe(-162); + }); + + it("passes through a degenerate zero-width edge unchanged (west === east)", () => { + expect(unwrapEastLng(170, 170)).toBe(170); + }); }); diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..2c76db24 --- /dev/null +++ b/plan.md @@ -0,0 +1,26 @@ + + +# Plan: render an antimeridian-crossing COG as two parts, split at `uCut` + +## The two things actually broken + +**1. Bbox: a crossing tile can't have one bbox.** +`getTileMetadata`'s `bbox` (line ~410, `transformBounds(projectTo4326, ...projectedBounds)`) reprojects the *whole* tile rectangle in one shot. For a crossing tile this naively spans ~360° (or, with `wrapPeriod`, silently violates the `[-180°,180°]` / west Date: Tue, 29 Sep 2026 10:17:31 +1000 Subject: [PATCH 02/21] WIP: first version mosaic/multicog antimeridian --- .../README.md | 25 +++ .../index.html | 22 +++ .../package.json | 37 ++++ .../src/App.tsx | 100 ++++++++++ .../src/data.ts | 43 +++++ .../src/main.tsx | 12 ++ .../src/vite-env.d.ts | 1 + .../antimeridian-mosaic-multi-example/todo.md | 8 +- .../tsconfig.json | 4 + .../vite.config.ts | 11 ++ .../src/mosaic-layer/mosaic-tileset-2d.ts | 31 ++- .../tests/mosaic-tileset-2d.test.ts | 34 ++++ .../raster-tileset-2d-antimeridian.test.ts | 182 ++++++++++-------- plan.md | 31 ++- pnpm-lock.yaml | 67 +++++++ 15 files changed, 510 insertions(+), 98 deletions(-) create mode 100644 examples/antimeridian-mosaic-multi-example/README.md create mode 100644 examples/antimeridian-mosaic-multi-example/index.html create mode 100644 examples/antimeridian-mosaic-multi-example/package.json create mode 100644 examples/antimeridian-mosaic-multi-example/src/App.tsx create mode 100644 examples/antimeridian-mosaic-multi-example/src/data.ts create mode 100644 examples/antimeridian-mosaic-multi-example/src/main.tsx create mode 100644 examples/antimeridian-mosaic-multi-example/src/vite-env.d.ts create mode 100644 examples/antimeridian-mosaic-multi-example/tsconfig.json create mode 100644 examples/antimeridian-mosaic-multi-example/vite.config.ts diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md new file mode 100644 index 00000000..8e9a5ec7 --- /dev/null +++ b/examples/antimeridian-mosaic-multi-example/README.md @@ -0,0 +1,25 @@ +# Antimeridian: Mosaic + MultiCOG Example + +Same two DEP GeoMAD items as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` — the shape real usage actually needs. See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). + +`MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame in `src/data.ts` before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. + +## 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..6d1b3932 --- /dev/null +++ b/examples/antimeridian-mosaic-multi-example/package.json @@ -0,0 +1,37 @@ +{ + "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:*", + "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..fc0a3c98 --- /dev/null +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -0,0 +1,100 @@ +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 { + ControlPanel, + DeckGlOverlay, + ExternalLink, +} from "deck.gl-raster-examples-shared"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { Map as MaplibreMap } from "react-map-gl/maplibre"; +import { GEOMAD_ITEMS } from "./data.js"; + +// DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees +// rawDN / 65535), so the display range needs the same division. This +// item's own STAC raster:bands stats put the red band in [7014, 16807] +// (mean 7682); 7200–12000 matches the reference app's own tuned stretch for +// the same product line. +const RESCALE_MIN = 7200 / 65535; +const RESCALE_MAX = 12000 / 65535; + +export default function App() { + const mosaicLayer = new MosaicLayer({ + id: "geomad-mosaic", + sources: GEOMAD_ITEMS, + 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: RESCALE_MIN, rescaleMax: RESCALE_MAX }, + }, + ], + }), + }); + + // Reference line at the true antimeridian (±180°). + const antimeridianLine = new PathLayer({ + id: "antimeridian-line", + data: [ + [ + [180, 80], + [180, -80], + ], + ], + getPath: (d: [number, number][]) => d, + getColor: [255, 0, 200, 255], + getWidth: 2, + widthUnits: "pixels", + }); + + return ( +
+ + + + + + + Same two DEP GeoMAD items as{" "} + + antimeridian-example + + , but composed as R/G/B through MosaicLayer +{" "} + MultiCOGLayer — the shape real usage actually needs, + not just a single-band COGLayer. See{" "} + + #575 + + . + + + + 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..59cf2bbb --- /dev/null +++ b/examples/antimeridian-mosaic-multi-example/src/data.ts @@ -0,0 +1,43 @@ +/** Minimal per-item shape this example needs — id, bbox, and R/G/B asset URLs. */ +export type GeomadItem = { + id: string; + bbox: [number, number, number, number]; + assets: { red: string; green: string; blue: string }; +}; + +const BASE = + "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test"; + +// TODO: Get this from the stac items, not the r g b asset urls. e.g. https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/064/020/2025/dep_ls_geomad_064_020_2025.stac-item.json +// Actually the stac-geoparquet. https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet + +export const GEOMAD_ITEMS: GeomadItem[] = [ + { + id: "dep_ls_geomad_064_020_2025", + // Ordinary STAC bbox — doesn't cross the antimeridian. + bbox: [ + 178.24303253271776, -18.47766942595451, 179.1054152054725, + -17.65281330691017, + ], + assets: { + red: `${BASE}/064/020/2025/dep_ls_geomad_064_020_2025_red.tif`, + green: `${BASE}/064/020/2025/dep_ls_geomad_064_020_2025_green.tif`, + blue: `${BASE}/064/020/2025/dep_ls_geomad_064_020_2025_blue.tif`, + }, + }, + { + id: "dep_ls_geomad_066_022_2025", + // The STAC bbox for this item is GeoJSON-flipped (RFC 7946 §5.2: crosses + // ±180° → xmin=179.97 > xmax=-179.17). MosaicLayer's spatial index + // (Flatbush) is a plain numeric R-tree with no antimeridian awareness — + // a flipped bbox (minX > maxX) doesn't mean anything to it. Unwrap onto + // a continuous frame instead (same convention as antimeridian-cut.ts's + // `unwrapEastLng`): xmax = −179.169819 + 360 = 180.830181. + bbox: [179.9677978782272, -16.8241145, 180.830180550982, -15.991730594839623], + assets: { + red: `${BASE}/066/022/2025/dep_ls_geomad_066_022_2025_red.tif`, + green: `${BASE}/066/022/2025/dep_ls_geomad_066_022_2025_green.tif`, + blue: `${BASE}/066/022/2025/dep_ls_geomad_066_022_2025_blue.tif`, + }, + }, +]; 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/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/todo.md b/examples/antimeridian-mosaic-multi-example/todo.md index 0007db39..87a70097 100644 --- a/examples/antimeridian-mosaic-multi-example/todo.md +++ b/examples/antimeridian-mosaic-multi-example/todo.md @@ -1 +1,7 @@ -# TODO: Test a combo of MosaicLayer and MultiCOGLayer which is what we actually need, not just simple COGLayer. +# TODO: Test a combo of MosaicLayer and MultiCOGLayer which is what we actually need, not just simple COGLayer. Also use the stac-geoparquet and stac items, not the red asset. +https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet + +I also want to make a mutant version to test all of the fixes: +- https://github.com/willjnz/deck.gl-raster/tree/willjnz/antimeridian-crossing-fixes +- https://github.com/developmentseed/deck.gl-raster/pull/640 +- https://github.com/developmentseed/deck.gl-raster/pull/639 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/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..ddc31b3e 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_MAP_COPIES = 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 @@ -108,11 +111,33 @@ export class MosaicTileset2D extends Tileset2D { return []; } - const viewportBounds = viewport.getBounds(); - const indices = index.search(...viewportBounds); + const [minX, minY, maxX, maxY] = viewport.getBounds(); + const matchedIndices = new Set(index.search(minX, minY, maxX, maxY)); + + // World-copy passes: when the viewport spans multiple world copies (e.g. + // WebMercatorViewport with repeat: true panned across the antimeridian), + // `viewport.getBounds()` reports longitudes for only one copy, but a + // source's bbox may only be indexed in another copy's range. Re-query the + // index with the bounds shifted by ±360°, ±720°… — same idea as + // raster-tile-traversal.ts's per-offset frustum passes, just in lng/lat + // instead of common-space pixels. See dev-docs/world-copies.md. + if ((viewport.subViewports?.length ?? 0) > 1) { + for (let offset = -1; offset >= -MAX_MAP_COPIES; offset--) { + const shift = offset * 360; + for (const i of index.search(minX + shift, minY, maxX + shift, maxY)) { + matchedIndices.add(i); + } + } + for (let offset = 1; offset <= MAX_MAP_COPIES; offset++) { + const shift = offset * 360; + for (const i of index.search(minX + shift, minY, maxX + shift, maxY)) { + matchedIndices.add(i); + } + } + } const sources = this.getSources(); - const selectedSources = indices.map((sourceIndex) => { + const selectedSources = Array.from(matchedIndices).map((sourceIndex) => { const source = sources[sourceIndex]!; return { // Remove once https://github.com/visgl/deck.gl/pull/10299 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..f6d5afa1 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,37 @@ 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 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/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..0238ebc3 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 @@ -18,6 +18,46 @@ function tilesetProps(): Tileset2DProps { return { getTileData: () => new Promise(() => {}) } as Tileset2DProps; } +// EPSG:3857's well-known half-circumference (meters at ±180°). +const HALF_CIRCUMFERENCE = 20037508.342789244; +const TILE_SIZE = 512; + +/** + * Wraps `lngDegrees` to `(−180°, 180°]` before converting to 3857 meters — + * mimicking what a *projected* source CRS's forward-to-3857 composition + * does in practice: it passes through a periodic geographic intermediate + * that proj4 normalizes, so a native lng outside that range wraps (see + * `buildPieceReprojection`'s doc comment). A source CRS that's *directly* + * EPSG:4326 (the `antimeridianCut` detection tests below) does not — hence + * two separate projection mocks in this file. + */ +function lngDegreesToWrappedMercatorX(lngDegrees: number): number { + let wrapped = lngDegrees; + while (wrapped > 180) { + wrapped -= 360; + } + while (wrapped <= -180) { + wrapped += 360; + } + return (wrapped / 180) * HALF_CIRCUMFERENCE; +} + +const WRAPPING_PROJECTIONS = { + projectTo3857: (x: number, y: number): [number, number] => [ + lngDegreesToWrappedMercatorX(x), + y, + ], + projectFrom3857: (x: number, y: number): [number, number] => [ + (x / HALF_CIRCUMFERENCE) * 180, + y, + ], + // Detection (`antimeridianCut`) needs native, un-normalized corner lngs — + // see the `antimeridian.tif` comment below. Identity here matches an + // EPSG:4326 source (proj4 4326→4326 does not renormalize). + projectTo4326: identity, + projectFrom4326: identity, +}; + 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. @@ -60,90 +100,76 @@ describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { expect(metadata._eastReprojection).toBeUndefined(); }); - it("shifts the west piece's geotransform by +360° so its native lngs round-trip through proj4", () => { - // antimeridian.tif shape again. West piece native lngs (−204°, −180°) are - // outside proj4's valid range. The piece's forwardTransform must add 360° - // to land in [+156°, +180°]; the matching inverseTransform must subtract - // 360° so pixel coords round-trip cleanly. - const level = new AffineTilesetLevel({ - affine: compose(translation(-204, 24), scale(1, -1)), - arrayWidth: 42, - arrayHeight: 42, - tileWidth: 42, - tileHeight: 42, - mpu: 1, + describe("piece reprojection (buildPieceReprojection)", () => { + // Same antimeridian.tif shape (native lngs −204..−162, uCut = 24/42), + // but with WRAPPING_PROJECTIONS so `forwardReproject` actually exercises + // proj4-style wraparound — the mechanism the fix corrects for. + function crossingMetadata() { + 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], + ...WRAPPING_PROJECTIONS, + }); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + return tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + } + + it("leaves forwardTransform/inverseTransform untouched — the correction lives in forwardReproject, not the geotransform", () => { + const metadata = crossingMetadata(); + expect(metadata._westReprojection!.forwardTransform(5, 5)).toEqual( + metadata.forwardTransform(5, 5), + ); + expect(metadata._eastReprojection!.inverseTransform(5, 5)).toEqual( + metadata.inverseTransform(5, 5), + ); }); - const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); - const tileset = new RasterTileset2D(tilesetProps(), descriptor); - - const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); - - expect(metadata._westReprojection).toBeDefined(); - const { forwardTransform: westFwd, inverseTransform: westInv } = - metadata._westReprojection!; - - // Pixel (0, 0) → native (−204, 24) → shifted (+156, 24) - const [wx0, wy0] = westFwd(0, 0); - expect(wx0).toBeCloseTo(156, 9); - expect(wy0).toBeCloseTo(24, 9); - - // Inverse round-trip: shifted (+156, 24) → pixel (0, 0) - const [wpx0, wpy0] = westInv(156, 24); - expect(wpx0).toBeCloseTo(0, 9); - expect(wpy0).toBeCloseTo(0, 9); - }); - it("leaves the east piece's geotransform unshifted (its lngs already in range)", () => { - const level = new AffineTilesetLevel({ - affine: compose(translation(-204, 24), scale(1, -1)), - arrayWidth: 42, - arrayHeight: 42, - tileWidth: 42, - tileHeight: 42, - mpu: 1, + it("leaves a point that wraps to common-space x ≥ TILE_SIZE/2 unshifted (west piece's own territory)", () => { + const metadata = crossingMetadata(); + // Native lng −204° wraps to +156° → common-space x ≈ 477.9 (≥ 256). + const [cx] = metadata._westReprojection!.forwardReproject(-204, 24); + expect(cx).toBeGreaterThanOrEqual(TILE_SIZE / 2); + expect(cx).toBeCloseTo(477.87, 1); }); - const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); - const tileset = new RasterTileset2D(tilesetProps(), descriptor); - - const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); - - expect(metadata._eastReprojection).toBeDefined(); - const { forwardTransform: eastFwd } = metadata._eastReprojection!; - // East piece native lng midpoint = −171° ∈ [−180°, 180°] → no shift. - // Seam pixel (col 24, row 0) → native (−180, 24), unchanged. - const [ex0, ey0] = eastFwd(24, 0); - expect(ex0).toBeCloseTo(-180, 9); - expect(ey0).toBeCloseTo(24, 9); - }); - it("shifts the east piece by −360° for a fixture crossing native +180° (e.g. native [170, 190])", () => { - // Mirror of the antimeridian.tif case: tile native lngs (170, 190). East - // piece (180, 190) is outside proj4's range and must shift by −360°. - const level = new AffineTilesetLevel({ - affine: compose(translation(170, 10), scale(1, -1)), - arrayWidth: 20, - arrayHeight: 20, - tileWidth: 20, - tileHeight: 20, - mpu: 1, + it("shifts a point that wraps to common-space x < TILE_SIZE/2 by +TILE_SIZE (east piece's interior)", () => { + const metadata = crossingMetadata(); + // Native lng −162° wraps to −162° (already in range) → common-space + // x ≈ 25.6 (< 256) → shifted to ≈ 537.6, continuing past the west + // piece's edge instead of wrapping back to the start of the world. + const [cx] = metadata._eastReprojection!.forwardReproject(-162, 24); + expect(cx).toBeCloseTo(25.6 + TILE_SIZE, 1); }); - const descriptor = new AffineTileset({ levels: [level], ...PROJECTIONS }); - const tileset = new RasterTileset2D(tilesetProps(), descriptor); - const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + it("does NOT shift the exact seam corner, even though it's the east piece's own west edge — the bug this fix corrects", () => { + // The seam itself: native lng −180° wraps to exactly +180° → + // common-space x = TILE_SIZE exactly (512, not < TILE_SIZE/2). This is + // the same corner as the west piece's east edge — both pieces must + // agree on it, or the boundary corner gets double-shifted (regression + // test for the real-world bug: a single per-piece constant shift + // pushed this corner to 1024 instead of leaving it at 512). + const metadata = crossingMetadata(); + const [westCx] = metadata._westReprojection!.forwardReproject(-180, 24); + const [eastCx] = metadata._eastReprojection!.forwardReproject(-180, 24); + expect(westCx).toBeCloseTo(TILE_SIZE, 9); + expect(eastCx).toBeCloseTo(TILE_SIZE, 9); + }); - 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); + it("round-trips forwardReproject/inverseReproject for a shifted point", () => { + const metadata = crossingMetadata(); + const { forwardReproject, inverseReproject } = + metadata._eastReprojection!; + const [cx, cy] = forwardReproject(-162, 24); + const [x, y] = inverseReproject(cx, cy); + expect(x).toBeCloseTo(-162, 6); + expect(y).toBeCloseTo(24, 6); + }); }); }); diff --git a/plan.md b/plan.md index 2c76db24..3232ec34 100644 --- a/plan.md +++ b/plan.md @@ -1,26 +1,25 @@ +# Antimeridian crossing: what it took (summary) +Four real bugs. The first three are in `packages/deck.gl-raster/src/raster-tileset/`, on top of PR #576's existing split-in-two design (`antimeridian-cut.ts`'s `uCut` + two `RasterLayer` pieces). The fourth is in `packages/deck.gl-geotiff/src/mosaic-layer/`, and only shows up once a crossing dataset is used through `MosaicLayer` + `MultiCOGLayer` instead of a plain `COGLayer`. -# Plan: render an antimeridian-crossing COG as two parts, split at `uCut` +## Fixed -## The two things actually broken +1. **Crossing detection missed projected (non-4326) sources.** `edgeUCut` (`antimeridian-cut.ts`) only accepted "native un-normalized" corner lngs (`west < east` always, e.g. `−204°, −162°` — true for a 4326 source). A projected source (e.g. EPSG:3832) makes `projectTo4326` normalize its output to `(−180°, 180°]`, so a crossing tile shows up **GeoJSON-flipped** instead (`west > east`, e.g. `179.97°, −179.17°` — RFC 7946 §5.2's own convention). Fix: `unwrapEastLng` adds 360° when `east < west` before the seam search. Needed for any real-world projected-CRS COG, not just STAC's bbox convention — it's `proj4` normalization, unrelated to STAC. -**1. Bbox: a crossing tile can't have one bbox.** -`getTileMetadata`'s `bbox` (line ~410, `transformBounds(projectTo4326, ...projectedBounds)`) reprojects the *whole* tile rectangle in one shot. For a crossing tile this naively spans ~360° (or, with `wrapPeriod`, silently violates the `[-180°,180°]` / west 1`, unioning the results (deduping via a `Set`) — the same per-offset-pass idea `raster-tile-traversal.ts` already uses for XYZ tile frustum culling, just in lng/lat degrees instead of common-space pixels. -## Plan +## Known gap (not yet fixed, currently dormant) -1. **Bbox split.** In `getTileMetadata`, when `_antimeridianCut` is defined: instead of one `transformBounds` call over the full `projectedBounds`, split `projectedBounds` at the source-CRS x matching `uCut` (`cutX = left + uCut * (right - left)`), and call plain `transformBounds` (no wrap) once per piece: `[left, bottom, cutX, top]` and `[cutX, bottom, right, top]`. Each piece is monotonic by construction — no wrap logic needed anywhere. -2. **Reconcile west/east bbox with each piece's mesh frame.** The west bbox comes out ordinary (e.g. `[179.97°, …, 180°]`). The east bbox, reprojected with the *stock* (unshifted) `projectTo4326`, comes out on the wrong side (`[-180°, …, -179.17°]`) — adjacent to the west bbox only in wrap-around terms, not numerically. It needs the same `+1 world` shift as its mesh piece so the two bboxes sit in one continuous frame and union correctly (`[179.97°, …, 180.83°]`). -3. **Move the mesh shift to the right stage.** Change `buildPieceReprojection` to stop shifting `forwardTransform`'s output. Instead, wrap `forwardReproject`/`inverseReproject` (currently just `this.projectPosition`/`this.unprojectPosition`) to add/subtract one world-copy (`TILE_SIZE` in common space, equivalently `EPSG_3857_CIRCUMFERENCE` in 3857 meters) for the east piece. Decide "does this piece need a world shift" from the piece's *already-computed WGS84 mid-longitude* (degrees), not from raw source-CRS units — this makes it correct for both the 4326-native-unnormalized case and the projected/GeoJSON-flipped case with one code path. -4. **Reuse for bbox.** Once (3) exists, step 1/2's per-piece bbox can reproject through the *same* per-piece `forwardReproject`, guaranteeing bbox and mesh always agree — no separate shift computation to keep in sync. -5. **Dataset-level `wgs84Bounds`** (constructor, line ~221): same treatment, once the per-tile case is confirmed working in the browser. Lower priority — only matters when the dataset's own outer rectangle (not just one internal tile) crosses. +`getTileMetadata`'s per-tile `bbox` and the constructor's dataset-level `wgs84Bounds` still call `transformBounds` **once over the whole (unsplit) tile/dataset** — for a genuinely crossing tile this still computes a bogus ~360°-wide box. It hasn't broken anything in `antimeridian-example` because that box only gates tile *selection*, and a single-tile (1×1 grid) dataset's small-dataset path in `getTileIndices` bypasses that check entirely. A multi-tile crossing mosaic would hit this. Proper fix: split `projectedBounds` at the same `uCut`-derived source-CRS x, reproject each piece with its own (now-fixed) `forwardReproject`, and union — mirrors what the mesh/debug-outline fixes already do, just not implemented for these two call sites yet. -## Non-goals +## Verified -- Slanted/curved cuts, multi-crossing datasets (#575). -- Don't touch tests until this renders correctly in the browser. +- Real-data isolated test (`GeoTIFF.fromUrl` → `geoTiffToDescriptor`, no STAC) against the actual DEP GeoMAD antimeridian item. +- `examples/antimeridian-example`: renders both pieces with real pixel data, correct debug-overlay boxes, no more "belting the earth." +- `examples/antimeridian-mosaic-multi-example`: same two DEP GeoMAD items composited R/G/B through `MosaicLayer` + `MultiCOGLayer` (the shape real usage actually needs) instead of a single-band `COGLayer`; renders both items with a continuous, untorn coastline straddling the antimeridian reference line. +- 151/151 unit tests pass (`packages/deck.gl-raster`), including new coverage for the per-point threshold fix and its exact-seam-corner regression case. +- 25/25 unit tests pass (`packages/deck.gl-geotiff`), including new coverage for the mosaic world-copy fix (shifted-copy lookup, no-op when not needed, dedup across passes). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23ff2140..80f28d51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,73 @@ 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 + 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': From 92bb492209020760660d3694fcd72a17a5c6b998 Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 10:41:55 +1000 Subject: [PATCH 03/21] WIP: debug antimeridian mosaic/multicog from stac-geoparquet --- .../README.md | 4 +- .../src/App.tsx | 23 ++- .../src/data.ts | 72 +++++----- .../antimeridian-mosaic-multi-example/todo.md | 3 - .../deck.gl-geotiff/src/multi-cog-layer.ts | 136 +++++++++++++++--- .../multi-cog-layer-debug-outline.test.ts | 51 +++++++ plan.md | 7 +- 7 files changed, 239 insertions(+), 57 deletions(-) create mode 100644 packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md index 8e9a5ec7..f8892531 100644 --- a/examples/antimeridian-mosaic-multi-example/README.md +++ b/examples/antimeridian-mosaic-multi-example/README.md @@ -2,7 +2,9 @@ Same two DEP GeoMAD items as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` — the shape real usage actually needs. See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). -`MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame in `src/data.ts` before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. +`src/data.ts` fetches each item's own STAC item JSON (rather than hardcoding bbox/asset URLs) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. + +The debug overlay toggle exercises `MultiCOGLayer`'s own tile-outline rendering (`debug`/`debugOpacity`/`debugLevel`), confirming it also knows about the antimeridian mesh split. ## Setup diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index fc0a3c98..799d2b0f 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -2,14 +2,18 @@ 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 { GEOMAD_ITEMS } from "./data.js"; +import type { GeomadItem } from "./data.js"; +import { fetchGeomadItems } from "./data.js"; // DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees // rawDN / 65535), so the display range needs the same division. This @@ -20,9 +24,20 @@ const RESCALE_MIN = 7200 / 65535; const RESCALE_MAX = 12000 / 65535; 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: GEOMAD_ITEMS, + sources: geomadItems, renderSource: (source) => new MultiCOGLayer({ id: `geomad-${source.id}`, @@ -38,6 +53,9 @@ export default function App() { props: { rescaleMin: RESCALE_MIN, rescaleMax: RESCALE_MAX }, }, ], + debug: debugState.debug, + debugOpacity: debugState.debugOpacity, + debugLevel: debugState.debugLevel, }), }); @@ -94,6 +112,7 @@ export default function App() { {" "} Landsat GeoMAD mosaic. + ); diff --git a/examples/antimeridian-mosaic-multi-example/src/data.ts b/examples/antimeridian-mosaic-multi-example/src/data.ts index 59cf2bbb..e0f6c8d5 100644 --- a/examples/antimeridian-mosaic-multi-example/src/data.ts +++ b/examples/antimeridian-mosaic-multi-example/src/data.ts @@ -8,36 +8,44 @@ export type GeomadItem = { const BASE = "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test"; -// TODO: Get this from the stac items, not the r g b asset urls. e.g. https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/064/020/2025/dep_ls_geomad_064_020_2025.stac-item.json -// Actually the stac-geoparquet. https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet - -export const GEOMAD_ITEMS: GeomadItem[] = [ - { - id: "dep_ls_geomad_064_020_2025", - // Ordinary STAC bbox — doesn't cross the antimeridian. - bbox: [ - 178.24303253271776, -18.47766942595451, 179.1054152054725, - -17.65281330691017, - ], - assets: { - red: `${BASE}/064/020/2025/dep_ls_geomad_064_020_2025_red.tif`, - green: `${BASE}/064/020/2025/dep_ls_geomad_064_020_2025_green.tif`, - blue: `${BASE}/064/020/2025/dep_ls_geomad_064_020_2025_blue.tif`, - }, - }, - { - id: "dep_ls_geomad_066_022_2025", - // The STAC bbox for this item is GeoJSON-flipped (RFC 7946 §5.2: crosses - // ±180° → xmin=179.97 > xmax=-179.17). MosaicLayer's spatial index - // (Flatbush) is a plain numeric R-tree with no antimeridian awareness — - // a flipped bbox (minX > maxX) doesn't mean anything to it. Unwrap onto - // a continuous frame instead (same convention as antimeridian-cut.ts's - // `unwrapEastLng`): xmax = −179.169819 + 360 = 180.830181. - bbox: [179.9677978782272, -16.8241145, 180.830180550982, -15.991730594839623], - assets: { - red: `${BASE}/066/022/2025/dep_ls_geomad_066_022_2025_red.tif`, - green: `${BASE}/066/022/2025/dep_ls_geomad_066_022_2025_green.tif`, - blue: `${BASE}/066/022/2025/dep_ls_geomad_066_022_2025_blue.tif`, - }, - }, +// Two items from the same DEP Landsat GeoMAD catalog: `064/020` doesn't cross +// the antimeridian, `066/022` does. Fetched from each item's own STAC item +// JSON below rather than hardcoding bbox/asset URLs. +const ITEM_PATHS = [ + "064/020/2025/dep_ls_geomad_064_020_2025", + "066/022/2025/dep_ls_geomad_066_022_2025", ]; + +type StacItem = { + bbox: [number, number, number, number]; + assets: Record; +}; + +/** + * Fetch each item's STAC item JSON and pull out just what this example needs. + * + * The STAC bbox for a crossing item is GeoJSON-flipped (RFC 7946 §5.2: crosses + * ±180° → xmin > xmax, e.g. `066/022`'s `179.97, -179.17`). MosaicLayer's + * spatial index (Flatbush) is a plain numeric R-tree with no antimeridian + * awareness — a flipped bbox doesn't mean anything to it — so unwrap onto a + * continuous frame instead (same convention as antimeridian-cut.ts's + * `unwrapEastLng`): xmax = −179.169819 + 360 = 180.830181. + */ +export async function fetchGeomadItems(): Promise { + return Promise.all( + ITEM_PATHS.map(async (path) => { + const res = await fetch(`${BASE}/${path}.stac-item.json`); + const item: StacItem = await res.json(); + const [minX, minY, maxX, maxY] = item.bbox; + return { + id: path.split("/").pop()!, + bbox: [minX, minY, maxX < minX ? maxX + 360 : maxX, maxY], + assets: { + red: item.assets.red!.href, + green: item.assets.green!.href, + blue: item.assets.blue!.href, + }, + }; + }), + ); +} diff --git a/examples/antimeridian-mosaic-multi-example/todo.md b/examples/antimeridian-mosaic-multi-example/todo.md index 87a70097..d6ab5dd5 100644 --- a/examples/antimeridian-mosaic-multi-example/todo.md +++ b/examples/antimeridian-mosaic-multi-example/todo.md @@ -1,6 +1,3 @@ -# TODO: Test a combo of MosaicLayer and MultiCOGLayer which is what we actually need, not just simple COGLayer. Also use the stac-geoparquet and stac items, not the red asset. -https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet - I also want to make a mutant version to test all of the fixes: - https://github.com/willjnz/deck.gl-raster/tree/willjnz/antimeridian-crossing-fixes - https://github.com/developmentseed/deck.gl-raster/pull/640 diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 8472054e..bab81c8b 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -16,6 +16,7 @@ import type { MultiRasterTilesetDescriptor, ProjectionFunction, RasterModule, + RasterTileMetadata, RasterTilesetDescriptor, RasterTilesetLevel, RenderTileResult, @@ -861,18 +862,60 @@ export class MultiCOGLayer extends RasterTileLayer< } // --- Primary tile outline and label --- - const primaryCrsCorners = primaryLevel.projectedTileCorners(x, y); - const { path: primaryPath, center: primaryCenter } = cornersToWgs84Path( - primaryCrsCorners, - forwardTo4326, - ); + // + // An antimeridian-crossing tile renders as two RasterLayer pieces (see + // RasterTileLayer._renderAntimeridianTile); the debug outline needs to + // match, or PathLayer connects a corner like 179.97° to −179.17° the + // "short" way in lng/lat space — the long way around the globe. Each + // piece's own corners stay entirely on one side of ±180°, so no unwrap + // correction is needed within a piece — just split at the same `uCut` + // pixel used to build the actual render pieces. + const { _antimeridianCut, forwardTransform, tileWidth, tileHeight } = + tile as unknown as RasterTileMetadata; + + const primaryBoxes: Array<{ + path: [number, number][]; + center: [number, number]; + labelSuffix: string; + }> = _antimeridianCut + ? [ + { + ...pieceBoxWgs84( + forwardTransform, + forwardTo4326, + 0, + _antimeridianCut.uCut * tileWidth, + tileHeight, + ), + labelSuffix: " (west)", + }, + { + ...pieceBoxWgs84( + forwardTransform, + forwardTo4326, + _antimeridianCut.uCut * tileWidth, + tileWidth, + tileHeight, + ), + labelSuffix: " (east)", + }, + ] + : [ + { + ...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, @@ -882,15 +925,20 @@ export class MultiCOGLayer extends RasterTileLayer< ); // 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 first primary piece's center and + // stacked below it. A second piece (the "east" half of a crossing tile) + // sits at its own separate position, so it needs no stack offset. + 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 +949,15 @@ export class MultiCOGLayer extends RasterTileLayer< layers.push( new TextLayer({ id: `${tileId}-debug-primary-label`, - data: [ - { - position: primaryCenter, - text: primaryText, - }, - ], + data: primaryBoxes.map((box, i) => ({ + position: box.center, + text: `x=${x} y=${y} z=${z}${box.labelSuffix}${primaryDetail}`, + pixelOffset: i === 0 ? [0, -topOffset] : [0, 0], + })), getColor: primaryColor.text, getSize: 14, - getPixelOffset: [0, -topOffset], + getPixelOffset: (d: { pixelOffset: [number, number] }) => + d.pixelOffset, sizeUnits: "pixels", outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -1032,6 +1080,60 @@ function createBandTexture(device: Device, array: RasterArray): Texture { }); } +/** + * Closed 5-point box path (in WGS84) for a pixel-space rectangle `[x0, 0] .. + * [x1, height]` within a tile, mapped through `forwardTransform` (pixel → + * source CRS) then `projectTo4326` (source CRS → WGS84). + * + * Used to draw one piece of an antimeridian-crossing tile's debug outline — + * mirrors `pieceBoxPath` in `@developmentseed/deck.gl-raster`'s + * `layer-utils.ts`, but stops at WGS84 rather than continuing on to + * common-space, since `_renderDebugLayers` draws in plain lng/lat. + * + * `projectTo4326` normalizes lng to `(−180°, 180°]` — the seam corner shared + * with the *other* piece projects to the same `+180°` regardless of which + * piece is asking. For the west piece that's already continuous with its + * other corners (~179.97°..180°), but for the east piece it isn't + * (180°..−179.17° would draw as a ~359° span, not the real ~0.83° gap), so + * every corner is unwrapped by ±360° relative to the box's first corner + * before PathLayer sees it. + */ +export function pieceBoxWgs84( + forwardTransform: ProjectionFunction, + projectTo4326: ProjectionFunction, + x0: number, + x1: number, + height: number, +): { path: [number, number][]; center: [number, number] } { + const corners: [number, number][] = [ + [x0, 0], + [x1, 0], + [x1, height], + [x0, height], + [x0, 0], + ]; + const rawCorners = corners.map(([px, py]) => { + const [sx, sy] = forwardTransform(px!, py!); + return projectTo4326(sx, sy) as [number, number]; + }); + const refLng = rawCorners[0]![0]; + const path: [number, number][] = rawCorners.map(([lng, lat]) => { + let unwrapped = lng; + while (unwrapped - refLng > 180) unwrapped -= 360; + while (unwrapped - refLng < -180) unwrapped += 360; + return [unwrapped, lat]; + }); + const xs = path.map((p) => p[0]); + const ys = path.map((p) => p[1]); + return { + path, + center: [ + (Math.min(...xs) + Math.max(...xs)) / 2, + (Math.min(...ys) + Math.max(...ys)) / 2, + ], + }; +} + /** * Project CRS tile corners to WGS84 and return a closed path suitable for * PathLayer, plus the center point for label placement. diff --git a/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts b/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts new file mode 100644 index 00000000..9c38bd92 --- /dev/null +++ b/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { pieceBoxWgs84 } from "../src/multi-cog-layer.js"; + +/** + * `projectTo4326` normalizes lng to `(−180°, 180°]`. For a point sitting + * exactly on an antimeridian-crossing tile's cut line, it returns `+180°` + * regardless of which piece asked — real (west corner, cut corner, east + * corner) values observed for the DEP GeoMAD `066/022` crossing item. + */ +function crossingProjectTo4326(px: number): [number, number] { + if (px === 0) return [179.9677978782272, -15.991730594838703]; + if (px === 11.949079327358657) return [180, -15.991730594838703]; + if (px === 320) return [-179.169819449018, -15.991730594838703]; + throw new Error(`unexpected px ${px}`); +} + +const identityForwardTransform = (px: number, py: number): [number, number] => [ + px, + py, +]; +const forwardTo4326 = (sx: number, _sy: number): [number, number] => + crossingProjectTo4326(sx); + +describe("pieceBoxWgs84", () => { + it("keeps the west piece's box within a small, continuous lng range", () => { + const { path } = pieceBoxWgs84( + identityForwardTransform, + forwardTo4326, + 0, + 11.949079327358657, + 320, + ); + const lngs = path.map((p) => p[0]); + expect(Math.max(...lngs) - Math.min(...lngs)).toBeLessThan(1); + }); + + it("unwraps the east piece's cut corner so it stays continuous with the far corner, instead of belting the globe", () => { + const { path } = pieceBoxWgs84( + identityForwardTransform, + forwardTo4326, + 11.949079327358657, + 320, + 320, + ); + const lngs = path.map((p) => p[0]); + // Un-fixed, the cut corner comes back as +180 while the far corner is + // -179.17 — a ~359° span. Fixed, the cut corner is unwrapped to -180, + // a ~0.83° span. + expect(Math.max(...lngs) - Math.min(...lngs)).toBeLessThan(1); + }); +}); diff --git a/plan.md b/plan.md index 3232ec34..c2083f5b 100644 --- a/plan.md +++ b/plan.md @@ -1,6 +1,6 @@ # Antimeridian crossing: what it took (summary) -Four real bugs. The first three are in `packages/deck.gl-raster/src/raster-tileset/`, on top of PR #576's existing split-in-two design (`antimeridian-cut.ts`'s `uCut` + two `RasterLayer` pieces). The fourth is in `packages/deck.gl-geotiff/src/mosaic-layer/`, and only shows up once a crossing dataset is used through `MosaicLayer` + `MultiCOGLayer` instead of a plain `COGLayer`. +Five real bugs. The first three are in `packages/deck.gl-raster/src/raster-tileset/`, on top of PR #576's existing split-in-two design (`antimeridian-cut.ts`'s `uCut` + two `RasterLayer` pieces). The last two are in `packages/deck.gl-geotiff/src/mosaic-layer/` and `.../multi-cog-layer.ts`, and only showed up once a crossing dataset was used through `MosaicLayer` + `MultiCOGLayer` instead of a plain `COGLayer` — a second, independent debug-outline implementation living outside `raster-tileset/` had the same class of bug as #3, just not caught until real usage exercised it. ## Fixed @@ -12,6 +12,8 @@ Four real bugs. The first three are in `packages/deck.gl-raster/src/raster-tiles 4. **`MosaicLayer`'s spatial index missed sources in the "other" world copy.** `MosaicTileset2D.getTileIndices` queried its Flatbush index once, with `viewport.getBounds()`'s raw lng/lat bounds. When the view spans the antimeridian, deck.gl renders multiple world-copy sub-viewports (`viewport.subViewports`), and each reports bounds for a different 360°-shifted copy of the world — but every source's bbox is indexed in only one of those copies (e.g. the crossing item's unwrapped `[179.97°, 180.83°]` bbox). A query landing in the wrong copy (e.g. `[-189.6°, -170.4°]`) found nothing, so the source dropped out of the tileset entirely — no tiles, no `MultiCOGLayer`, blank map. Fixed by re-querying the index with the bounds shifted by ±360°, ±720°, ±1080° whenever `subViewports.length > 1`, unioning the results (deduping via a `Set`) — the same per-offset-pass idea `raster-tile-traversal.ts` already uses for XYZ tile frustum culling, just in lng/lat degrees instead of common-space pixels. +5. **`MultiCOGLayer` has its own separate debug-outline renderer, and it had the same "one unsplit box" bug as #3.** `_renderDebugLayers` (`multi-cog-layer.ts`) draws its own primary-tile outline independently of `renderDebugTileOutline` — it isn't a thin wrapper, so fix #3 never reached it. It projected the tile's raw corners to WGS84 and connected them as one box; for a crossing tile the red outline connected `179.97°` to `−179.17°` directly — not "the long way around" like #3, but still wrong: `projectTo4326` normalizes the shared seam corner to `+180°` for *both* pieces, so the west piece's box (`179.97°..180°`) came out fine but the east piece's (`180°..−179.17°`) still read as a ~359° span instead of the real ~0.83° gap — a red bar "belting" the whole visible map. Fixed by splitting at the same `uCut` pixel (mirroring #3's `pieceBoxPath`, stopping at WGS84 instead of continuing to common space), then unwrapping every corner in a piece by ±360° relative to its own first corner before handing the path to `PathLayer`. + ## Known gap (not yet fixed, currently dormant) `getTileMetadata`'s per-tile `bbox` and the constructor's dataset-level `wgs84Bounds` still call `transformBounds` **once over the whole (unsplit) tile/dataset** — for a genuinely crossing tile this still computes a bogus ~360°-wide box. It hasn't broken anything in `antimeridian-example` because that box only gates tile *selection*, and a single-tile (1×1 grid) dataset's small-dataset path in `getTileIndices` bypasses that check entirely. A multi-tile crossing mosaic would hit this. Proper fix: split `projectedBounds` at the same `uCut`-derived source-CRS x, reproject each piece with its own (now-fixed) `forwardReproject`, and union — mirrors what the mesh/debug-outline fixes already do, just not implemented for these two call sites yet. @@ -22,4 +24,5 @@ Four real bugs. The first three are in `packages/deck.gl-raster/src/raster-tiles - `examples/antimeridian-example`: renders both pieces with real pixel data, correct debug-overlay boxes, no more "belting the earth." - `examples/antimeridian-mosaic-multi-example`: same two DEP GeoMAD items composited R/G/B through `MosaicLayer` + `MultiCOGLayer` (the shape real usage actually needs) instead of a single-band `COGLayer`; renders both items with a continuous, untorn coastline straddling the antimeridian reference line. - 151/151 unit tests pass (`packages/deck.gl-raster`), including new coverage for the per-point threshold fix and its exact-seam-corner regression case. -- 25/25 unit tests pass (`packages/deck.gl-geotiff`), including new coverage for the mosaic world-copy fix (shifted-copy lookup, no-op when not needed, dedup across passes). +- 27/27 unit tests pass (`packages/deck.gl-geotiff`), including new coverage for the mosaic world-copy fix (shifted-copy lookup, no-op when not needed, dedup across passes) and the `MultiCOGLayer` debug-outline unwrap fix. +- `examples/antimeridian-mosaic-multi-example`'s debug overlay toggle confirmed live in-browser: crossing tile draws as a small box straddling the antimeridian line, not a bar spanning the visible map. From 2de2d7d082a4df9d4e1bbe2c2b4e0a7486c7bc43 Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 10:52:13 +1000 Subject: [PATCH 04/21] Ponytail review --- .../src/mosaic-layer/mosaic-tileset-2d.ts | 21 ++++++++++--------- .../deck.gl-geotiff/src/multi-cog-layer.ts | 7 +++---- packages/deck.gl-raster/src/layer-utils.ts | 11 ++++++---- .../src/raster-tileset/antimeridian-cut.ts | 4 ---- .../src/raster-tileset/raster-tileset-2d.ts | 9 +++----- 5 files changed, 24 insertions(+), 28 deletions(-) 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 ddc31b3e..d8fc7795 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 @@ -122,16 +122,17 @@ export class MosaicTileset2D extends Tileset2D { // raster-tile-traversal.ts's per-offset frustum passes, just in lng/lat // instead of common-space pixels. See dev-docs/world-copies.md. if ((viewport.subViewports?.length ?? 0) > 1) { - for (let offset = -1; offset >= -MAX_MAP_COPIES; offset--) { - const shift = offset * 360; - for (const i of index.search(minX + shift, minY, maxX + shift, maxY)) { - matchedIndices.add(i); - } - } - for (let offset = 1; offset <= MAX_MAP_COPIES; offset++) { - const shift = offset * 360; - for (const i of index.search(minX + shift, minY, maxX + shift, maxY)) { - matchedIndices.add(i); + for (let n = 1; n <= MAX_MAP_COPIES; n++) { + for (const offset of [-n, n]) { + const shift = offset * 360; + for (const i of index.search( + minX + shift, + minY, + maxX + shift, + maxY, + )) { + matchedIndices.add(i); + } } } } diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index bab81c8b..7f132f8c 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -949,15 +949,14 @@ export class MultiCOGLayer extends RasterTileLayer< layers.push( new TextLayer({ id: `${tileId}-debug-primary-label`, - data: primaryBoxes.map((box, i) => ({ + data: primaryBoxes.map((box) => ({ position: box.center, text: `x=${x} y=${y} z=${z}${box.labelSuffix}${primaryDetail}`, - pixelOffset: i === 0 ? [0, -topOffset] : [0, 0], })), getColor: primaryColor.text, getSize: 14, - getPixelOffset: (d: { pixelOffset: [number, number] }) => - d.pixelOffset, + getPixelOffset: (_d: unknown, { index }: { index: number }) => + index === 0 ? [0, -topOffset] : [0, 0], sizeUnits: "pixels", outlineWidth: 3, outlineColor: [0, 0, 0, 255], diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts index 5d2664ad..ad4f34c8 100644 --- a/packages/deck.gl-raster/src/layer-utils.ts +++ b/packages/deck.gl-raster/src/layer-utils.ts @@ -43,27 +43,30 @@ export function renderDebugTileOutline( tile: Tile2DHeader & RasterTileMetadata, forwardTo4326: ReprojectionFns["forwardReproject"], ) { - const { _antimeridianCut, _westReprojection, _eastReprojection } = tile; + const { _antimeridianCut, _westReprojection } = tile; // A crossing tile renders as two `RasterLayer`s in different world // copies (see `RasterTileLayer._renderAntimeridianTile`) — the debug // outline needs to match, in the same common-space coordinate system, // rather than drawing one WGS84 box whose corners (e.g. lng 179.97° and // −179.17°) `PathLayer` would connect the long way around the globe. - if (_antimeridianCut && _westReprojection && _eastReprojection) { + if (_antimeridianCut && _westReprojection) { + // Both pieces share the same (piece-agnostic) reprojection bundle — see + // `RasterTileset2D.buildPieceReprojection`. + const { forwardReproject } = _westReprojection; const { uCut } = _antimeridianCut; const { tileWidth, tileHeight, forwardTransform } = tile; const cutPx = uCut * tileWidth; const westPath = pieceBoxPath( forwardTransform, - _westReprojection.forwardReproject, + forwardReproject, 0, cutPx, tileHeight, ); const eastPath = pieceBoxPath( forwardTransform, - _eastReprojection.forwardReproject, + forwardReproject, cutPx, tileWidth, tileHeight, diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index 3e22ddf5..c669294f 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -87,11 +87,8 @@ export function antimeridianCut( ): AntimeridianCut | undefined { const { topLeft, topRight, bottomLeft, bottomRight } = cornerLngs; - console.log("[antimeridianCut] corners:", cornerLngs); - const topUCut = edgeUCut(topLeft, topRight); const bottomUCut = edgeUCut(bottomLeft, bottomRight); - console.log("[antimeridianCut] topUCut:", topUCut, "bottomUCut:", bottomUCut); if (topUCut === undefined || bottomUCut === undefined) { return undefined; } @@ -101,6 +98,5 @@ export function antimeridianCut( if (Math.abs(topUCut - bottomUCut) > U_EPSILON) { return undefined; } - console.log("[antimeridianCut] uCut:", (topUCut + bottomUCut) / 2); return { uCut: (topUCut + bottomUCut) / 2 }; } 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 dd743027..b909de09 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 @@ -443,15 +443,12 @@ export class RasterTileset2D extends Tileset2D { }); // Each piece of a crossing tile needs its `forwardReproject` output // (common space) corrected per-point, not by one constant shift — see - // `buildPieceReprojection` for why. + // `buildPieceReprojection` for why. The correction is piece-agnostic, so + // both pieces share the same built bundle. let _westReprojection: ReprojectionFns | undefined; let _eastReprojection: ReprojectionFns | undefined; if (_antimeridianCut) { - _westReprojection = this.buildPieceReprojection( - forwardTransform, - inverseTransform, - ); - _eastReprojection = this.buildPieceReprojection( + _westReprojection = _eastReprojection = this.buildPieceReprojection( forwardTransform, inverseTransform, ); From 00511c350178e745d8220fb79fd99b1678a3ed4d Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 14:29:09 +1000 Subject: [PATCH 05/21] feat(examples): read GeoMAD test items from the STAC-geoparquet; simplify render-gray; fix stale-index crash in MosaicTileset2D --- examples/_shared/index.ts | 2 + examples/_shared/package.json | 2 + examples/_shared/stac-geoparquet.ts | 48 +++++++++++++++++ examples/antimeridian-example/README.md | 6 +-- examples/antimeridian-example/src/App.tsx | 29 +++++++---- .../antimeridian-example/src/render-gray.ts | 52 ++++++------------- .../README.md | 4 +- .../src/App.tsx | 2 +- .../src/data.ts | 49 ++++++----------- .../antimeridian-mosaic-multi-example/todo.md | 4 -- .../src/mosaic-layer/mosaic-tileset-2d.ts | 21 ++++++-- .../tests/mosaic-tileset-2d.test.ts | 18 +++++++ pnpm-lock.yaml | 24 +++++++++ 13 files changed, 166 insertions(+), 95 deletions(-) create mode 100644 examples/_shared/stac-geoparquet.ts delete mode 100644 examples/antimeridian-mosaic-multi-example/todo.md diff --git a/examples/_shared/index.ts b/examples/_shared/index.ts index 7236f82f..aaa09b66 100644 --- a/examples/_shared/index.ts +++ b/examples/_shared/index.ts @@ -17,4 +17,6 @@ export { ExampleProvider } from "./components/provider.js"; export type { RangeSliderProps } from "./components/range-slider.js"; export { RangeSlider } from "./components/range-slider.js"; export { UIOverlay } from "./components/ui-overlay.js"; +export type { StacAssets, StacGeoparquetItem } from "./stac-geoparquet.js"; +export { fetchStacGeoparquetItems } from "./stac-geoparquet.js"; export { system } from "./styles/theme.js"; diff --git a/examples/_shared/package.json b/examples/_shared/package.json index 9c36aa6e..7ed20e1c 100644 --- a/examples/_shared/package.json +++ b/examples/_shared/package.json @@ -16,6 +16,8 @@ "@chakra-ui/react": "^3.34.0", "@deck.gl/mapbox": "^9.3.2", "@devseed-ui/collecticons-chakra": "^4.0.0", + "hyparquet": "^1.31.2", + "hyparquet-compressors": "^1.1.2", "maplibre-gl": "^5.24.0", "react": "^19.2.5", "react-map-gl": "^8.1.1" diff --git a/examples/_shared/stac-geoparquet.ts b/examples/_shared/stac-geoparquet.ts new file mode 100644 index 00000000..6c2e516a --- /dev/null +++ b/examples/_shared/stac-geoparquet.ts @@ -0,0 +1,48 @@ +import { asyncBufferFromUrl, parquetReadObjects } from "hyparquet"; +import { compressors } from "hyparquet-compressors"; + +/** A STAC item's `assets` entries, keyed by asset name. */ +export type StacAssets = Record; + +/** Minimal STAC item shape read from a STAC-geoparquet file. */ +export type StacGeoparquetItem = { + id: string; + /** `[minX, minY, maxX, maxY]`, as stored — not unwrapped for a crossing item. */ + bbox: [number, number, number, number]; + assets: StacAssets; +}; + +/** + * Read `id`, `bbox`, and `assets` for every item in a STAC-geoparquet file, + * fetched directly in the browser (no server-side indexing step) via + * `hyparquet` — a pure-JS, zero-dependency Parquet reader that uses HTTP + * range requests, so only the 3 needed columns are actually downloaded. + * + * `bbox` here is the geoparquet `STRUCT(xmin, ymin, xmax, ymax)` column + * as-is — an antimeridian-crossing item's bbox is GeoJSON-flipped (RFC 7946 + * §5.2: xmin > xmax), same as a STAC item's own JSON `bbox`. Callers that + * need a continuous frame (e.g. for a Flatbush spatial index) must unwrap it + * themselves. + */ +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-example/README.md b/examples/antimeridian-example/README.md index 5d18c2fa..e8a5ec00 100644 --- a/examples/antimeridian-example/README.md +++ b/examples/antimeridian-example/README.md @@ -1,9 +1,9 @@ # Antimeridian Crossing Example -Renders two `COGLayer`s from the same [Digital Earth Pacific](https://digitalearthpacific.org) Landsat GeoMAD catalog (EPSG:3832 / PDC Mercator), side by side, as-is: +Renders every `COGLayer` in the [Digital Earth Pacific](https://digitalearthpacific.org) Landsat GeoMAD test catalog (EPSG:3832 / PDC Mercator) — a 4×3 grid of 12 tiles, read live from the catalog's STAC-geoparquet — side by side, as-is: -- `dep_ls_geomad_066_022_2025` — crosses the ±180° antimeridian. Its corner longitudes come out GeoJSON-flipped ([RFC 7946 §5.2](https://datatracker.ietf.org/doc/html/rfc7946#section-5.2): west > east, e.g. `179.97 → −179.17`), since `proj4`'s inverse projection normalizes output to `(−180°, 180°]`. -- `dep_ls_geomad_064_020_2025` — same catalog, doesn't cross — for comparison. +- The 3 tiles at column `066` (`dep_ls_geomad_066_020/021/022_2025`) cross the ±180° antimeridian. Their corner longitudes come out GeoJSON-flipped ([RFC 7946 §5.2](https://datatracker.ietf.org/doc/html/rfc7946#section-5.2): west > east, e.g. `179.97 → −179.17`), since `proj4`'s inverse projection normalizes output to `(−180°, 180°]`. +- The other 9 tiles, same catalog, don't cross — for comparison. See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575) for the antimeridian-handling design. diff --git a/examples/antimeridian-example/src/App.tsx b/examples/antimeridian-example/src/App.tsx index 21b54923..30ad36f8 100644 --- a/examples/antimeridian-example/src/App.tsx +++ b/examples/antimeridian-example/src/App.tsx @@ -7,28 +7,34 @@ import { DebugControls, DeckGlOverlay, ExternalLink, + fetchStacGeoparquetItems, } from "deck.gl-raster-examples-shared"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; import { getTileDataGray, renderGrayWhiteToBlack } from "./render-gray.js"; -// Two items from the same DEP Landsat GeoMAD catalog (EPSG:3832 / PDC +// Every item in the DEP Landsat GeoMAD test catalog (EPSG:3832 / PDC // Mercator), shown together as they are — no reprojection-hiding fitBounds. -// `066_022` crosses the antimeridian (GeoJSON-flipped corner lngs: xmin -// 179.97 → xmax −179.17); `064_020` doesn't, for comparison. -const CROSSING_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"; -const NON_CROSSING_URL = - "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/064/020/2025/dep_ls_geomad_064_020_2025_red.tif"; +// The 3 items at column `066` cross the antimeridian (GeoJSON-flipped corner +// lngs: xmin 179.97 → xmax −179.17); the other 9 don't, for comparison. +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 default function App() { + const [redBandUrls, setRedBandUrls] = useState([]); const [debugState, setDebugState] = useState({ debug: true, debugOpacity: 0.25, }); - const layers = [CROSSING_URL, NON_CROSSING_URL].map( + useEffect(() => { + fetchStacGeoparquetItems(PARQUET_URL).then((items) => + setRedBandUrls(items.map((item) => item.assets.red!.href)), + ); + }, []); + + const layers = redBandUrls.map( (url) => new COGLayer({ id: `cog-layer-${url}`, @@ -79,8 +85,9 @@ export default function App() { sourcePath="examples/antimeridian-example" > - Two COGs from - the same catalog: one crosses the ±180° antimeridian, one doesn't — + All {redBandUrls.length || 12}{" "} + COGs in the + test catalog: 3 cross the ±180° antimeridian, the other 9 don't — see{" "} #575 diff --git a/examples/antimeridian-example/src/render-gray.ts b/examples/antimeridian-example/src/render-gray.ts index f7893132..f7a230f4 100644 --- a/examples/antimeridian-example/src/render-gray.ts +++ b/examples/antimeridian-example/src/render-gray.ts @@ -9,40 +9,27 @@ import { texture } from "@developmentseed/deck.gl-geotiff"; import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; import type { Texture } from "@luma.gl/core"; -/** Texture payload for a 1-band grayscale tile, plus its own contrast-stretch range. */ +/** Texture payload for a 1-band grayscale tile. */ export type GrayTileData = { texture: Texture; width: number; height: number; - /** Normalized [0, 1] — inner-90% value range, per-tile. */ - rescaleMin: number; - rescaleMax: number; }; -/** r16unorm normalizes the raw uint16 sample by this before the shader sees it. */ -const UINT16_MAX = 65535; - -/** - * Inner-90% value range (5th/95th percentile), ignoring `nodata` (0 for this - * dataset). A simple per-tile auto-contrast-stretch — good enough for a demo, - * not a substitute for a real histogram-based stretch across the whole COG. - */ -function inner90Range(data: ArrayLike): [number, number] { - const values = Array.from(data).filter((v) => v !== 0); - if (values.length === 0) { - return [0, UINT16_MAX]; - } - values.sort((a, b) => a - b); - const lo = values[Math.floor(values.length * 0.05)]!; - const hi = values[Math.ceil(values.length * 0.95) - 1]!; - return [lo, hi]; -} +// DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees +// rawDN / 65535), so the display range needs the same division. Same fixed +// range as antimeridian-mosaic-multi-example's own tuned stretch for the +// same product line; its red-band raster:bands stats across all 12 test +// items cluster around a mean of ~7300–7900, so one fixed range works fine +// everywhere — no need for a per-tile percentile stretch. +const RESCALE_MIN = 7200 / 65535; +const RESCALE_MAX = 12000 / 65535; /** * Tile loader for the 1-band (16-bit unsigned) DEP GeoMAD bands used in this - * example. Both COGs here have no overviews, so `image` is always the base - * `GeoTIFF`; `texture.createTextureProps` infers the right WebGL format from - * its tags (`r16unorm` for this data) rather than assuming 8-bit. + * example. `texture.createTextureProps` infers the right WebGL format from + * the GeoTIFF's own tags (`r16unorm` for this data) rather than assuming + * 8-bit. */ export async function getTileDataGray( image: GeoTIFF | Overview, @@ -59,22 +46,13 @@ export async function getTileDataGray( width, height, }); - const [lo, hi] = inner90Range(data); - return { - texture: device.createTexture(props), - width, - height, - rescaleMin: lo / UINT16_MAX, - rescaleMax: hi / UINT16_MAX, - }; + return { texture: device.createTexture(props), width, height }; } /** * Render pipeline showing raw value as white (low) → black (high) — * `WhiteIsZero`, matching TIFF `PhotometricInterpretation = 0` regardless of - * this data's own tag (`= 1`, BlackIsZero). `LinearRescale` runs first, using - * this tile's own inner-90% range, so `WhiteIsZero` sees an already-stretched - * [0, 1] value. + * this data's own tag (`= 1`, BlackIsZero). */ export function renderGrayWhiteToBlack(data: GrayTileData): RenderTileResult { return { @@ -82,7 +60,7 @@ export function renderGrayWhiteToBlack(data: GrayTileData): RenderTileResult { { module: CreateTexture, props: { textureName: data.texture } }, { module: LinearRescale, - props: { rescaleMin: data.rescaleMin, rescaleMax: data.rescaleMax }, + props: { rescaleMin: RESCALE_MIN, rescaleMax: RESCALE_MAX }, }, { module: WhiteIsZero }, ], diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md index f8892531..edea3b87 100644 --- a/examples/antimeridian-mosaic-multi-example/README.md +++ b/examples/antimeridian-mosaic-multi-example/README.md @@ -1,8 +1,8 @@ # Antimeridian: Mosaic + MultiCOG Example -Same two DEP GeoMAD items as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` — the shape real usage actually needs. See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). +Same DEP GeoMAD test catalog as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` — the shape real usage actually needs. See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). -`src/data.ts` fetches each item's own STAC item JSON (rather than hardcoding bbox/asset URLs) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. +`src/data.ts` reads every item directly from the catalog's STAC-geoparquet (via `hyparquet`, no server-side indexing step) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. The debug overlay toggle exercises `MultiCOGLayer`'s own tile-outline rendering (`debug`/`debugOpacity`/`debugLevel`), confirming it also knows about the antimeridian mesh split. diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index 799d2b0f..6760f9c9 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -94,7 +94,7 @@ export default function App() { sourcePath="examples/antimeridian-mosaic-multi-example" > - Same two DEP GeoMAD items as{" "} + Same {geomadItems.length || 12} DEP GeoMAD items as{" "} antimeridian-example diff --git a/examples/antimeridian-mosaic-multi-example/src/data.ts b/examples/antimeridian-mosaic-multi-example/src/data.ts index e0f6c8d5..22bb19a7 100644 --- a/examples/antimeridian-mosaic-multi-example/src/data.ts +++ b/examples/antimeridian-mosaic-multi-example/src/data.ts @@ -1,3 +1,5 @@ +import { fetchStacGeoparquetItems } from "deck.gl-raster-examples-shared"; + /** Minimal per-item shape this example needs — id, bbox, and R/G/B asset URLs. */ export type GeomadItem = { id: string; @@ -5,24 +7,13 @@ export type GeomadItem = { assets: { red: string; green: string; blue: string }; }; -const BASE = - "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test"; - -// Two items from the same DEP Landsat GeoMAD catalog: `064/020` doesn't cross -// the antimeridian, `066/022` does. Fetched from each item's own STAC item -// JSON below rather than hardcoding bbox/asset URLs. -const ITEM_PATHS = [ - "064/020/2025/dep_ls_geomad_064_020_2025", - "066/022/2025/dep_ls_geomad_066_022_2025", -]; - -type StacItem = { - bbox: [number, number, number, number]; - assets: Record; -}; +const PARQUET_URL = + "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet"; /** - * Fetch each item's STAC item JSON and pull out just what this example needs. + * Fetch every item in the DEP Landsat GeoMAD test catalog (a 4×3 grid, 12 + * items) from its STAC-geoparquet and pull out just what this example needs. + * The 3 items at column `066` cross the antimeridian; the other 9 don't. * * The STAC bbox for a crossing item is GeoJSON-flipped (RFC 7946 §5.2: crosses * ±180° → xmin > xmax, e.g. `066/022`'s `179.97, -179.17`). MosaicLayer's @@ -32,20 +23,14 @@ type StacItem = { * `unwrapEastLng`): xmax = −179.169819 + 360 = 180.830181. */ export async function fetchGeomadItems(): Promise { - return Promise.all( - ITEM_PATHS.map(async (path) => { - const res = await fetch(`${BASE}/${path}.stac-item.json`); - const item: StacItem = await res.json(); - const [minX, minY, maxX, maxY] = item.bbox; - return { - id: path.split("/").pop()!, - bbox: [minX, minY, maxX < minX ? maxX + 360 : maxX, maxY], - assets: { - red: item.assets.red!.href, - green: item.assets.green!.href, - blue: item.assets.blue!.href, - }, - }; - }), - ); + const items = await fetchStacGeoparquetItems(PARQUET_URL); + return items.map(({ id, bbox: [minX, minY, maxX, maxY], assets }) => ({ + id, + bbox: [minX, minY, maxX < minX ? maxX + 360 : maxX, maxY], + assets: { + red: assets.red!.href, + green: assets.green!.href, + blue: assets.blue!.href, + }, + })); } diff --git a/examples/antimeridian-mosaic-multi-example/todo.md b/examples/antimeridian-mosaic-multi-example/todo.md deleted file mode 100644 index d6ab5dd5..00000000 --- a/examples/antimeridian-mosaic-multi-example/todo.md +++ /dev/null @@ -1,4 +0,0 @@ -I also want to make a mutant version to test all of the fixes: -- https://github.com/willjnz/deck.gl-raster/tree/willjnz/antimeridian-crossing-fixes -- https://github.com/developmentseed/deck.gl-raster/pull/640 -- https://github.com/developmentseed/deck.gl-raster/pull/639 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 d8fc7795..cecb2851 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 @@ -137,10 +137,21 @@ export class MosaicTileset2D extends Tileset2D { } } + // `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 = Array.from(matchedIndices).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, @@ -148,8 +159,8 @@ export class MosaicTileset2D extends Tileset2D { z: 0, ...source, id: source.id ?? String(sourceIndex), - }; - }); + }); + } const { maxRequests } = this.opts; if (selectedSources.length <= maxRequests) { 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 f6d5afa1..aa4fdd17 100644 --- a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts +++ b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts @@ -159,6 +159,24 @@ describe("MosaicTileset2D world-copy passes", () => { }); }); +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/pnpm-lock.yaml b/pnpm-lock.yaml index 80f28d51..165c2e43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,12 @@ importers: '@devseed-ui/collecticons-chakra': specifier: ^4.0.0 version: 4.0.0(@chakra-ui/react@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))(@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) + 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 @@ -6404,10 +6410,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'} @@ -16371,8 +16386,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 From fde497e615799b07f0f02f640d829a579cbd2c6f Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 14:39:30 +1000 Subject: [PATCH 06/21] refactor(antimeridian-example): simplify render-gray to a plain linear ramp Drop WhiteIsZero (color inversion, not needed for this example) and the texture-format-inference indirection (hardcode the known r16unorm format). Swap in BlackIsZero, which still does the required single-band -> RGB broadcast so the image doesn't render red-tinted, but leaves values un-inverted: dark = low, bright = high. --- examples/antimeridian-example/src/App.tsx | 8 ++--- .../antimeridian-example/src/render-gray.ts | 35 ++++++++----------- .../src/App.tsx | 4 +-- 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/examples/antimeridian-example/src/App.tsx b/examples/antimeridian-example/src/App.tsx index 30ad36f8..debcf18e 100644 --- a/examples/antimeridian-example/src/App.tsx +++ b/examples/antimeridian-example/src/App.tsx @@ -12,7 +12,7 @@ import { import "maplibre-gl/dist/maplibre-gl.css"; import { useEffect, useState } from "react"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; -import { getTileDataGray, renderGrayWhiteToBlack } from "./render-gray.js"; +import { getTileDataGray, renderGray } from "./render-gray.js"; // Every item in the DEP Landsat GeoMAD test catalog (EPSG:3832 / PDC // Mercator), shown together as they are — no reprojection-hiding fitBounds. @@ -40,7 +40,7 @@ export default function App() { id: `cog-layer-${url}`, geotiff: url, getTileData: getTileDataGray, - renderTile: renderGrayWhiteToBlack, + renderTile: renderGray, debug: debugState.debug, debugOpacity: debugState.debugOpacity, // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps @@ -55,8 +55,8 @@ export default function App() { id: "antimeridian-line", data: [ [ - [180, 80], - [180, -80], + [180, 85], + [180, -85], ], ], getPath: (d: [number, number][]) => d, diff --git a/examples/antimeridian-example/src/render-gray.ts b/examples/antimeridian-example/src/render-gray.ts index f7a230f4..be6f6708 100644 --- a/examples/antimeridian-example/src/render-gray.ts +++ b/examples/antimeridian-example/src/render-gray.ts @@ -1,11 +1,10 @@ import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; import { + BlackIsZero, CreateTexture, LinearRescale, - WhiteIsZero, } from "@developmentseed/deck.gl-raster/gpu-modules"; import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; -import { texture } from "@developmentseed/deck.gl-geotiff"; import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; import type { Texture } from "@luma.gl/core"; @@ -17,20 +16,13 @@ export type GrayTileData = { }; // DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees -// rawDN / 65535), so the display range needs the same division. Same fixed -// range as antimeridian-mosaic-multi-example's own tuned stretch for the -// same product line; its red-band raster:bands stats across all 12 test -// items cluster around a mean of ~7300–7900, so one fixed range works fine -// everywhere — no need for a per-tile percentile stretch. +// rawDN / 65535), so the display range needs the same division. Not tuned +// per-tile — this example is about antimeridian correctness, not radiometric +// accuracy, so a simple fixed linear ramp is enough. const RESCALE_MIN = 7200 / 65535; const RESCALE_MAX = 12000 / 65535; -/** - * Tile loader for the 1-band (16-bit unsigned) DEP GeoMAD bands used in this - * example. `texture.createTextureProps` infers the right WebGL format from - * the GeoTIFF's own tags (`r16unorm` for this data) rather than assuming - * 8-bit. - */ +/** Tile loader for the 1-band (16-bit unsigned) DEP GeoMAD bands used in this example. */ export async function getTileDataGray( image: GeoTIFF | Overview, options: GetTileDataOptions, @@ -42,19 +34,22 @@ export async function getTileDataGray( throw new Error("Expected a pixel-interleaved (1-band) COG"); } const { width, height, data } = array; - const props = texture.createTextureProps(image as GeoTIFF, data, { + const texture = device.createTexture({ + data, + format: "r16unorm", width, height, }); - return { texture: device.createTexture(props), width, height }; + return { texture, width, height }; } /** - * Render pipeline showing raw value as white (low) → black (high) — - * `WhiteIsZero`, matching TIFF `PhotometricInterpretation = 0` regardless of - * this data's own tag (`= 1`, BlackIsZero). + * Render pipeline: a plain linear ramp — dark = low value, bright = high + * value. `BlackIsZero` broadcasts the single band into RGB so it renders as + * grayscale instead of red-tinted (a bare `r16unorm` texture only has data + * in the red channel). */ -export function renderGrayWhiteToBlack(data: GrayTileData): RenderTileResult { +export function renderGray(data: GrayTileData): RenderTileResult { return { renderPipeline: [ { module: CreateTexture, props: { textureName: data.texture } }, @@ -62,7 +57,7 @@ export function renderGrayWhiteToBlack(data: GrayTileData): RenderTileResult { module: LinearRescale, props: { rescaleMin: RESCALE_MIN, rescaleMax: RESCALE_MAX }, }, - { module: WhiteIsZero }, + { module: BlackIsZero }, ], }; } diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index 6760f9c9..c2ad3844 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -64,8 +64,8 @@ export default function App() { id: "antimeridian-line", data: [ [ - [180, 80], - [180, -80], + [180, 85], + [180, -85], ], ], getPath: (d: [number, number][]) => d, From 64d35c7841124c81deb579a1f105a051908af769 Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 14:54:50 +1000 Subject: [PATCH 07/21] Align with my earlier PR#639 that renders in world copies --- .../README.md | 2 +- .../src/App.tsx | 8 ++- .../src/mosaic-layer/mosaic-tileset-2d.ts | 54 +++++++++++++------ plan.md | 28 ---------- 4 files changed, 43 insertions(+), 49 deletions(-) delete mode 100644 plan.md diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md index edea3b87..94e91c93 100644 --- a/examples/antimeridian-mosaic-multi-example/README.md +++ b/examples/antimeridian-mosaic-multi-example/README.md @@ -1,6 +1,6 @@ # Antimeridian: Mosaic + MultiCOG Example -Same DEP GeoMAD test catalog as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` — the shape real usage actually needs. See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). +Same DEP GeoMAD test catalog as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` (the needed use pattern). See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). `src/data.ts` reads every item directly from the catalog's STAC-geoparquet (via `hyparquet`, no server-side indexing step) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index c2ad3844..710d664d 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -16,10 +16,8 @@ import type { GeomadItem } from "./data.js"; import { fetchGeomadItems } from "./data.js"; // DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees -// rawDN / 65535), so the display range needs the same division. This -// item's own STAC raster:bands stats put the red band in [7014, 16807] -// (mean 7682); 7200–12000 matches the reference app's own tuned stretch for -// the same product line. +// rawDN / 65535), so the display range needs the same division. One +// item's STAC raster:bands stats state the red band range as [7014, 16807] const RESCALE_MIN = 7200 / 65535; const RESCALE_MAX = 12000 / 65535; @@ -99,7 +97,7 @@ export default function App() { antimeridian-example , but composed as R/G/B through MosaicLayer +{" "} - MultiCOGLayer — the shape real usage actually needs, + MultiCOGLayer (the needed use pattern), not just a single-band COGLayer. See{" "} #575 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 cecb2851..229037fd 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 @@ -47,7 +47,7 @@ export type MosaicSource = { type ResolvedSource = TileIndex & MosaicT & { id: string }; /** Matches raster-tile-traversal.ts's world-copy pass count. */ -const MAX_MAP_COPIES = 3; +const MAX_MAPS = 3; export class MosaicTileset2D extends Tileset2D { /** Closure returning the parent layer's current sources array. Re-evaluated @@ -111,8 +111,8 @@ export class MosaicTileset2D extends Tileset2D { return []; } - const [minX, minY, maxX, maxY] = viewport.getBounds(); - const matchedIndices = new Set(index.search(minX, minY, maxX, maxY)); + const viewportBounds = viewport.getBounds(); + const matchedIndices = new Set(index.search(...viewportBounds)); // World-copy passes: when the viewport spans multiple world copies (e.g. // WebMercatorViewport with repeat: true panned across the antimeridian), @@ -120,19 +120,17 @@ export class MosaicTileset2D extends Tileset2D { // source's bbox may only be indexed in another copy's range. Re-query the // index with the bounds shifted by ±360°, ±720°… — same idea as // raster-tile-traversal.ts's per-offset frustum passes, just in lng/lat - // instead of common-space pixels. See dev-docs/world-copies.md. + // instead of common-space pixels. Walk each direction until an offset + // comes back empty — see dev-docs/world-copies.md. if ((viewport.subViewports?.length ?? 0) > 1) { - for (let n = 1; n <= MAX_MAP_COPIES; n++) { - for (const offset of [-n, n]) { - const shift = offset * 360; - for (const i of index.search( - minX + shift, - minY, - maxX + shift, - maxY, - )) { - matchedIndices.add(i); - } + 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; } } } @@ -177,3 +175,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/plan.md b/plan.md deleted file mode 100644 index c2083f5b..00000000 --- a/plan.md +++ /dev/null @@ -1,28 +0,0 @@ -# Antimeridian crossing: what it took (summary) - -Five real bugs. The first three are in `packages/deck.gl-raster/src/raster-tileset/`, on top of PR #576's existing split-in-two design (`antimeridian-cut.ts`'s `uCut` + two `RasterLayer` pieces). The last two are in `packages/deck.gl-geotiff/src/mosaic-layer/` and `.../multi-cog-layer.ts`, and only showed up once a crossing dataset was used through `MosaicLayer` + `MultiCOGLayer` instead of a plain `COGLayer` — a second, independent debug-outline implementation living outside `raster-tileset/` had the same class of bug as #3, just not caught until real usage exercised it. - -## Fixed - -1. **Crossing detection missed projected (non-4326) sources.** `edgeUCut` (`antimeridian-cut.ts`) only accepted "native un-normalized" corner lngs (`west < east` always, e.g. `−204°, −162°` — true for a 4326 source). A projected source (e.g. EPSG:3832) makes `projectTo4326` normalize its output to `(−180°, 180°]`, so a crossing tile shows up **GeoJSON-flipped** instead (`west > east`, e.g. `179.97°, −179.17°` — RFC 7946 §5.2's own convention). Fix: `unwrapEastLng` adds 360° when `east < west` before the seam search. Needed for any real-world projected-CRS COG, not just STAC's bbox convention — it's `proj4` normalization, unrelated to STAC. - -2. **Piece reprojection shifted the wrong stage.** `buildPieceReprojection` used to add a `+k·360°` shift to `forwardTransform`'s *output* (source-CRS native units). That's correct only when native units are degrees (the 4326 case); for a projected CRS (meters), it silently added `360 meters`, and — more subtly — a single constant-per-piece shift is simply wrong at the piece's own boundary corner, which coincides with the *other* piece's edge and needs no shift at all. Fix: correct `forwardReproject`'s *output* instead (common space, post-projection — where the discontinuity actually lives), per-point: `raw x < TILE_SIZE/2 → +TILE_SIZE`, else unchanged. No longer needs to know which piece it is or any precomputed midpoint. - -3. **Debug outline didn't know about the split.** `renderDebugTileOutline` drew one box from the tile's raw (un-split) WGS84 corners — for a crossing tile, `PathLayer` connected `179.97° → −179.17°` the long way around the globe. Fixed to draw two boxes (one per piece) in common space, using each piece's own `forwardReproject`, matching the real render split. - -4. **`MosaicLayer`'s spatial index missed sources in the "other" world copy.** `MosaicTileset2D.getTileIndices` queried its Flatbush index once, with `viewport.getBounds()`'s raw lng/lat bounds. When the view spans the antimeridian, deck.gl renders multiple world-copy sub-viewports (`viewport.subViewports`), and each reports bounds for a different 360°-shifted copy of the world — but every source's bbox is indexed in only one of those copies (e.g. the crossing item's unwrapped `[179.97°, 180.83°]` bbox). A query landing in the wrong copy (e.g. `[-189.6°, -170.4°]`) found nothing, so the source dropped out of the tileset entirely — no tiles, no `MultiCOGLayer`, blank map. Fixed by re-querying the index with the bounds shifted by ±360°, ±720°, ±1080° whenever `subViewports.length > 1`, unioning the results (deduping via a `Set`) — the same per-offset-pass idea `raster-tile-traversal.ts` already uses for XYZ tile frustum culling, just in lng/lat degrees instead of common-space pixels. - -5. **`MultiCOGLayer` has its own separate debug-outline renderer, and it had the same "one unsplit box" bug as #3.** `_renderDebugLayers` (`multi-cog-layer.ts`) draws its own primary-tile outline independently of `renderDebugTileOutline` — it isn't a thin wrapper, so fix #3 never reached it. It projected the tile's raw corners to WGS84 and connected them as one box; for a crossing tile the red outline connected `179.97°` to `−179.17°` directly — not "the long way around" like #3, but still wrong: `projectTo4326` normalizes the shared seam corner to `+180°` for *both* pieces, so the west piece's box (`179.97°..180°`) came out fine but the east piece's (`180°..−179.17°`) still read as a ~359° span instead of the real ~0.83° gap — a red bar "belting" the whole visible map. Fixed by splitting at the same `uCut` pixel (mirroring #3's `pieceBoxPath`, stopping at WGS84 instead of continuing to common space), then unwrapping every corner in a piece by ±360° relative to its own first corner before handing the path to `PathLayer`. - -## Known gap (not yet fixed, currently dormant) - -`getTileMetadata`'s per-tile `bbox` and the constructor's dataset-level `wgs84Bounds` still call `transformBounds` **once over the whole (unsplit) tile/dataset** — for a genuinely crossing tile this still computes a bogus ~360°-wide box. It hasn't broken anything in `antimeridian-example` because that box only gates tile *selection*, and a single-tile (1×1 grid) dataset's small-dataset path in `getTileIndices` bypasses that check entirely. A multi-tile crossing mosaic would hit this. Proper fix: split `projectedBounds` at the same `uCut`-derived source-CRS x, reproject each piece with its own (now-fixed) `forwardReproject`, and union — mirrors what the mesh/debug-outline fixes already do, just not implemented for these two call sites yet. - -## Verified - -- Real-data isolated test (`GeoTIFF.fromUrl` → `geoTiffToDescriptor`, no STAC) against the actual DEP GeoMAD antimeridian item. -- `examples/antimeridian-example`: renders both pieces with real pixel data, correct debug-overlay boxes, no more "belting the earth." -- `examples/antimeridian-mosaic-multi-example`: same two DEP GeoMAD items composited R/G/B through `MosaicLayer` + `MultiCOGLayer` (the shape real usage actually needs) instead of a single-band `COGLayer`; renders both items with a continuous, untorn coastline straddling the antimeridian reference line. -- 151/151 unit tests pass (`packages/deck.gl-raster`), including new coverage for the per-point threshold fix and its exact-seam-corner regression case. -- 27/27 unit tests pass (`packages/deck.gl-geotiff`), including new coverage for the mosaic world-copy fix (shifted-copy lookup, no-op when not needed, dedup across passes) and the `MultiCOGLayer` debug-outline unwrap fix. -- `examples/antimeridian-mosaic-multi-example`'s debug overlay toggle confirmed live in-browser: crossing tile draws as a small box straddling the antimeridian line, not a bar spanning the visible map. From b642329d0ab8ffd38a63f5457599469cf00a770f Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 15:46:49 +1000 Subject: [PATCH 08/21] Refactor unwrapping for AM-crossing --- .../README.md | 2 +- .../src/data.ts | 12 ++++----- .../src/mosaic-layer/mosaic-layer.ts | 24 ++++++++++++++--- .../tests/mosaic-layer.test.ts | 26 +++++++++++++++++-- packages/deck.gl-raster/src/index.ts | 1 + .../src/raster-tileset/index.ts | 1 + 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md index 94e91c93..ecc0a61c 100644 --- a/examples/antimeridian-mosaic-multi-example/README.md +++ b/examples/antimeridian-mosaic-multi-example/README.md @@ -2,7 +2,7 @@ Same DEP GeoMAD test catalog as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` (the needed use pattern). See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). -`src/data.ts` reads every item directly from the catalog's STAC-geoparquet (via `hyparquet`, no server-side indexing step) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a plain Flatbush R-tree with no antimeridian awareness — a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2) for the crossing item is unwrapped onto a continuous frame before being passed in, the same convention `antimeridian-cut.ts`'s `unwrapEastLng` uses. +`src/data.ts` reads every item directly from the catalog's STAC-geoparquet (via `hyparquet`, no server-side indexing step) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a Flatbush R-tree, unwrapping a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2 — the crossing item's encoding) onto a continuous frame internally before indexing. The debug overlay toggle exercises `MultiCOGLayer`'s own tile-outline rendering (`debug`/`debugOpacity`/`debugLevel`), confirming it also knows about the antimeridian mesh split. diff --git a/examples/antimeridian-mosaic-multi-example/src/data.ts b/examples/antimeridian-mosaic-multi-example/src/data.ts index 22bb19a7..7c495b97 100644 --- a/examples/antimeridian-mosaic-multi-example/src/data.ts +++ b/examples/antimeridian-mosaic-multi-example/src/data.ts @@ -16,17 +16,15 @@ const PARQUET_URL = * The 3 items at column `066` cross the antimeridian; the other 9 don't. * * The STAC bbox for a crossing item is GeoJSON-flipped (RFC 7946 §5.2: crosses - * ±180° → xmin > xmax, e.g. `066/022`'s `179.97, -179.17`). MosaicLayer's - * spatial index (Flatbush) is a plain numeric R-tree with no antimeridian - * awareness — a flipped bbox doesn't mean anything to it — so unwrap onto a - * continuous frame instead (same convention as antimeridian-cut.ts's - * `unwrapEastLng`): xmax = −179.169819 + 360 = 180.830181. + * ±180° → xmin > xmax, e.g. `066/022`'s `179.97, -179.17`) — passed straight + * through here; `MosaicLayer` unwraps a flipped bbox onto a continuous frame + * internally. */ export async function fetchGeomadItems(): Promise { const items = await fetchStacGeoparquetItems(PARQUET_URL); - return items.map(({ id, bbox: [minX, minY, maxX, maxY], assets }) => ({ + return items.map(({ id, bbox, assets }) => ({ id, - bbox: [minX, minY, maxX < minX ? maxX + 360 : maxX, maxY], + bbox, assets: { red: assets.red!.href, green: assets.green!.href, 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..9e83dbba 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -13,6 +13,7 @@ import { } from "@deck.gl/core"; import type { TileLayerProps } from "@deck.gl/geo-layers"; import { TileLayer } from "@deck.gl/geo-layers"; +import { _unwrapEastLng as unwrapEastLng } from "@developmentseed/deck.gl-raster"; import type { ConcurrencyLimiter, Priority } from "@developmentseed/geotiff"; import Flatbush from "flatbush"; import { DEFAULT_CONCURRENCY_LIMITER } from "../default-concurrency-limiter.js"; @@ -127,6 +128,18 @@ 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; + return { ...source, bbox: [minX, minY, unwrapEastLng(minX, maxX), 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/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-raster/src/index.ts b/packages/deck.gl-raster/src/index.ts index 4bf51c25..b9552696 100644 --- a/packages/deck.gl-raster/src/index.ts +++ b/packages/deck.gl-raster/src/index.ts @@ -39,4 +39,5 @@ export { // Not a public export, but we want to share across modules sortItemsByDistanceFromViewportCenter as _sortItemsByDistanceFromViewportCenter, TileMatrixSetAdaptor, + unwrapEastLng as _unwrapEastLng, } from "./raster-tileset/index.js"; diff --git a/packages/deck.gl-raster/src/raster-tileset/index.ts b/packages/deck.gl-raster/src/raster-tileset/index.ts index 4f768cc2..c09e1146 100644 --- a/packages/deck.gl-raster/src/raster-tileset/index.ts +++ b/packages/deck.gl-raster/src/raster-tileset/index.ts @@ -2,6 +2,7 @@ export type { AffineTilesetOptions } from "./affine-tileset.js"; export { AffineTileset } from "./affine-tileset.js"; export type { AffineTilesetLevelOptions } from "./affine-tileset-level.js"; export { AffineTilesetLevel } from "./affine-tileset-level.js"; +export { unwrapEastLng } from "./antimeridian-cut.js"; export type { RasterTileMetadata } from "./raster-tileset-2d.js"; export { RasterTileset2D } from "./raster-tileset-2d.js"; export { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js"; From 86f262b2f3174e9be444ed57612fe285721c57ac Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 17:23:00 +1000 Subject: [PATCH 09/21] Fix east side of am-crossing tile not showing if west side not in view --- ...sappears when zoomed away from the seam.md | 80 +++++++++++++ packages/deck.gl-raster/src/layer-utils.ts | 24 ++-- .../src/raster-tileset/antimeridian-cut.ts | 13 ++ .../raster-tileset/raster-tile-traversal.ts | 111 ++++++++++++++++-- .../src/raster-tileset/raster-tileset-2d.ts | 83 +++++++++---- .../raster-tileset-2d-antimeridian.test.ts | 25 ++-- 6 files changed, 285 insertions(+), 51 deletions(-) create mode 100644 antimeridian-crossing tile disappears when zoomed away from the seam.md diff --git a/antimeridian-crossing tile disappears when zoomed away from the seam.md b/antimeridian-crossing tile disappears when zoomed away from the seam.md new file mode 100644 index 00000000..afbd1163 --- /dev/null +++ b/antimeridian-crossing tile disappears when zoomed away from the seam.md @@ -0,0 +1,80 @@ +# Antimeridian-crossing tile disappears when zoomed away from the seam + +## Problem + +In `antimeridian-example`, once zoomed in tight on one side of the crossing tile — far enough +that ±180° itself scrolls out of view — the tile stopped rendering entirely, even though its data +footprint was still on screen. Reappeared as soon as ±180° came back into view. + +## Root causes (three, in `packages/deck.gl-raster/src/raster-tileset/`) + +The traversal/culling code (`raster-tile-traversal.ts`) had zero awareness of the antimeridian +split that the render path (`raster-tileset-2d.ts`'s `buildPieceReprojection`) already handled. + +1. **Malformed per-tile bounding volume.** `_getGenericBoundingVolume` reprojects 9 reference + points per tile with no antimeridian correction. For a crossing tile, some points land near + `x≈0` and others near `x≈512` (proj4 wraps ±180° during the forward-to-3857 projection) — one + `OrientedBoundingBox` fit over that scattered cloud doesn't represent "a thin sliver at the + seam," so culling was unreliable except by accident (the old box was wide enough to overlap + the frustum only when the seam itself was in view). +2. **Malformed dataset-level bounds.** The same problem, one level up: `wgs84Bounds` (used as an + `insideBounds` pre-filter, checked before frustum culling on every tile) is derived from + `descriptor.projectedBounds` via a naive min/max over densified samples — for a crossing + dataset this produces numbers that don't correspond to the real west/east corners either. + Once (1) was fixed, this mismatch caused a *new* failure: the corrected tile box and the + uncorrected dataset box only touched at `x=512` instead of overlapping, so the tile was + rejected at the bounds-check stage, at every zoom, before culling even ran. +3. **Mesh rendered in the wrong world copy.** `buildPieceReprojection` built one shared + reprojection for both the west and east pieces, always shifting the "wrapped" half so both + pieces sit in a single combined frame near `x≈512`. That's correct when both pieces are + visible together (they need to connect seamlessly), but wrong when only the east piece is in + view: its mesh was still drawn at `x≈512`, a full world away from wherever the camera actually + was — selected (fetched) but never on screen, since deck.gl only draws the `worldOffset` camera + passes the viewport's own current bounds actually produce (`offset=0` is always one of them; + `offset=-1` etc. only exist when the viewport itself currently straddles a seam). + +## Fix + +- **`antimeridian-cut.ts`**: added `unwrapCommonSpaceX(x, tileSize)` — the shared per-point + correction (`x < tileSize/2 → x + tileSize`), extracted from `buildPieceReprojection`'s inline + version so both the render path and the traversal use the same tested logic. +- **`raster-tile-traversal.ts`**: + - `_getGenericBoundingVolume` now detects crossing via `antimeridianCut` (same corner-longitude + check `raster-tileset-2d.ts` already used) and applies `unwrapCommonSpaceX` to the sampled + reference points before fitting the bounding volume — fixes root cause 1. + - `getTileIndices` now separately detects whether the *dataset* crosses (from + `descriptor.projectedBounds`' real corners, not the lossy `wgs84Bounds`) and recomputes the + `bottomLeft`/`topRight` common-space bounds from each corner's own longitude — fixes root + cause 2. (Patching the already-mixed-up `wgs84Bounds` min/max in place doesn't work — the two + numbers no longer correspond to "west corner" and "east corner" individually, so it has to be + rebuilt from the real corners.) + - The world-copy offset-pass gate changed from `subViewports.length > 1` to + `subViewports != null`, so a tile whose corrected box only overlaps the frustum at a non-zero + offset can still be found once zoomed in tight (rather than being un-findable simply because + the *viewport* itself no longer straddles a seam). +- **`raster-tileset-2d.ts`**: `buildPieceReprojection` now takes a `"west" | "east"` piece + argument and applies a *mirrored* correction per piece instead of one shared bundle — fixes root + cause 3. West keeps the existing `+TILE_SIZE`-if-wrapped rule (already correct, since its raw + values never fall below `TILE_SIZE/2`); east now does the opposite (`-TILE_SIZE` if + `x ≥ TILE_SIZE/2`, a no-op for its own interior), so it renders at its own natural near-`0` + position instead of being forced next to the west piece. deck.gl's own repeat-rendering already + draws whichever camera passes actually exist each frame, so each piece just needs to be at its + true position — no manual cross-piece shift needed, and the two pieces still meet up correctly + when both are in view (each gets independently repeat-rendered into the copy the other is in). +- **`layer-utils.ts`**: `renderDebugTileOutline` was using the shared west bundle for *both* + pieces' debug boxes (a leftover of the old design) — updated to use each piece's own + reprojection, matching how they now actually render. + +## Verification + +- `pnpm --filter @developmentseed/deck.gl-raster test` — 151/151 passing, including updated + `raster-tileset-2d-antimeridian.test.ts` cases for the new mirrored east-piece behavior. +- Manually confirmed in `antimeridian-example`: crossing tile (image + debug outline, both + pieces) now stays visible when zoomed into either side alone, away from ±180°. + +## Known follow-up + +- `raster-tile-traversal.ts` still has temporary `console.warn('[AM-DEBUG] ...')` logging (gated + to near-seam tiles) left in from live debugging — remove before shipping. +- Not yet ported to the Mosaic/MultiCOG path (`mosaic-tileset-2d.ts`) — out of scope for this + pass, which was deliberately limited to `antimeridian-example`. diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts index ad4f34c8..e28c5a2d 100644 --- a/packages/deck.gl-raster/src/layer-utils.ts +++ b/packages/deck.gl-raster/src/layer-utils.ts @@ -43,30 +43,30 @@ export function renderDebugTileOutline( tile: Tile2DHeader & RasterTileMetadata, forwardTo4326: ReprojectionFns["forwardReproject"], ) { - const { _antimeridianCut, _westReprojection } = tile; + const { _antimeridianCut, _westReprojection, _eastReprojection } = tile; - // A crossing tile renders as two `RasterLayer`s in different world - // copies (see `RasterTileLayer._renderAntimeridianTile`) — the debug - // outline needs to match, in the same common-space coordinate system, - // rather than drawing one WGS84 box whose corners (e.g. lng 179.97° and - // −179.17°) `PathLayer` would connect the long way around the globe. - if (_antimeridianCut && _westReprojection) { - // Both pieces share the same (piece-agnostic) reprojection bundle — see - // `RasterTileset2D.buildPieceReprojection`. - const { forwardReproject } = _westReprojection; + // A crossing tile renders as two `RasterLayer`s, each at its own natural + // common-space position (see `RasterTileLayer._renderAntimeridianTile` and + // `RasterTileset2D.buildPieceReprojection`) — the debug outline needs to + // match, using EACH piece's own reprojection, rather than drawing one WGS84 + // box whose corners (e.g. lng 179.97° and −179.17°) `PathLayer` would + // connect the long way around the globe, or using one piece's reprojection + // for both (which would place the east box a full world away from where + // it actually renders). + if (_antimeridianCut && _westReprojection && _eastReprojection) { const { uCut } = _antimeridianCut; const { tileWidth, tileHeight, forwardTransform } = tile; const cutPx = uCut * tileWidth; const westPath = pieceBoxPath( forwardTransform, - forwardReproject, + _westReprojection.forwardReproject, 0, cutPx, tileHeight, ); const eastPath = pieceBoxPath( forwardTransform, - forwardReproject, + _eastReprojection.forwardReproject, cutPx, tileWidth, tileHeight, diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index c669294f..16f82aac 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -43,6 +43,19 @@ export function unwrapEastLng(westLng: number, eastLng: number): number { return eastLng < westLng ? eastLng + 360 : eastLng; } +/** + * Correct one reprojected reference point's common-space x for an + * antimeridian-crossing tile: raw x in the lower half `[0, tileSize/2)` is + * on the wrapped side (proj4 normalized it back into range) and gets + * `+tileSize`, continuing past the near-180 side instead of wrapping to the + * start of the world. No-op for a point already on the near-180 side, + * including the exact seam boundary. See `buildPieceReprojection` for the + * full reasoning (same rule, applied there per-piece at render time). + */ +export function unwrapCommonSpaceX(x: number, tileSize: number): number { + return x < tileSize / 2 ? x + tileSize : x; +} + /** * 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). 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..8fa3a25b 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 @@ -29,6 +29,7 @@ import { } from "@math.gl/culling"; import { lngLatToWorld, worldToLngLat } from "@math.gl/web-mercator"; +import { antimeridianCut, unwrapCommonSpaceX } from "./antimeridian-cut.js"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import type { RasterTilesetDescriptor, @@ -63,6 +64,12 @@ export const TILE_SIZE = 512; */ const MAX_MAPS = 3; +/** TEMP DEBUG: true if `x` (mod TILE_SIZE) is within 20 units of a seam (0 or TILE_SIZE), to gate noisy per-tile logging to only antimeridian-relevant tiles. */ +function isNearSeam(x: number): boolean { + const mod = ((x % TILE_SIZE) + TILE_SIZE) % TILE_SIZE; + return mod < 20 || mod > TILE_SIZE - 20; +} + // Reference points used to sample tile boundaries for bounding volume // calculation. // @@ -325,6 +332,12 @@ export class RasterTileNode { 0, ); if (!this.insideBounds(bounds, primaryWorldVolume.commonSpaceBounds)) { + if (isNearSeam(primaryWorldVolume.commonSpaceBounds[0])) { + // eslint-disable-next-line no-console + console.warn( + `[AM-DEBUG] BOUNDS-REJECT z=${this.z} x=${this.x} y=${this.y} worldOffset=${worldOffset} tileBounds=${primaryWorldVolume.commonSpaceBounds.map((n) => n.toFixed(1))} datasetBounds=${bounds.map((n) => n.toFixed(1))}`, + ); + } return false; } } @@ -334,6 +347,12 @@ export class RasterTileNode { // Returns: <0 if outside, 0 if intersecting, >0 if fully inside const isInside = cullingVolume.computeVisibility(boundingVolume); if (isInside < 0) { + if (isNearSeam(boundingVolume.center[0] ?? 0)) { + // eslint-disable-next-line no-console + console.warn( + `[AM-DEBUG] FRUSTUM-REJECT z=${this.z} x=${this.x} y=${this.y} worldOffset=${worldOffset} center=${boundingVolume.center[0]?.toFixed(1)},${boundingVolume.center[1]?.toFixed(1)}`, + ); + } return false; } @@ -362,6 +381,12 @@ export class RasterTileNode { (children === null && this.z >= minZ) ) { this.selected = true; + if (isNearSeam(boundingVolume.center[0] ?? 0)) { + // eslint-disable-next-line no-console + console.warn( + `[AM-DEBUG] SELECTED z=${this.z} x=${this.x} y=${this.y} worldOffset=${worldOffset} devicePixelsPerSourcePixel=${devicePixelsPerSourcePixel.toFixed(3)}`, + ); + } return true; } } @@ -528,6 +553,19 @@ export class RasterTileNode { const tileCorners = this.level.projectedTileCorners(this.x, this.y); + // Detect whether this tile crosses ±180°. proj4's forward-to-3857 + // (below) wraps longitudes outside (-180°, 180°] back into range, so a + // crossing tile's reference points past the seam land on the opposite + // edge of common space unless corrected — see `unwrapCommonSpaceX`. + const cornerLng = (corner: Point): number => + this.descriptor.projectTo4326(corner[0], corner[1])[0]; + const cut = antimeridianCut({ + topLeft: cornerLng(tileCorners.topLeft), + topRight: cornerLng(tileCorners.topRight), + bottomLeft: cornerLng(tileCorners.bottomLeft), + bottomRight: cornerLng(tileCorners.bottomRight), + }); + const refPointsEPSG3857 = sampleReferencePointsInEPSG3857( REF_POINTS_9, tileCorners, @@ -535,9 +573,16 @@ export class RasterTileNode { this.descriptor.projectTo4326, ); - const commonSpacePositions = refPointsEPSG3857.map((xy) => - rescaleEPSG3857ToCommonSpace(xy), - ); + const commonSpacePositions: [number, number][] = refPointsEPSG3857 + .map((xy) => rescaleEPSG3857ToCommonSpace(xy)) + .map(([x, y]) => (cut ? [unwrapCommonSpaceX(x, TILE_SIZE), y] : [x, y])); + + // eslint-disable-next-line no-console + if (cut) { + console.warn( + `[AM-DEBUG] bbox z=${this.z} x=${this.x} y=${this.y} cut.uCut=${cut.uCut.toFixed(4)} rawX=${refPointsEPSG3857.map((xy) => rescaleEPSG3857ToCommonSpace(xy)[0].toFixed(2)).join(",")} correctedX=${commonSpacePositions.map((p) => p[0].toFixed(2)).join(",")}`, + ); + } const refPointPositions: [number, number, number][] = []; for (const p of commonSpacePositions) { @@ -953,6 +998,50 @@ export function getTileIndices( const [minLng, minLat, maxLng, maxLat] = wgs84Bounds; const bottomLeft = lngLatToWorld([minLng, minLat]); const topRight = lngLatToWorld([maxLng, maxLat]); + + // `wgs84Bounds` is derived from `descriptor.projectedBounds` via a plain + // min/max over densified samples (see `RasterTileset2D`'s constructor), + // which — like a single tile's naive reference-point reprojection — loses + // antimeridian awareness for a crossing dataset. Detect crossing directly + // from the dataset's own corner longitudes (mirroring the per-tile + // `cornerLng`/`antimeridianCut` check in `_getGenericBoundingVolume`) and + // apply the same `unwrapCommonSpaceX` correction, so this bounds pre-filter + // agrees with the now-corrected per-tile bounding volumes it's compared + // against in `RasterTileNode.update`'s `insideBounds` check — otherwise a + // crossing tile's corrected box (starting at x=TILE_SIZE) and this + // uncorrected dataset box (ending at x=TILE_SIZE) merely touch instead of + // overlapping, and the tile is wrongly rejected at every zoom. + const [projMinX, , projMaxX, projMaxY] = descriptor.projectedBounds; + const datasetWestLng = descriptor.projectTo4326(projMinX, projMaxY)[0]; + const datasetEastLng = descriptor.projectTo4326(projMaxX, projMaxY)[0]; + const datasetCut = antimeridianCut({ + topLeft: datasetWestLng, + topRight: datasetEastLng, + bottomLeft: datasetWestLng, + bottomRight: datasetEastLng, + }); + if (datasetCut) { + // `bottomLeft`/`topRight` above came from `wgs84Bounds`'s already-lossy + // min/max (mixed together from densified samples on both sides of the + // seam) — patching those two numbers independently would collapse the + // box, since they no longer correspond to "the west corner" and "the + // east corner" individually. Recompute each corner's common-space x + // directly from its own real longitude instead, then apply the same + // per-point unwrap rule used for a single tile's reference points. + bottomLeft[0] = unwrapCommonSpaceX( + lngLatToWorld([datasetWestLng, minLat])[0], + TILE_SIZE, + ); + topRight[0] = unwrapCommonSpaceX( + lngLatToWorld([datasetEastLng, maxLat])[0], + TILE_SIZE, + ); + } + // eslint-disable-next-line no-console + console.warn( + `[AM-DEBUG] datasetCut=${datasetCut ? datasetCut.uCut.toFixed(4) : "undefined"} datasetLngs=${datasetWestLng.toFixed(3)},${datasetEastLng.toFixed(3)} bottomLeft=${bottomLeft[0].toFixed(2)} topRight=${topRight[0].toFixed(2)}`, + ); + const bounds: Bounds = [ bottomLeft[0], bottomLeft[1], @@ -983,13 +1072,19 @@ 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. + // eslint-disable-next-line no-console + console.warn( + `[AM-DEBUG] subViewports.length=${viewport.subViewports?.length ?? "null"}`, + ); + if (viewport.subViewports != null) { for (let offset = -1; offset >= -MAX_MAPS; offset--) { if (!runOffsetPass(roots, traversalParams, offset)) { break; 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 b909de09..2739ae22 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 @@ -20,7 +20,7 @@ import type { } 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 { antimeridianCut, unwrapCommonSpaceX } from "./antimeridian-cut.js"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import { getTileIndices, @@ -443,14 +443,23 @@ export class RasterTileset2D extends Tileset2D { }); // Each piece of a crossing tile needs its `forwardReproject` output // (common space) corrected per-point, not by one constant shift — see - // `buildPieceReprojection` for why. The correction is piece-agnostic, so - // both pieces share the same built bundle. + // `buildPieceReprojection` for why. West and east each get their OWN + // bundle (mirror-image corrections), so each piece renders at its own + // natural, internally-consistent position rather than both being forced + // into the west piece's frame — see that method's doc comment for why a + // single shared bundle breaks visibility when only one piece is in view. let _westReprojection: ReprojectionFns | undefined; let _eastReprojection: ReprojectionFns | undefined; if (_antimeridianCut) { - _westReprojection = _eastReprojection = this.buildPieceReprojection( + _westReprojection = this.buildPieceReprojection( forwardTransform, inverseTransform, + "west", + ); + _eastReprojection = this.buildPieceReprojection( + forwardTransform, + inverseTransform, + "east", ); } @@ -489,36 +498,70 @@ export class RasterTileset2D extends Tileset2D { * *below* `TILE_SIZE` (e.g. 511.95) — no correction needed. The east * piece's *interior* has native lngs just past −180° after proj4's * wraparound (e.g. −179.17°), mapping to common-space x just *above* 0 - * (e.g. 1.18) — this needs `+TILE_SIZE` to sit continuously past the west - * piece. But the east piece's own west edge *is* the seam itself (native - * lng exactly 180°), which `projectPosition` maps to common-space x - * exactly `TILE_SIZE` — the *same* boundary value as the west piece's - * east edge, needing *no* correction, even though it belongs to the east - * piece. + * (e.g. 1.18) — already its own natural, internally-consistent position. + * The east piece's own west edge *is* the seam itself (native lng exactly + * 180°), which `projectPosition` maps to common-space x exactly + * `TILE_SIZE` — the *same* boundary value as the west piece's east edge, + * but *belonging* to the east piece's natural (near-0) frame, so it needs + * `-TILE_SIZE` to join up with that piece's interior rather than sitting a + * full world away from it. * * So the correction can't be "shift this whole piece by one world" (a * single piece can contain both a boundary point at exactly the seam and - * interior points on the wrapped side of it) — it has to be a per-point - * test: any raw common-space x in the lower half `[0, TILE_SIZE/2)` is on - * the wrapped side and gets `+TILE_SIZE`; everything else (including the - * exact-seam boundary, at `TILE_SIZE`) is left alone. This is symmetric - * for both pieces — the west piece's raw x values never fall below - * `TILE_SIZE/2`, so the test is a no-op there, matching its already-correct - * behavior. + * interior points already on its own natural side) — it has to be a + * per-point test, and it's *mirrored* between the two pieces: the west + * piece shifts raw x in `[0, TILE_SIZE/2)` up by `+TILE_SIZE` (a no-op for + * its own raw values, which never fall below `TILE_SIZE/2`); the east + * piece shifts raw x in `[TILE_SIZE/2, TILE_SIZE]` down by `-TILE_SIZE` (a + * no-op for its own interior, which never reaches `TILE_SIZE/2`). + * + * Each piece therefore renders at its *own* natural common-space position + * (west near `TILE_SIZE`, east near `0`) instead of both being forced into + * one shared, artificially-combined frame. This matters once the viewport + * is zoomed into just one piece, away from the seam: deck.gl's own + * world-copy/repeat rendering already knows how to place a layer's raw, + * unmodified position on screen regardless of which world copy the camera + * is centered on (the same mechanism that already renders ordinary, + * non-crossing tiles correctly at any zoom) — but only if that position is + * the piece's genuine one. Forcing the east piece into the west piece's + * frame put it a full world away from wherever the camera was actually + * looking whenever only the east piece was in view, leaving it selected + * (fetched) but invisible. When *both* pieces are in view (viewport + * straddling the seam), deck.gl's own repeat rendering draws each piece at + * every world copy it's visible in, so the west piece (near `TILE_SIZE`) + * and the east piece (near `0`, repeat-rendered a world copy over at + * `TILE_SIZE`) still meet up seamlessly — no manual cross-piece shift + * needed for that case either. */ private buildPieceReprojection( forwardTransform: ProjectionFunction, inverseTransform: ProjectionFunction, + piece: "west" | "east", ): ReprojectionFns { return { forwardTransform, inverseTransform, forwardReproject: (x, y) => { const [cx, cy] = this.projectPosition(x, y); - return cx < TILE_SIZE / 2 ? [cx + TILE_SIZE, cy] : [cx, cy]; + const corrected = + piece === "west" + ? unwrapCommonSpaceX(cx, TILE_SIZE) + : cx >= TILE_SIZE / 2 + ? cx - TILE_SIZE + : cx; + return [corrected, cy]; + }, + inverseReproject: (cx, cy) => { + const raw = + piece === "west" + ? cx >= TILE_SIZE + ? cx - TILE_SIZE + : cx + : cx < 0 + ? cx + TILE_SIZE + : cx; + return this.unprojectPosition(raw, cy); }, - inverseReproject: (cx, cy) => - this.unprojectPosition(cx >= TILE_SIZE ? cx - TILE_SIZE : cx, cy), }; } } 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 0238ebc3..06332533 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 @@ -139,27 +139,30 @@ describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { expect(cx).toBeCloseTo(477.87, 1); }); - it("shifts a point that wraps to common-space x < TILE_SIZE/2 by +TILE_SIZE (east piece's interior)", () => { + it("leaves the east piece's interior unshifted (already its own natural, near-0 position)", () => { const metadata = crossingMetadata(); // Native lng −162° wraps to −162° (already in range) → common-space - // x ≈ 25.6 (< 256) → shifted to ≈ 537.6, continuing past the west - // piece's edge instead of wrapping back to the start of the world. + // x ≈ 25.6 (< 256) — left as-is, since this IS the east piece's own + // natural position. Forcing it to join the west piece's frame (the old + // +TILE_SIZE behavior) put it a full world away from wherever the + // camera was actually looking whenever only the east piece was in + // view — see `buildPieceReprojection`'s doc comment. const [cx] = metadata._eastReprojection!.forwardReproject(-162, 24); - expect(cx).toBeCloseTo(25.6 + TILE_SIZE, 1); + expect(cx).toBeCloseTo(25.6, 1); }); - it("does NOT shift the exact seam corner, even though it's the east piece's own west edge — the bug this fix corrects", () => { + it("shifts the seam corner by -TILE_SIZE for the east piece, joining its own interior instead of the west piece's frame", () => { // The seam itself: native lng −180° wraps to exactly +180° → - // common-space x = TILE_SIZE exactly (512, not < TILE_SIZE/2). This is - // the same corner as the west piece's east edge — both pieces must - // agree on it, or the boundary corner gets double-shifted (regression - // test for the real-world bug: a single per-piece constant shift - // pushed this corner to 1024 instead of leaving it at 512). + // common-space x = TILE_SIZE exactly (512). For the WEST piece this is + // its own natural east edge — no shift. For the EAST piece this same + // raw value is its own west edge, but belongs to the east piece's + // natural (near-0) frame, so it shifts by -TILE_SIZE to join up with + // that piece's interior (≈25.6) rather than sitting a full world away. const metadata = crossingMetadata(); const [westCx] = metadata._westReprojection!.forwardReproject(-180, 24); const [eastCx] = metadata._eastReprojection!.forwardReproject(-180, 24); expect(westCx).toBeCloseTo(TILE_SIZE, 9); - expect(eastCx).toBeCloseTo(TILE_SIZE, 9); + expect(eastCx).toBeCloseTo(0, 9); }); it("round-trips forwardReproject/inverseReproject for a shifted point", () => { From 92a0e250e346e2b20f3fa5eb2155dc5426e6dbbc Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 17:26:24 +1000 Subject: [PATCH 10/21] WIP: PR description --- PR.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 PR.md diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..73ff5ab8 --- /dev/null +++ b/PR.md @@ -0,0 +1,26 @@ +This PR aims to extend upon and test the [open antimeridian PR](https://github.com/developmentseed/deck.gl-raster/pull/576). I am using tiled Digital Earth Pacific data that is in PDC (EPSG:3832). One column of tiles span the antimeridian. + +The above linked PR sets up a good approach in dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md. This PR continues this split-in-two mesh design. This PR also aims to follow https://www.gadom.ski/antimeridian/latest/ where relevant. + +This PR has significant overlap with my earlier PR https://github.com/developmentseed/deck.gl-raster/pull/639, and should replace it. + +Technical details: +- RasterTileLayer._renderAntimeridianTile emits two RasterLayers (west/east), each seeded via triangulateRectangle from its own UV sub-domain, sharing one texture — exactly the "split in the sublayer factory, everything else keeps its single-mesh contract" design. +- Cut detection matches the MVP's stated scope. antimeridianCut/edgeUCut — vertical-only, rejects slanted (U_EPSILON tolerance), falls back to full mesh otherwise. Matches the code's own docstring; the design doc's broader "vertical and slanted" scope claim was already aspirational in #576, not something this branch touched either way. + +- All 3 of kylebarron's review comments on antimeridian-cut.ts are addressed: + - U_EPSILON now has a docstring + - edgeUCut was hoisted to a top-level function + - antimeridianCut's docstring now explicitly says general antimeridian handling should eventually be supported, just isn't yet +- Test plan items present: cut-location unit tests (antimeridian-cut.test.ts), piece-reprojection unit tests (raster-tileset-2d-antimeridian.test.ts), and the cog-basic dev-only antimeridian.tif fixture entry from #576 is still intact and untouched. + +- One real discrepancy — the design doc is now stale. The doc's "Seam handling" section describes the fix as: "local to the wrapped (negative-side) piece only... if the projected X comes back positive, subtract one world-width." That's the original #576 mechanism, and it's what this branch replaces. It was wrong ("a single constant-per-piece shift is simply wrong at the piece's own boundary corner, which coincides with the other piece's edge and needs no shift at all"). The actual code now applies one symmetric per-point test to both pieces, in common-space, post-projectPosition — not piece-specific, not a native-CRS sign check. Functionally correct and tested (including the exact-seam-corner regression case), but the design doc's prose still describes the old mechanism. Need to update the doc. + +I have made a frankenstein monster of my 3 PRs to test, and they work well together for my needs (a combo of MosaicLayer, MultiCOGLayer that spans the antimeridian and needs nearest sampling). https://github.com/willjnz/deck.gl-raster/tree/willjnz/frankenstein. This combines: +- https://github.com/willjnz/deck.gl-raster/tree/willjnz/antimeridian-crossing-fixes +- https://github.com/developmentseed/deck.gl-raster/pull/640 +- https://github.com/developmentseed/deck.gl-raster/pull/639 + + +Remaining problems: +- when zoomed in, but the am is no longer visible, the 066 right part of the tile does not render. when zoomed in far around -179.9 or +180.1, the am-crossing tile does not render. this stops rendering as soon as the 180 degree is no longer visible (but the tile's data should still render). make a plan to fix this. i guess it is to do with tile traversal. this issue is present in both am examples From 8a1af00e00693bb51af9b14d022a9a2fffac47cc Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 17:46:32 +1000 Subject: [PATCH 11/21] Finalise --- PR.md | 28 +++++-- ...sappears when zoomed away from the seam.md | 80 ------------------- ...05-27-antimeridian-crossing-tile-design.md | 34 +++++++- .../src/mosaic-layer/mosaic-tileset-2d.ts | 21 ++--- .../raster-tileset/raster-tile-traversal.ts | 39 --------- 5 files changed, 63 insertions(+), 139 deletions(-) delete mode 100644 antimeridian-crossing tile disappears when zoomed away from the seam.md diff --git a/PR.md b/PR.md index 73ff5ab8..d55332a3 100644 --- a/PR.md +++ b/PR.md @@ -1,10 +1,13 @@ -This PR aims to extend upon and test the [open antimeridian PR](https://github.com/developmentseed/deck.gl-raster/pull/576). I am using tiled Digital Earth Pacific data that is in PDC (EPSG:3832). One column of tiles span the antimeridian. +This PR aims to test the [open antimeridian PR](https://github.com/developmentseed/deck.gl-raster/pull/576) and make it functional. I am using tiled Digital Earth Pacific data that is in PDC (EPSG:3832) which passes the vertical (not slanted) cut condition. One column of tiled COGs span the antimeridian. -The above linked PR sets up a good approach in dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md. This PR continues this split-in-two mesh design. This PR also aims to follow https://www.gadom.ski/antimeridian/latest/ where relevant. +The [above linked PR](dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md) sets up a good approach. This PR continues this split-in-two mesh design. This PR also aims to follow https://www.gadom.ski/antimeridian/latest/ where relevant. This PR has significant overlap with my earlier PR https://github.com/developmentseed/deck.gl-raster/pull/639, and should replace it. -Technical details: +This PR has 2 examples, a simple COGLayer spanning the antimeridian, and a MosaicLayer/MultiCOGLayer example. + + +#### Technical details: - RasterTileLayer._renderAntimeridianTile emits two RasterLayers (west/east), each seeded via triangulateRectangle from its own UV sub-domain, sharing one texture — exactly the "split in the sublayer factory, everything else keeps its single-mesh contract" design. - Cut detection matches the MVP's stated scope. antimeridianCut/edgeUCut — vertical-only, rejects slanted (U_EPSILON tolerance), falls back to full mesh otherwise. Matches the code's own docstring; the design doc's broader "vertical and slanted" scope claim was already aspirational in #576, not something this branch touched either way. @@ -14,13 +17,22 @@ Technical details: - antimeridianCut's docstring now explicitly says general antimeridian handling should eventually be supported, just isn't yet - Test plan items present: cut-location unit tests (antimeridian-cut.test.ts), piece-reprojection unit tests (raster-tileset-2d-antimeridian.test.ts), and the cog-basic dev-only antimeridian.tif fixture entry from #576 is still intact and untouched. -- One real discrepancy — the design doc is now stale. The doc's "Seam handling" section describes the fix as: "local to the wrapped (negative-side) piece only... if the projected X comes back positive, subtract one world-width." That's the original #576 mechanism, and it's what this branch replaces. It was wrong ("a single constant-per-piece shift is simply wrong at the piece's own boundary corner, which coincides with the other piece's edge and needs no shift at all"). The actual code now applies one symmetric per-point test to both pieces, in common-space, post-projectPosition — not piece-specific, not a native-CRS sign check. Functionally correct and tested (including the exact-seam-corner regression case), but the design doc's prose still describes the old mechanism. Need to update the doc. +- One real discrepancy — the design doc (`2026-05-27-antimeridian-crossing-tile-design.md`) is now stale on seam handling: + - **What the doc says**: its "Seam handling" section describes the fix as "local to the wrapped (negative-side) piece only... if the projected X comes back positive, subtract one world-width." That's the original #576 mechanism. + - **Why it's wrong**: a single constant-per-piece shift breaks at the piece's own boundary corner, which coincides with the other piece's edge and needs no shift at all. + - **What this branch does instead**: one symmetric per-point test applied to both pieces, in common-space, after `projectPosition` — not piece-specific, not a native-CRS sign check. + - **Status**: fixed — `dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md`'s "Seam handling" section now describes the shipped symmetric mechanism (old text kept, struck through, for history), and its "Traversal" bullet, "Edge cases", and "Test plan" sections are updated to match. Also added a new "Locating and selecting a crossing tile in the traversal" section documenting the fix described below. + +- MosaicLayer/MultiCOGLayer support: `normalizeSourceBbox` (mosaic-layer.ts) unwraps a GeoJSON-flipped source bbox (RFC 7946 §5.2: `minX > maxX` marks a crossing) onto a continuous frame before it's indexed into Flatbush, so the spatial index, the priority-queue distance calculation, and `MosaicTileset2D`'s own viewport search all agree on one bbox per source. `MultiCOGLayer`'s debug overlay (`_renderDebugLayers`/`pieceBoxWgs84`) splits into matching west/east boxes for a crossing tile — `MultiCOGLayer extends RasterTileLayer`, so the actual mesh-splitting/reprojection path is shared automatically; only the debug-outline drawing (which works in plain lng/lat, not common-space) needed its own antimeridian handling. +- This PR also covers visualising just one side of the AM-split COG/Mosaic. An earlier version of this PR would not show the right/east side of an am-split texture, if the left/west side was not in view. Root cause: `raster-tile-traversal.ts`'s frustum-culling traversal had zero antimeridian awareness, three compounding defects: + 1. `_getGenericBoundingVolume` sampled 9 reference points per tile with no antimeridian correction — for a crossing tile some land on each edge of common space, producing a bounding volume wide enough to be found only by accident (when the seam itself was in view). Fixed by extracting the wrap-correction already used in `buildPieceReprojection` into a shared `unwrapCommonSpaceX(x, tileSize)` helper (`antimeridian-cut.ts`) and applying it before fitting the box. + 2. Once (1) was fixed, the dataset-level `insideBounds` pre-filter in `getTileIndices` — built from the same lossy min/max `wgs84Bounds` — only *touched* the now-tight per-tile box at the seam instead of overlapping it, rejecting the tile at every zoom. Fixed by recomputing the dataset's own bounds from its real corner longitudes when the dataset itself crosses, instead of patching the already-mixed-up min/max. + 3. `buildPieceReprojection` built one shared reprojection for both pieces, always anchoring both near common-space `x≈512` — correct when both pieces are in view together, wrong once only the east piece is in view (its mesh stayed a full world away from the camera, selected but never drawn on screen). Fixed by mirroring the correction per piece so each renders at its own natural position; deck.gl's existing repeat-rendering (`subViewports`) already draws whatever world copies the camera needs. + - The world-copy offset-pass gate also changed from `subViewports.length > 1` to `subViewports != null` in both traversal files — the former only tests whether the *viewport itself* currently straddles a seam, not whether a tile's (or, for `MosaicTileset2D`'s Flatbush-based, lng/lat bbox search in `mosaic-tileset-2d.ts`, a source's) own position needs a shifted pass to be found once zoomed in tight to one side alone. + +#### Test PR: I have made a frankenstein monster of my 3 PRs to test, and they work well together for my needs (a combo of MosaicLayer, MultiCOGLayer that spans the antimeridian and needs nearest sampling). https://github.com/willjnz/deck.gl-raster/tree/willjnz/frankenstein. This combines: - https://github.com/willjnz/deck.gl-raster/tree/willjnz/antimeridian-crossing-fixes - https://github.com/developmentseed/deck.gl-raster/pull/640 - https://github.com/developmentseed/deck.gl-raster/pull/639 - - -Remaining problems: -- when zoomed in, but the am is no longer visible, the 066 right part of the tile does not render. when zoomed in far around -179.9 or +180.1, the am-crossing tile does not render. this stops rendering as soon as the 180 degree is no longer visible (but the tile's data should still render). make a plan to fix this. i guess it is to do with tile traversal. this issue is present in both am examples diff --git a/antimeridian-crossing tile disappears when zoomed away from the seam.md b/antimeridian-crossing tile disappears when zoomed away from the seam.md deleted file mode 100644 index afbd1163..00000000 --- a/antimeridian-crossing tile disappears when zoomed away from the seam.md +++ /dev/null @@ -1,80 +0,0 @@ -# Antimeridian-crossing tile disappears when zoomed away from the seam - -## Problem - -In `antimeridian-example`, once zoomed in tight on one side of the crossing tile — far enough -that ±180° itself scrolls out of view — the tile stopped rendering entirely, even though its data -footprint was still on screen. Reappeared as soon as ±180° came back into view. - -## Root causes (three, in `packages/deck.gl-raster/src/raster-tileset/`) - -The traversal/culling code (`raster-tile-traversal.ts`) had zero awareness of the antimeridian -split that the render path (`raster-tileset-2d.ts`'s `buildPieceReprojection`) already handled. - -1. **Malformed per-tile bounding volume.** `_getGenericBoundingVolume` reprojects 9 reference - points per tile with no antimeridian correction. For a crossing tile, some points land near - `x≈0` and others near `x≈512` (proj4 wraps ±180° during the forward-to-3857 projection) — one - `OrientedBoundingBox` fit over that scattered cloud doesn't represent "a thin sliver at the - seam," so culling was unreliable except by accident (the old box was wide enough to overlap - the frustum only when the seam itself was in view). -2. **Malformed dataset-level bounds.** The same problem, one level up: `wgs84Bounds` (used as an - `insideBounds` pre-filter, checked before frustum culling on every tile) is derived from - `descriptor.projectedBounds` via a naive min/max over densified samples — for a crossing - dataset this produces numbers that don't correspond to the real west/east corners either. - Once (1) was fixed, this mismatch caused a *new* failure: the corrected tile box and the - uncorrected dataset box only touched at `x=512` instead of overlapping, so the tile was - rejected at the bounds-check stage, at every zoom, before culling even ran. -3. **Mesh rendered in the wrong world copy.** `buildPieceReprojection` built one shared - reprojection for both the west and east pieces, always shifting the "wrapped" half so both - pieces sit in a single combined frame near `x≈512`. That's correct when both pieces are - visible together (they need to connect seamlessly), but wrong when only the east piece is in - view: its mesh was still drawn at `x≈512`, a full world away from wherever the camera actually - was — selected (fetched) but never on screen, since deck.gl only draws the `worldOffset` camera - passes the viewport's own current bounds actually produce (`offset=0` is always one of them; - `offset=-1` etc. only exist when the viewport itself currently straddles a seam). - -## Fix - -- **`antimeridian-cut.ts`**: added `unwrapCommonSpaceX(x, tileSize)` — the shared per-point - correction (`x < tileSize/2 → x + tileSize`), extracted from `buildPieceReprojection`'s inline - version so both the render path and the traversal use the same tested logic. -- **`raster-tile-traversal.ts`**: - - `_getGenericBoundingVolume` now detects crossing via `antimeridianCut` (same corner-longitude - check `raster-tileset-2d.ts` already used) and applies `unwrapCommonSpaceX` to the sampled - reference points before fitting the bounding volume — fixes root cause 1. - - `getTileIndices` now separately detects whether the *dataset* crosses (from - `descriptor.projectedBounds`' real corners, not the lossy `wgs84Bounds`) and recomputes the - `bottomLeft`/`topRight` common-space bounds from each corner's own longitude — fixes root - cause 2. (Patching the already-mixed-up `wgs84Bounds` min/max in place doesn't work — the two - numbers no longer correspond to "west corner" and "east corner" individually, so it has to be - rebuilt from the real corners.) - - The world-copy offset-pass gate changed from `subViewports.length > 1` to - `subViewports != null`, so a tile whose corrected box only overlaps the frustum at a non-zero - offset can still be found once zoomed in tight (rather than being un-findable simply because - the *viewport* itself no longer straddles a seam). -- **`raster-tileset-2d.ts`**: `buildPieceReprojection` now takes a `"west" | "east"` piece - argument and applies a *mirrored* correction per piece instead of one shared bundle — fixes root - cause 3. West keeps the existing `+TILE_SIZE`-if-wrapped rule (already correct, since its raw - values never fall below `TILE_SIZE/2`); east now does the opposite (`-TILE_SIZE` if - `x ≥ TILE_SIZE/2`, a no-op for its own interior), so it renders at its own natural near-`0` - position instead of being forced next to the west piece. deck.gl's own repeat-rendering already - draws whichever camera passes actually exist each frame, so each piece just needs to be at its - true position — no manual cross-piece shift needed, and the two pieces still meet up correctly - when both are in view (each gets independently repeat-rendered into the copy the other is in). -- **`layer-utils.ts`**: `renderDebugTileOutline` was using the shared west bundle for *both* - pieces' debug boxes (a leftover of the old design) — updated to use each piece's own - reprojection, matching how they now actually render. - -## Verification - -- `pnpm --filter @developmentseed/deck.gl-raster test` — 151/151 passing, including updated - `raster-tileset-2d-antimeridian.test.ts` cases for the new mirrored east-piece behavior. -- Manually confirmed in `antimeridian-example`: crossing tile (image + debug outline, both - pieces) now stays visible when zoomed into either side alone, away from ±180°. - -## Known follow-up - -- `raster-tile-traversal.ts` still has temporary `console.warn('[AM-DEBUG] ...')` logging (gated - to near-seam tiles) left in from live debugging — remove before shipping. -- Not yet ported to the Mosaic/MultiCOG path (`mosaic-tileset-2d.ts`) — out of scope for this - pass, which was deliberately limited to `antimeridian-example`. 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..352245c0 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 @@ -43,10 +43,38 @@ The antimeridian becomes *a tile boundary*, which the pipeline already handles, ### Seam handling +> **Updated 2026-09-29** — this section originally described a "wrapped-piece-only, output-sign-based" mechanism (below, struck through for the record). This was replaced by the symmetric per-point rule described first (below). See the implemented version's tests in `raster-tileset-2d-antimeridian.test.ts` and the traversal fix this motivated in "Locating and selecting a crossing tile in the traversal" below. + 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. +**As implemented**, the fix is a **symmetric per-point unwrap**, applied in common-space, after `projectPosition` — not piece-specific, not an input-longitude test. Both pieces' `forwardReproject` run every vertex's raw common-space X through a shared helper, `unwrapCommonSpaceX(x, tileSize)` ([`antimeridian-cut.ts`](../../packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts)), then each piece applies the mirror-image correction: + +- **West piece**: any vertex whose raw X wrapped onto the low end (`x < tileSize/2`) gets `+ tileSize`, joining the west piece's own high-x interior. (This is the rule the struck-through mechanism below also got right — it's only wrong for the *other* piece.) +- **East piece**: the mirror image — any vertex still sitting on the west piece's high-x frame (`x >= tileSize/2`, which includes the shared seam corner) gets `− tileSize`, joining the east piece's own near-0 interior. + +`inverseReproject` mirrors the correction (subtract for west, add for east, only where the corrected value would otherwise land outside `[0, tileSize)`), so round-tripping stays exact. + +One outcome worth calling out: this gives each piece its own **natural** common-space position (west near `x≈512`, east near `x≈0`) instead of always anchoring both near a single shared frame. That turned out to be load-bearing for the traversal fix below — a piece now needs no help from the other piece (or the seam) being in view to be found and drawn correctly. + +**Why the original mechanism (below) doesn't work:** a single constant-per-piece shift, decided by *which piece* is rendering, is wrong exactly at that piece's own boundary corner — the vertex that coincides with the *other* piece's edge and needs no shift at all. A per-piece constant can't tell "this piece's own interior vertex that happens to be wrapped" apart from "this piece's own boundary vertex that must stay put," because whether a given vertex needs the shift depends on *that vertex's own position*, not on which piece asked. The per-point test above (`x < tileSize/2` vs. `x >= tileSize/2`) gets every vertex right, including the exact seam corner, because it tests the vertex, not the piece. + +
+Original text (superseded, kept for history) + 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. +
+ +### Locating and selecting a crossing tile in the traversal + +> **Added 2026-09-29.** Not in the original design — found once the mechanism above shipped: a crossing tile disappeared once zoomed in tight enough on one side alone that ±180° itself scrolled out of view, reappearing as soon as the seam came back into view. Root cause was that `raster-tile-traversal.ts`'s frustum-culling traversal had no antimeridian awareness at all — three compounding defects, all in `packages/deck.gl-raster/src/raster-tileset/`: + +1. **Malformed per-tile bounding volume** — see the updated "Traversal" bullet above. Fixed by reusing `unwrapCommonSpaceX` in `_getGenericBoundingVolume`. +2. **Malformed dataset-level bounds.** The same problem one level up: `wgs84Bounds` (the `insideBounds` pre-filter checked before frustum culling on every tile) is a naive min/max over densified samples, which for a crossing dataset doesn't correspond to the real west/east corners. Once (1) was fixed this caused a *new* failure — the corrected tile box and the uncorrected dataset box only touched at `x=512` instead of overlapping, rejecting the tile at every zoom. Fixed in `getTileIndices` by recomputing the dataset's own bounds from its real corner longitudes when the dataset itself crosses. +3. **World-copy offset-pass gate tied to the wrong condition.** The extra ±1..±`MAX_MAPS` bounding-volume passes (for `renderWorldCopies`/repeat mode) were gated on `viewport.subViewports.length > 1` — whether the *viewport's own* visible span currently straddles a ±180° multiple. That's unrelated to whether a *tile's* position is on a different world-copy frame than the viewport's canonical one, which is what the offset passes actually need to answer. Once zoomed in tight to one side, this wrongly skipped the exact offset pass that would have placed the (now correctly shaped) bounding volume in front of the frustum. Fixed by gating on `viewport.subViewports != null` instead — "repeat mode is active at all," not "the viewport itself currently straddles a seam." The early-break in the offset walk keeps this cheap when nothing is near a seam. + +The same three-defect pattern, adapted to `MosaicTileset2D`'s structurally different Flatbush/lng-lat-bbox spatial index (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts`), needed only the world-copy-gate fix (its own version of #3) — a crossing source's bbox is normalized onto a continuous frame by `normalizeSourceBbox` (`mosaic-layer.ts`) before indexing, so it's found only by a query shifted ±360°, ±720°…, which the same `subViewports != null` gate now permits once zoomed in tight. `MultiCOGLayer extends RasterTileLayer`, so it already inherited defects #1/#2's fixes (and the seam-handling fix above) for free via the shared mesh/traversal code. + ### 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. @@ -77,7 +105,7 @@ RasterTileLayer._renderSubLayers (per tile) ← the only split point - **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). +- **Traversal** — originally planned as a **two-box bounding volume** for a crossing tile (west ≈ `[510,512]`, east ≈ `[0,2]`). **As implemented**, it's one tight box instead: the traversal's `_getGenericBoundingVolume` (`raster-tile-traversal.ts`) applies the same `unwrapCommonSpaceX` correction used by `forwardReproject` to its 9 sampled reference points before fitting the `OrientedBoundingBox`, so a crossing tile gets a single box straddling `x=512` rather than one spanning most of `[0,512]`. See "Locating and selecting a crossing tile in the traversal" below for why a second, independent fix was also needed here. ## Transparency to end users @@ -113,7 +141,7 @@ The initial-triangulation seed subsumes several pending needs into one primitive ## 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)). +- **Seam between pieces:** west's cut edge lands at common-x 512, east's at common-x 0 — each piece sits at its own natural position (see "Seam handling" above) rather than a shared frame, and abut across the world-copy boundary via deck.gl's own repeat-rendering. 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. @@ -122,7 +150,7 @@ The initial-triangulation seed subsumes several pending needs into one primitive **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). +- Bounding volume for a crossing tile is a single tight box straddling the seam (not one spanning most of `[0,512]`); correct selection under the world-copy traversal, including when zoomed in tight to one piece alone (see "Locating and selecting a crossing tile in the traversal" above). **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. 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 229037fd..7709e3fe 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 @@ -114,15 +114,18 @@ export class MosaicTileset2D extends Tileset2D { const viewportBounds = viewport.getBounds(); const matchedIndices = new Set(index.search(...viewportBounds)); - // World-copy passes: when the viewport spans multiple world copies (e.g. - // WebMercatorViewport with repeat: true panned across the antimeridian), - // `viewport.getBounds()` reports longitudes for only one copy, but a - // source's bbox may only be indexed in another copy's range. Re-query the - // index with the bounds shifted by ±360°, ±720°… — same idea as - // raster-tile-traversal.ts's per-offset frustum passes, just in lng/lat - // instead of common-space pixels. Walk each direction until an offset - // comes back empty — see dev-docs/world-copies.md. - if ((viewport.subViewports?.length ?? 0) > 1) { + // 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; 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 8fa3a25b..c9c4143d 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 @@ -64,12 +64,6 @@ export const TILE_SIZE = 512; */ const MAX_MAPS = 3; -/** TEMP DEBUG: true if `x` (mod TILE_SIZE) is within 20 units of a seam (0 or TILE_SIZE), to gate noisy per-tile logging to only antimeridian-relevant tiles. */ -function isNearSeam(x: number): boolean { - const mod = ((x % TILE_SIZE) + TILE_SIZE) % TILE_SIZE; - return mod < 20 || mod > TILE_SIZE - 20; -} - // Reference points used to sample tile boundaries for bounding volume // calculation. // @@ -332,12 +326,6 @@ export class RasterTileNode { 0, ); if (!this.insideBounds(bounds, primaryWorldVolume.commonSpaceBounds)) { - if (isNearSeam(primaryWorldVolume.commonSpaceBounds[0])) { - // eslint-disable-next-line no-console - console.warn( - `[AM-DEBUG] BOUNDS-REJECT z=${this.z} x=${this.x} y=${this.y} worldOffset=${worldOffset} tileBounds=${primaryWorldVolume.commonSpaceBounds.map((n) => n.toFixed(1))} datasetBounds=${bounds.map((n) => n.toFixed(1))}`, - ); - } return false; } } @@ -347,12 +335,6 @@ export class RasterTileNode { // Returns: <0 if outside, 0 if intersecting, >0 if fully inside const isInside = cullingVolume.computeVisibility(boundingVolume); if (isInside < 0) { - if (isNearSeam(boundingVolume.center[0] ?? 0)) { - // eslint-disable-next-line no-console - console.warn( - `[AM-DEBUG] FRUSTUM-REJECT z=${this.z} x=${this.x} y=${this.y} worldOffset=${worldOffset} center=${boundingVolume.center[0]?.toFixed(1)},${boundingVolume.center[1]?.toFixed(1)}`, - ); - } return false; } @@ -381,12 +363,6 @@ export class RasterTileNode { (children === null && this.z >= minZ) ) { this.selected = true; - if (isNearSeam(boundingVolume.center[0] ?? 0)) { - // eslint-disable-next-line no-console - console.warn( - `[AM-DEBUG] SELECTED z=${this.z} x=${this.x} y=${this.y} worldOffset=${worldOffset} devicePixelsPerSourcePixel=${devicePixelsPerSourcePixel.toFixed(3)}`, - ); - } return true; } } @@ -577,13 +553,6 @@ export class RasterTileNode { .map((xy) => rescaleEPSG3857ToCommonSpace(xy)) .map(([x, y]) => (cut ? [unwrapCommonSpaceX(x, TILE_SIZE), y] : [x, y])); - // eslint-disable-next-line no-console - if (cut) { - console.warn( - `[AM-DEBUG] bbox z=${this.z} x=${this.x} y=${this.y} cut.uCut=${cut.uCut.toFixed(4)} rawX=${refPointsEPSG3857.map((xy) => rescaleEPSG3857ToCommonSpace(xy)[0].toFixed(2)).join(",")} correctedX=${commonSpacePositions.map((p) => p[0].toFixed(2)).join(",")}`, - ); - } - const refPointPositions: [number, number, number][] = []; for (const p of commonSpacePositions) { refPointPositions.push([p[0], p[1], minZ]); @@ -1037,10 +1006,6 @@ export function getTileIndices( TILE_SIZE, ); } - // eslint-disable-next-line no-console - console.warn( - `[AM-DEBUG] datasetCut=${datasetCut ? datasetCut.uCut.toFixed(4) : "undefined"} datasetLngs=${datasetWestLng.toFixed(3)},${datasetEastLng.toFixed(3)} bottomLeft=${bottomLeft[0].toFixed(2)} topRight=${topRight[0].toFixed(2)}`, - ); const bounds: Bounds = [ bottomLeft[0], @@ -1080,10 +1045,6 @@ export function getTileIndices( // world copies along common-space X. A tile is selected if any pass selects // it. The early-break below keeps this cheap when nothing is near a seam. // See dev-docs/world-copies.md. - // eslint-disable-next-line no-console - console.warn( - `[AM-DEBUG] subViewports.length=${viewport.subViewports?.length ?? "null"}`, - ); if (viewport.subViewports != null) { for (let offset = -1; offset >= -MAX_MAPS; offset--) { if (!runOffsetPass(roots, traversalParams, offset)) { From 21700bd6a99c35945b63bc3ada3bcb8900a2846c Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 18:42:54 +1000 Subject: [PATCH 12/21] First attempt at heuristic fix --- .../src/raster-tileset/antimeridian-cut.ts | 42 +++- replace-256-x-heuristic.md | 185 ++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 replace-256-x-heuristic.md diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index 16f82aac..f86dcb2b 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -32,6 +32,27 @@ export interface AntimeridianCut { */ const U_EPSILON = 1e-6; +/** + * Maximum longitude span (degrees) either piece may have after the cut. + * + * `unwrapCommonSpaceX`'s per-point correction (and `buildPieceReprojection`'s + * matching per-vertex correction) assumes a piece's own interior never + * legitimately crosses the common-space halfway point (x = tileSize/2, + * i.e. the piece spans less than 180° of longitude): any point found past + * that mark is treated as a wrapped artifact of the seam and shifted a full + * world-width. For a piece ≥ 180° wide that assumption breaks — a point can + * legitimately cross the halfway mark without having wrapped at all, and + * gets wrongly shifted, tearing the mesh. There is no simple per-point or + * per-piece fix for this (a true fix needs a continuous per-point unwrap, + * which doesn't generalize to arbitrary source CRSs — see the antimeridian + * design doc's "Locating and selecting a crossing tile in the traversal" + * section), so a piece exceeding this width is rejected outright, the same + * way a curved (non-vertical) cut is: the caller falls back to a single + * full-mesh render. 170° (not the mathematical limit of 180°) leaves a + * margin against floating-point noise right at the boundary. + */ +const MAX_PIECE_SPAN_DEG = 170; + /** * Unwrap a GeoJSON-flipped edge (RFC 7946 §5.2: west > east marks a * crossing) onto the continuous native scale `edgeUCut`'s seam search @@ -92,6 +113,11 @@ function edgeUCut(westLng: number, eastLng: number): number | undefined { * cuts (non-geographic CRSs) — but for now those return `undefined` and fall * back to a single full-mesh layer. See issue #575. * + * Also rejects a cut where either resulting piece would be ≥ + * {@link MAX_PIECE_SPAN_DEG} wide — see that constant's doc comment — falling + * back to a single full-mesh layer the same way an unsupported slanted or + * curved cut does. + * * Assumes u increases eastward (standard north-up geotransform). Corner * longitudes may be in either encoding described on {@link CornerLongitudes}. */ @@ -111,5 +137,19 @@ export function antimeridianCut( if (Math.abs(topUCut - bottomUCut) > U_EPSILON) { return undefined; } - return { uCut: (topUCut + bottomUCut) / 2 }; + const uCut = (topUCut + bottomUCut) / 2; + + // Reject a piece too wide for unwrapCommonSpaceX's halfway-point + // assumption to hold — see MAX_PIECE_SPAN_DEG's doc comment. + const totalSpanDeg = unwrapEastLng(topLeft, topRight) - topLeft; + const westPieceSpanDeg = uCut * totalSpanDeg; + const eastPieceSpanDeg = totalSpanDeg - westPieceSpanDeg; + if ( + westPieceSpanDeg >= MAX_PIECE_SPAN_DEG || + eastPieceSpanDeg >= MAX_PIECE_SPAN_DEG + ) { + return undefined; + } + + return { uCut }; } diff --git a/replace-256-x-heuristic.md b/replace-256-x-heuristic.md new file mode 100644 index 00000000..cd1e807b --- /dev/null +++ b/replace-256-x-heuristic.md @@ -0,0 +1,185 @@ +# Can the GeoJSON west>east convention replace the `x < 256` heuristic? + +## Background + +`unwrapCommonSpaceX` (`packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts`) corrects +one reprojected reference point's common-space `x` for an antimeridian-crossing tile: + +```ts +export function unwrapCommonSpaceX(x: number, tileSize: number): number { + return x < tileSize / 2 ? x + tileSize : x; +} +``` + +This is used both by the traversal's bounding-volume fit (`raster-tile-traversal.ts`) and, mirrored +per piece, by `buildPieceReprojection` (`raster-tileset-2d.ts`) to place each piece's mesh vertices. +It's a **midpoint test**: `tileSize/2` (256, in common-space units) is the threshold. It works for a +normal, narrow tile — but it's a heuristic, not a proof, and it has a real failure mode: a piece +wide enough that its own legitimate points cross the 256 line without ever having wrapped around the +antimeridian at all. + +Elsewhere in this codebase, antimeridian-crossing detection avoids exactly this kind of magnitude +guess by using the GeoJSON bbox convention (RFC 7946 §5.2: `minX > maxX` marks a crossing bbox) — +`unwrapEastLng` and `normalizeSourceBbox`, described below. Given that convention already solves a +structurally similar problem cleanly, this doc asks: can it replace `unwrapCommonSpaceX` too? + +**Short answer: no — not because the convention is wrong, but because the two problems aren't the +same shape.** Detection and per-point placement hit fundamentally different constraints. This doc +works through why, then documents the width guard that was added instead. + +## Where the GeoJSON convention is already used (and why it works there) + +- **`unwrapEastLng(westLng, eastLng)`** (`antimeridian-cut.ts`): the literal GeoJSON idiom — if + `eastLng < westLng`, add 360°. Used by `edgeUCut`/`antimeridianCut` to detect whether a tile's + corners cross the seam and to locate the cut (`uCut`). +- **`normalizeSourceBbox`** (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts`): the same + idiom applied to a `MosaicLayer` source's bbox before it's indexed into Flatbush. + +Both are **corner/bbox-level, one-shot, detection-time** uses: given exactly two known longitudes +(west corner, east corner), decide "does this cross, and if so, where." That's a single yes/no +comparison between two known values — it doesn't care how wide the span between them is. A dataset +whose bbox is 300° wide is detected exactly as reliably as one that's 3° wide, because there's +nothing here to disambiguate — the two corners are given directly, not derived by guessing which +"copy" of a periodic function they came from. + +`unwrapCommonSpaceX`'s job is different: given a single **already-wrapped** output value (a mesh +vertex's `x`, after it's been through a proj4 forward projection that only ever returns something in +`(−180°, 180°]`), decide how many multiples of 360° it's actually offset by. That's not a two-value +comparison — it's recovering information that's already been thrown away by the projection. + +## Trying the "obvious" fix: make the reference explicit and piece-relative + +The first instinct is: instead of a hardcoded `tileSize/2`, compare each point against its own +piece's *known* reference corner — the same "compare against a trusted value" idiom `unwrapEastLng` +uses, instead of a magic constant. + +Concretely: west piece's own seam-adjacent corner is always expected at common-space `x = tileSize` +(512); east piece's at `x = 0`. Write the correction as reference-relative: + +```ts +function unwrap(x, reference, worldWidth) { + let corrected = x; + while (corrected - reference > worldWidth / 2) corrected -= worldWidth; + while (corrected - reference < -worldWidth / 2) corrected += worldWidth; + return corrected; +} +``` + +For the west piece, `reference = 512`: `x - 512 < -256` ⟺ `x < 256` → add 512. **Identical to the +current test.** For the east piece, `reference = 0`: `x - 0 > 256` ⟺ `x >= 256` → subtract 512. +**Also identical.** + +This isn't a coincidence to be engineered around — it's forced. For this exact geometry (a tile cut +into precisely two pieces, both anchored at the seam), the *only* legitimate reference for each +piece is 0 or 512 — both of which are literally the antimeridian, just viewed from each piece's own +frame. Their arithmetic midpoint is unavoidably 256. Reframing the test as "reference-relative" +doesn't change what reference to use, because there was never a real choice of reference to make. +**No improvement available here.** + +## Trying to move the correction earlier: unwrap in longitude space, not common space + +Second instinct: EPSG:3857's `x` is *exactly linear* in longitude (`x = R · radians(lng)`, no +trigonometry, unlike the `y` axis) — so what if the wrap correction happened on the longitude +*before* that linear step, using the same `unwrapEastLng`-style "compare against the piece's known +reference longitude" logic, instead of correcting the already-rescaled common-space output? + +This felt promising enough in the first pass of reasoning through it to conclude it might extend the +safe range all the way to just under 360° per piece. That conclusion was **wrong**, and the mistake +is worth keeping visible rather than editing out, because it's the natural way to talk yourself into +believing this direction works: + +- The error was measuring the safe window as "±180° around the reference" and calling that the + piece's *width budget* — i.e. up to 360° total, 180° in each direction. +- But the reference longitude is the piece's own **seam corner** — one *edge* of the piece, not its + center. The piece only extends *away* from that edge in one direction. So the usable budget is the + distance from the reference to the piece's *far* corner in that single direction — which is capped + at just under 180°, not 360°, before you reach the reference's own antipode and the same ambiguity + reappears. + +Redone correctly: doing the correction in longitude-space before the linear rescale hits the +**exact same ±180°-per-piece limit** as doing it in common-space after. Moving where the correction +happens doesn't change how much information was lost — the projection is periodic with period 360° +regardless of which side of the linear rescale you sit on, and no single-reference comparison can +resolve an ambiguity wider than half that period. **No improvement here either.** + +## The actual limit, stated plainly + +Any point-independent correction anchored to a single reference — whether phrased as "is `x` +past the midpoint," "is `x` more than half a world from this piece's known corner," or "is this +point's longitude more than 180° from the seam" — is the same test under different names, and none +of them can disambiguate a 360°-periodic signal beyond **±180° from the reference**. This is a +property of the math (Nyquist-style: you cannot recover a signal's true phase from a wrapped sample +that's more than half a period from your only reference point), not a gap in how cleverly the +formula is written. `unwrapCommonSpaceX`'s width limit was never really "assumes points don't +wander past Greenwich" (256 only numerically coincides with the prime meridian) — it's "assumes each +piece is under ~180° wide," which is the actual, unavoidable constraint on this entire class of +correction. + +**What would genuinely raise the limit**: true neighbor-relative phase unwrapping — walk a piece's +sample points in spatial order and accumulate a running ±360° offset whenever adjacent samples jump +by more than half a period. This is the only way to legitimately handle a piece approaching 360° +wide. It's also exactly the family of fix the antimeridian design doc already tried and explicitly +rejected (PR #374, "sank" — see `dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md`, +"Why not render-as-one"), and it doesn't fit this codebase's current sampling architecture (each of +the 9 reference points / mesh vertices is computed independently, with no defined spatial adjacency +or traversal order to accumulate along). Reintroducing it here would be resurrecting a +previously-rejected approach for a risk that has a much cheaper fix available — see below. + +## The width guard (implemented) + +Since no formula closes the gap, the fix is to detect when a piece would exceed the safe range and +refuse to cut it — the same way an unsupported slanted or curved cut is already refused, rather than +silently producing a torn mesh. + +**`antimeridianCut`** (`antimeridian-cut.ts`) now computes each piece's angular span from the same +unwrapped corner longitudes already used for cut detection — no new data needed: + +```ts +const MAX_PIECE_SPAN_DEG = 170; + +// ...after computing uCut from the top/bottom edge crossings: +const totalSpanDeg = unwrapEastLng(topLeft, topRight) - topLeft; +const westPieceSpanDeg = uCut * totalSpanDeg; +const eastPieceSpanDeg = totalSpanDeg - westPieceSpanDeg; +if ( + westPieceSpanDeg >= MAX_PIECE_SPAN_DEG || + eastPieceSpanDeg >= MAX_PIECE_SPAN_DEG +) { + return undefined; +} +``` + +`170` rather than the mathematical limit of `180` leaves margin against floating-point noise right +at the boundary. Returning `undefined` reuses the exact fallback path a curved or slanted cut +already takes: the caller renders the tile as a single full mesh instead of splitting it — not a +new failure mode, the same one #366 already documented for wide/global tiles, just reached +deliberately instead of by silent corruption. + +Covered by tests in `antimeridian-cut.test.ts`: a piece just under the threshold still cuts +normally, and a piece at or over it returns `undefined` instead of a cut. + +## Limitations (explicit) + +- **The guard doesn't fix wide tiles — it stops them from being silently wrong.** A rejected + crossing tile falls back to a single full mesh, which can still hit the original #366 divergence + (`RasterReprojector` failing to converge) for a genuinely wide/global tile. That's a pre-existing, + documented limitation of the render-as-one fallback, not something this guard solves. +- **No per-point or per-piece formula can raise the 170°/180° limit.** Every variant considered + above — common-space, reference-relative, longitude-space — is the same test in different clothes. + Only neighbor-relative phase unwrapping could, and that's already-rejected territory (#374). +- **Practical exposure is low for normally-tiled data.** A single OGC/zarr tile — including the + PDC/EPSG:3832 data this PR targets — is almost always a small fraction of the globe. The real risk + is concentrated in coarse `z=0` root tiles of lightly-tiled global datasets (the codebase already + has special-casing elsewhere for datasets with very few root tiles, e.g. `MAX_ROOT_TILES_NO_CULL` + in `raster-tile-traversal.ts`) — worth checking if any real dataset's coarsest level can actually + produce a piece this wide, but not an urgent risk for typically-tiled sources. + +## Conclusion + +The GeoJSON west>east convention is already doing the right job everywhere it's used — +`unwrapEastLng`/`antimeridianCut` for cut detection, `normalizeSourceBbox` for Mosaic source +indexing — and neither needs replacing. It does not generalize into a fix for +`unwrapCommonSpaceX`'s per-point placement correction: reframed as reference-relative, it's +arithmetically identical to what's already shipped; moved into longitude-space, it hits the same +±180°-per-piece wall under a different name. The width guard added to `antimeridianCut` — reject and +fall back, not a smarter formula — is the right-sized fix for the actual risk. From d5d1cd84b66f0e75ed0c9dfb130b12af0d7ad02f Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 18:58:09 +1000 Subject: [PATCH 13/21] Plan heuristic fix --- replace-256-x-heuristic.md | 300 +++++++++++++++++-------------------- 1 file changed, 140 insertions(+), 160 deletions(-) diff --git a/replace-256-x-heuristic.md b/replace-256-x-heuristic.md index cd1e807b..4b9cce3a 100644 --- a/replace-256-x-heuristic.md +++ b/replace-256-x-heuristic.md @@ -1,9 +1,13 @@ -# Can the GeoJSON west>east convention replace the `x < 256` heuristic? +# Replacing the `x < 256` antimeridian heuristic -## Background +## Problem -`unwrapCommonSpaceX` (`packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts`) corrects -one reprojected reference point's common-space `x` for an antimeridian-crossing tile: +An antimeridian-crossing tile gets cut into two pieces (west/east), each meshed independently. Every +mesh vertex is reprojected through proj4, which always normalizes longitude to `(−180°, 180°]` — so +a vertex whose true position is just past the seam comes back wrapped to the *opposite* edge of the +map. Each piece needs its wrapped vertices corrected back onto a single contiguous mesh. + +The current correction, `unwrapCommonSpaceX` (`packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts`): ```ts export function unwrapCommonSpaceX(x: number, tileSize: number): number { @@ -11,50 +15,125 @@ export function unwrapCommonSpaceX(x: number, tileSize: number): number { } ``` -This is used both by the traversal's bounding-volume fit (`raster-tile-traversal.ts`) and, mirrored -per piece, by `buildPieceReprojection` (`raster-tileset-2d.ts`) to place each piece's mesh vertices. -It's a **midpoint test**: `tileSize/2` (256, in common-space units) is the threshold. It works for a -normal, narrow tile — but it's a heuristic, not a proof, and it has a real failure mode: a piece -wide enough that its own legitimate points cross the 256 line without ever having wrapped around the -antimeridian at all. - -Elsewhere in this codebase, antimeridian-crossing detection avoids exactly this kind of magnitude -guess by using the GeoJSON bbox convention (RFC 7946 §5.2: `minX > maxX` marks a crossing bbox) — -`unwrapEastLng` and `normalizeSourceBbox`, described below. Given that convention already solves a -structurally similar problem cleanly, this doc asks: can it replace `unwrapCommonSpaceX` too? - -**Short answer: no — not because the convention is wrong, but because the two problems aren't the -same shape.** Detection and per-point placement hit fundamentally different constraints. This doc -works through why, then documents the width guard that was added instead. +is a **magnitude test against a fixed midpoint** (256, half the world). It works for a normal, +narrow tile. It breaks for a piece wide enough that one of its own, legitimately-placed points +crosses that same 256 line without ever having wrapped at all — the test can't tell "this point +wrapped around the seam" apart from "this point is just far from the seam," because both look +identical once reduced to a single magnitude comparison. The result is a torn mesh, silently. -## Where the GeoJSON convention is already used (and why it works there) +## Solution -- **`unwrapEastLng(westLng, eastLng)`** (`antimeridian-cut.ts`): the literal GeoJSON idiom — if - `eastLng < westLng`, add 360°. Used by `edgeUCut`/`antimeridianCut` to detect whether a tile's - corners cross the seam and to locate the cut (`uCut`). -- **`normalizeSourceBbox`** (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts`): the same - idiom applied to a `MosaicLayer` source's bbox before it's indexed into Flatbush. +Correct each point against a **reference computed from that point's own position**, instead of a +single fixed constant for the whole piece. -Both are **corner/bbox-level, one-shot, detection-time** uses: given exactly two known longitudes -(west corner, east corner), decide "does this cross, and if so, where." That's a single yes/no -comparison between two known values — it doesn't care how wide the span between them is. A dataset -whose bbox is 300° wide is detected exactly as reliably as one that's 3° wide, because there's -nothing here to disambiguate — the two corners are given directly, not derived by guessing which -"copy" of a periodic function they came from. +`antimeridianCut` (`antimeridian-cut.ts`) already computes, for every tile, the true continuous +longitude range of each piece — via `unwrapEastLng`, the same "west > east marks a crossing, add +360°" comparison GeoJSON (RFC 7946 §5.2) uses for bboxes. That range is exact and doesn't degrade +with width; it's how `antimeridianCut` locates the seam (`uCut`) in the first place, for a piece of +*any* size. -`unwrapCommonSpaceX`'s job is different: given a single **already-wrapped** output value (a mesh -vertex's `x`, after it's been through a proj4 forward projection that only ever returns something in -`(−180°, 180°]`), decide how many multiples of 360° it's actually offset by. That's not a two-value -comparison — it's recovering information that's already been thrown away by the projection. +Reuse it. For a sample point at parameter `t ∈ [0,1]` along a piece, linearly interpolate between the +piece's two already-known corner longitudes to get an **expected** longitude for that point — pure +arithmetic on two numbers already in hand, no extra proj4 call. Then snap the point's real (wrapped) +projected longitude to the representative nearest that expected value: -## Trying the "obvious" fix: make the reference explicit and piece-relative - -The first instinct is: instead of a hardcoded `tileSize/2`, compare each point against its own -piece's *known* reference corner — the same "compare against a trusted value" idiom `unwrapEastLng` -uses, instead of a magic constant. +``` +corrected = actual + round((expected − actual) / 360) * 360 +``` -Concretely: west piece's own seam-adjacent corner is always expected at common-space `x = tileSize` -(512); east piece's at `x = 0`. Write the correction as reference-relative: +**In plain terms:** the old test asks one global question — "is this point in the left half of the +whole map?" — and treats "yes" as proof it wrapped. But a wide piece can have points that are +genuinely, correctly in the left half without anything having wrapped at all; the test can't tell +the two apart because it only ever looks at one number in isolation. The new test asks a different, +local question instead — "is this point roughly where I'd expect it, given where it sits *within this +piece*?" — and only nudges it by a full world-width if the answer is no. Because the expectation +moves with the point instead of sitting fixed at one spot, it stays a useful check no matter how wide +the piece is. + +**Worked example.** Take a tile whose corners are native lng −100° and +190° (un-normalized, so +west < east as usual) — crossing the seam at 180°, `uCut = (180−(−100))/(190−(−100)) = 280/290`. The +west piece spans native lng −100° to 180°: 280° wide, entirely inside proj4's normal `(−180°,180°]` +range, so *nothing in this piece ever wraps at all*. Take a point near its far edge, native lng −90° +(`t = 10/280 ≈ 0.036`). proj4 returns it unchanged, −90° (no wrap — there's nothing to correct) → +common-space `x ≈ 128`. The old test only looks at that `x`: `128 < 256`, so it "corrects" a point +that was already correct, shifting it a full world-width to `x ≈ 640` and tearing the mesh — the +heuristic doesn't require an actual wrap to misfire, just a piece wide enough to have legitimate +points below the midpoint. + +The new test instead interpolates an expected value for this point: `expected = −100 + 0.036×280 = +−90°` — matching the actual value exactly. `round((−90 − (−90))/360) = 0`, so `corrected = −90°`, +unchanged. It correctly recognizes nothing needs fixing, because its reference was computed from +*this point's own position in the piece*, not a fixed line drawn through the middle of the world. + +## Why this is the most general and robust solution + +**It has no artificial width limit.** The old test's ±180°-from-256 limit comes entirely from using +one fixed reference for an entire piece — any single, fixed reference (256, a corner, anything) can +only disambiguate up to half a period away from itself, and that's true regardless of how the +reference is chosen or where the test is performed (common-space, longitude-space, doesn't matter — +same wall, different name). A *per-point* reference doesn't have this problem: it's derived from +where that specific point actually sits, so it stays close to the true value everywhere across the +piece, no matter how wide the piece is. + +**The only remaining constraint isn't a new one.** The interpolation is only meaningful if the +piece's two corner longitudes describe a real, non-self-overlapping span — i.e. the tile's total +width is under 360°. That's not a limitation this fix introduces or fails to solve: data ≥360° wide +already has two pixels claiming the same real-world longitude, which is invalid regardless of how +it's rendered. No correction, however general, can produce a "correct" placement for data that +doesn't have one. This fix's domain of validity is exactly the domain of valid input. + +**It reuses machinery that's already trusted and already width-independent.** `unwrapEastLng` and +the corner data it produces are the *detection* step, used today for cut location and (via +`normalizeSourceBbox`) Mosaic source indexing — both already work at any width, because comparing +two known longitudes against each other never degrades the way a magnitude test against a fixed +constant does. This fix doesn't add a new mechanism; it extends the reach of the one already proven +correct at the corners down to every interior point. + +**It doesn't reintroduce the fragility of a rejected prior approach.** A generic phase-unwrap that +walks samples in spatial order and accumulates an offset from neighbor to neighbor (tried and +rejected as PR #374) is order-dependent, and one bad sample corrupts everything downstream of it. +This fix computes each point's reference independently, straight from validated corner geometry — +no walk order, no accumulated error, no dependency between points. + +**It keeps the existing architecture.** No change to the cut-into-two-pieces design, no change to +where `RasterReprojector` runs or what it needs — only the per-point correction step inside a piece +that's already known to be isolated on one side of the seam. + +## Prior art already using this idiom (detection only, not placement — until now) + +- **`unwrapEastLng(westLng, eastLng)`** — the GeoJSON west>east idiom itself, used by + `edgeUCut`/`antimeridianCut` to detect a crossing and locate the seam. +- **`normalizeSourceBbox`** (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts`) — the same + idiom applied to a `MosaicLayer` source's bbox before Flatbush indexing. + +Both are one-shot, corner-level comparisons — this fix is the same idea, applied per interior point +instead of once at the corners. + +## Before implementing + +- **`antimeridianCut`'s return type needs extending** — currently just `{ uCut }`; the render and + traversal code need each piece's corner longitudes too, to interpolate against. Natural to add + `westLng`/`unwrappedEastLng` (already computed internally) to `AntimeridianCut`. +- **Mesh vertices for a very wide piece will legitimately land outside `[0, 512]`** (into + neighboring world copies) — expected, and already how deck.gl's repeat rendering works via + `worldOffset` translation. `MAX_MAPS = 3` in `raster-tile-traversal.ts` (and the equivalent in + `mosaic-tileset-2d.ts`) may need to scale with piece width for a genuinely extreme piece rather + than staying a fixed constant. +- **Validate the interpolation assumption against a pathological CRS.** The interpolated reference + only needs to land within 180° of the true value to disambiguate correctly — a low bar for any + reasonably-behaved projection over one tile's extent — but this hasn't been stress-tested outside + the straight/vertical-or-slanted-cut MVP scope this project already targets. +- **`MAX_PIECE_SPAN_DEG` (already implemented, `antimeridian-cut.ts`)** — its role changes from + "reject any piece ≥170° wide" to a check against genuine self-overlap (total tile width ≥360°); + the threshold should move accordingly rather than being dropped outright. + +## Appendix: two fixed-reference variants considered and rejected + +Both were tried first, before landing on the per-point version above — kept here because they're the +reasoning that rules out the "just use the GeoJSON idiom directly" instinct. + +**Piece-relative fixed reference.** Compare each point against its own piece's known corner instead +of a bare constant: ```ts function unwrap(x, reference, worldWidth) { @@ -65,121 +144,22 @@ function unwrap(x, reference, worldWidth) { } ``` -For the west piece, `reference = 512`: `x - 512 < -256` ⟺ `x < 256` → add 512. **Identical to the -current test.** For the east piece, `reference = 0`: `x - 0 > 256` ⟺ `x >= 256` → subtract 512. -**Also identical.** - -This isn't a coincidence to be engineered around — it's forced. For this exact geometry (a tile cut -into precisely two pieces, both anchored at the seam), the *only* legitimate reference for each -piece is 0 or 512 — both of which are literally the antimeridian, just viewed from each piece's own -frame. Their arithmetic midpoint is unavoidably 256. Reframing the test as "reference-relative" -doesn't change what reference to use, because there was never a real choice of reference to make. -**No improvement available here.** - -## Trying to move the correction earlier: unwrap in longitude space, not common space - -Second instinct: EPSG:3857's `x` is *exactly linear* in longitude (`x = R · radians(lng)`, no -trigonometry, unlike the `y` axis) — so what if the wrap correction happened on the longitude -*before* that linear step, using the same `unwrapEastLng`-style "compare against the piece's known -reference longitude" logic, instead of correcting the already-rescaled common-space output? - -This felt promising enough in the first pass of reasoning through it to conclude it might extend the -safe range all the way to just under 360° per piece. That conclusion was **wrong**, and the mistake -is worth keeping visible rather than editing out, because it's the natural way to talk yourself into -believing this direction works: - -- The error was measuring the safe window as "±180° around the reference" and calling that the - piece's *width budget* — i.e. up to 360° total, 180° in each direction. -- But the reference longitude is the piece's own **seam corner** — one *edge* of the piece, not its - center. The piece only extends *away* from that edge in one direction. So the usable budget is the - distance from the reference to the piece's *far* corner in that single direction — which is capped - at just under 180°, not 360°, before you reach the reference's own antipode and the same ambiguity - reappears. - -Redone correctly: doing the correction in longitude-space before the linear rescale hits the -**exact same ±180°-per-piece limit** as doing it in common-space after. Moving where the correction -happens doesn't change how much information was lost — the projection is periodic with period 360° -regardless of which side of the linear rescale you sit on, and no single-reference comparison can -resolve an ambiguity wider than half that period. **No improvement here either.** - -## The actual limit, stated plainly - -Any point-independent correction anchored to a single reference — whether phrased as "is `x` -past the midpoint," "is `x` more than half a world from this piece's known corner," or "is this -point's longitude more than 180° from the seam" — is the same test under different names, and none -of them can disambiguate a 360°-periodic signal beyond **±180° from the reference**. This is a -property of the math (Nyquist-style: you cannot recover a signal's true phase from a wrapped sample -that's more than half a period from your only reference point), not a gap in how cleverly the -formula is written. `unwrapCommonSpaceX`'s width limit was never really "assumes points don't -wander past Greenwich" (256 only numerically coincides with the prime meridian) — it's "assumes each -piece is under ~180° wide," which is the actual, unavoidable constraint on this entire class of -correction. - -**What would genuinely raise the limit**: true neighbor-relative phase unwrapping — walk a piece's -sample points in spatial order and accumulate a running ±360° offset whenever adjacent samples jump -by more than half a period. This is the only way to legitimately handle a piece approaching 360° -wide. It's also exactly the family of fix the antimeridian design doc already tried and explicitly -rejected (PR #374, "sank" — see `dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md`, -"Why not render-as-one"), and it doesn't fit this codebase's current sampling architecture (each of -the 9 reference points / mesh vertices is computed independently, with no defined spatial adjacency -or traversal order to accumulate along). Reintroducing it here would be resurrecting a -previously-rejected approach for a risk that has a much cheaper fix available — see below. - -## The width guard (implemented) - -Since no formula closes the gap, the fix is to detect when a piece would exceed the safe range and -refuse to cut it — the same way an unsupported slanted or curved cut is already refused, rather than -silently producing a torn mesh. - -**`antimeridianCut`** (`antimeridian-cut.ts`) now computes each piece's angular span from the same -unwrapped corner longitudes already used for cut detection — no new data needed: - -```ts -const MAX_PIECE_SPAN_DEG = 170; - -// ...after computing uCut from the top/bottom edge crossings: -const totalSpanDeg = unwrapEastLng(topLeft, topRight) - topLeft; -const westPieceSpanDeg = uCut * totalSpanDeg; -const eastPieceSpanDeg = totalSpanDeg - westPieceSpanDeg; -if ( - westPieceSpanDeg >= MAX_PIECE_SPAN_DEG || - eastPieceSpanDeg >= MAX_PIECE_SPAN_DEG -) { - return undefined; -} -``` - -`170` rather than the mathematical limit of `180` leaves margin against floating-point noise right -at the boundary. Returning `undefined` reuses the exact fallback path a curved or slanted cut -already takes: the caller renders the tile as a single full mesh instead of splitting it — not a -new failure mode, the same one #366 already documented for wide/global tiles, just reached -deliberately instead of by silent corruption. - -Covered by tests in `antimeridian-cut.test.ts`: a piece just under the threshold still cuts -normally, and a piece at or over it returns `undefined` instead of a cut. - -## Limitations (explicit) - -- **The guard doesn't fix wide tiles — it stops them from being silently wrong.** A rejected - crossing tile falls back to a single full mesh, which can still hit the original #366 divergence - (`RasterReprojector` failing to converge) for a genuinely wide/global tile. That's a pre-existing, - documented limitation of the render-as-one fallback, not something this guard solves. -- **No per-point or per-piece formula can raise the 170°/180° limit.** Every variant considered - above — common-space, reference-relative, longitude-space — is the same test in different clothes. - Only neighbor-relative phase unwrapping could, and that's already-rejected territory (#374). -- **Practical exposure is low for normally-tiled data.** A single OGC/zarr tile — including the - PDC/EPSG:3832 data this PR targets — is almost always a small fraction of the globe. The real risk - is concentrated in coarse `z=0` root tiles of lightly-tiled global datasets (the codebase already - has special-casing elsewhere for datasets with very few root tiles, e.g. `MAX_ROOT_TILES_NO_CULL` - in `raster-tile-traversal.ts`) — worth checking if any real dataset's coarsest level can actually - produce a piece this wide, but not an urgent risk for typically-tiled sources. - -## Conclusion - -The GeoJSON west>east convention is already doing the right job everywhere it's used — -`unwrapEastLng`/`antimeridianCut` for cut detection, `normalizeSourceBbox` for Mosaic source -indexing — and neither needs replacing. It does not generalize into a fix for -`unwrapCommonSpaceX`'s per-point placement correction: reframed as reference-relative, it's -arithmetically identical to what's already shipped; moved into longitude-space, it hits the same -±180°-per-piece wall under a different name. The width guard added to `antimeridianCut` — reject and -fall back, not a smarter formula — is the right-sized fix for the actual risk. +For the west piece (`reference = 512`): `x - 512 < -256 ⟺ x < 256` — identical to the current test. +Same for east (`reference = 0`). Not a coincidence: for this geometry, the only legitimate *fixed* +reference for each piece is 0 or 512, both literally the antimeridian viewed from that piece's own +frame — so an explicit reference-relative version collapses to the exact arithmetic already shipped. + +**Unwrap in longitude space before the linear rescale.** EPSG:3857's `x` is exactly linear in +longitude, so try correcting the longitude (via the `unwrapEastLng` comparison) before that linear +step instead of after. This looked promising enough on a first pass to conclude it might extend the +safe range to just under 360° — worth keeping the mistake visible, since it's the natural way to +talk yourself into thinking this direction works. The error: treating "±180° around the reference" +as the piece's *whole* width budget (360° total), when the reference is one *edge* of the piece, not +its center — the piece only extends away from that edge in one direction, so the usable budget in +that direction is still capped at just under 180°. Moving the correction earlier doesn't change how +much information the projection already threw away — same limit, different domain. + +Both share the same root cause: a single, fixed reference for the whole piece can only disambiguate +±180° around itself, regardless of how cleverly the reference is chosen or which domain the +comparison happens in. That part of the reasoning holds. What doesn't hold is that a fixed reference +was the only option — see "Solution" above. From 9a8c7d6348762b6f7b73b1912e63caf201c9c834 Mon Sep 17 00:00:00 2001 From: willjnz Date: Tue, 29 Sep 2026 19:21:27 +1000 Subject: [PATCH 14/21] Fix antimeridian heuristic --- PR.md | 38 ---- ...05-27-antimeridian-crossing-tile-design.md | 44 ++++- dev-docs/world-copies.md | 33 +++- examples/antimeridian-example/src/App.tsx | 5 +- .../antimeridian-example/src/render-gray.ts | 2 +- .../src/App.tsx | 4 +- .../src/mosaic-layer/mosaic-tileset-2d.ts | 8 +- .../deck.gl-geotiff/src/multi-cog-layer.ts | 8 +- .../multi-cog-layer-debug-outline.test.ts | 12 +- .../src/raster-tileset/antimeridian-cut.ts | 95 ++++++---- .../raster-tileset/raster-tile-traversal.ts | 17 +- .../src/raster-tileset/raster-tileset-2d.ts | 61 +++---- .../raster-tileset/antimeridian-cut.test.ts | 78 ++++++++- .../raster-tileset-2d-antimeridian.test.ts | 45 +++++ replace-256-x-heuristic.md | 165 ------------------ 15 files changed, 309 insertions(+), 306 deletions(-) delete mode 100644 PR.md delete mode 100644 replace-256-x-heuristic.md diff --git a/PR.md b/PR.md deleted file mode 100644 index d55332a3..00000000 --- a/PR.md +++ /dev/null @@ -1,38 +0,0 @@ -This PR aims to test the [open antimeridian PR](https://github.com/developmentseed/deck.gl-raster/pull/576) and make it functional. I am using tiled Digital Earth Pacific data that is in PDC (EPSG:3832) which passes the vertical (not slanted) cut condition. One column of tiled COGs span the antimeridian. - -The [above linked PR](dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md) sets up a good approach. This PR continues this split-in-two mesh design. This PR also aims to follow https://www.gadom.ski/antimeridian/latest/ where relevant. - -This PR has significant overlap with my earlier PR https://github.com/developmentseed/deck.gl-raster/pull/639, and should replace it. - -This PR has 2 examples, a simple COGLayer spanning the antimeridian, and a MosaicLayer/MultiCOGLayer example. - - -#### Technical details: -- RasterTileLayer._renderAntimeridianTile emits two RasterLayers (west/east), each seeded via triangulateRectangle from its own UV sub-domain, sharing one texture — exactly the "split in the sublayer factory, everything else keeps its single-mesh contract" design. -- Cut detection matches the MVP's stated scope. antimeridianCut/edgeUCut — vertical-only, rejects slanted (U_EPSILON tolerance), falls back to full mesh otherwise. Matches the code's own docstring; the design doc's broader "vertical and slanted" scope claim was already aspirational in #576, not something this branch touched either way. - -- All 3 of kylebarron's review comments on antimeridian-cut.ts are addressed: - - U_EPSILON now has a docstring - - edgeUCut was hoisted to a top-level function - - antimeridianCut's docstring now explicitly says general antimeridian handling should eventually be supported, just isn't yet -- Test plan items present: cut-location unit tests (antimeridian-cut.test.ts), piece-reprojection unit tests (raster-tileset-2d-antimeridian.test.ts), and the cog-basic dev-only antimeridian.tif fixture entry from #576 is still intact and untouched. - -- One real discrepancy — the design doc (`2026-05-27-antimeridian-crossing-tile-design.md`) is now stale on seam handling: - - **What the doc says**: its "Seam handling" section describes the fix as "local to the wrapped (negative-side) piece only... if the projected X comes back positive, subtract one world-width." That's the original #576 mechanism. - - **Why it's wrong**: a single constant-per-piece shift breaks at the piece's own boundary corner, which coincides with the other piece's edge and needs no shift at all. - - **What this branch does instead**: one symmetric per-point test applied to both pieces, in common-space, after `projectPosition` — not piece-specific, not a native-CRS sign check. - - **Status**: fixed — `dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md`'s "Seam handling" section now describes the shipped symmetric mechanism (old text kept, struck through, for history), and its "Traversal" bullet, "Edge cases", and "Test plan" sections are updated to match. Also added a new "Locating and selecting a crossing tile in the traversal" section documenting the fix described below. - -- MosaicLayer/MultiCOGLayer support: `normalizeSourceBbox` (mosaic-layer.ts) unwraps a GeoJSON-flipped source bbox (RFC 7946 §5.2: `minX > maxX` marks a crossing) onto a continuous frame before it's indexed into Flatbush, so the spatial index, the priority-queue distance calculation, and `MosaicTileset2D`'s own viewport search all agree on one bbox per source. `MultiCOGLayer`'s debug overlay (`_renderDebugLayers`/`pieceBoxWgs84`) splits into matching west/east boxes for a crossing tile — `MultiCOGLayer extends RasterTileLayer`, so the actual mesh-splitting/reprojection path is shared automatically; only the debug-outline drawing (which works in plain lng/lat, not common-space) needed its own antimeridian handling. - -- This PR also covers visualising just one side of the AM-split COG/Mosaic. An earlier version of this PR would not show the right/east side of an am-split texture, if the left/west side was not in view. Root cause: `raster-tile-traversal.ts`'s frustum-culling traversal had zero antimeridian awareness, three compounding defects: - 1. `_getGenericBoundingVolume` sampled 9 reference points per tile with no antimeridian correction — for a crossing tile some land on each edge of common space, producing a bounding volume wide enough to be found only by accident (when the seam itself was in view). Fixed by extracting the wrap-correction already used in `buildPieceReprojection` into a shared `unwrapCommonSpaceX(x, tileSize)` helper (`antimeridian-cut.ts`) and applying it before fitting the box. - 2. Once (1) was fixed, the dataset-level `insideBounds` pre-filter in `getTileIndices` — built from the same lossy min/max `wgs84Bounds` — only *touched* the now-tight per-tile box at the seam instead of overlapping it, rejecting the tile at every zoom. Fixed by recomputing the dataset's own bounds from its real corner longitudes when the dataset itself crosses, instead of patching the already-mixed-up min/max. - 3. `buildPieceReprojection` built one shared reprojection for both pieces, always anchoring both near common-space `x≈512` — correct when both pieces are in view together, wrong once only the east piece is in view (its mesh stayed a full world away from the camera, selected but never drawn on screen). Fixed by mirroring the correction per piece so each renders at its own natural position; deck.gl's existing repeat-rendering (`subViewports`) already draws whatever world copies the camera needs. - - The world-copy offset-pass gate also changed from `subViewports.length > 1` to `subViewports != null` in both traversal files — the former only tests whether the *viewport itself* currently straddles a seam, not whether a tile's (or, for `MosaicTileset2D`'s Flatbush-based, lng/lat bbox search in `mosaic-tileset-2d.ts`, a source's) own position needs a shifted pass to be found once zoomed in tight to one side alone. - -#### Test PR: -I have made a frankenstein monster of my 3 PRs to test, and they work well together for my needs (a combo of MosaicLayer, MultiCOGLayer that spans the antimeridian and needs nearest sampling). https://github.com/willjnz/deck.gl-raster/tree/willjnz/frankenstein. This combines: -- https://github.com/willjnz/deck.gl-raster/tree/willjnz/antimeridian-crossing-fixes -- https://github.com/developmentseed/deck.gl-raster/pull/640 -- https://github.com/developmentseed/deck.gl-raster/pull/639 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 352245c0..fa220c27 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 @@ -43,23 +43,49 @@ The antimeridian becomes *a tile boundary*, which the pipeline already handles, ### Seam handling -> **Updated 2026-09-29** — this section originally described a "wrapped-piece-only, output-sign-based" mechanism (below, struck through for the record). This was replaced by the symmetric per-point rule described first (below). See the implemented version's tests in `raster-tileset-2d-antimeridian.test.ts` and the traversal fix this motivated in "Locating and selecting a crossing tile in the traversal" below. +> **Revised twice since first written — 2026-09-29.** This section has gone through three +> mechanisms as the failure modes of each were found: (1) the original design (struck through +> below) — a wrapped-piece-only sign test; (2) a symmetric per-point midpoint test that fixed (1)'s +> bug but turned out to have its own width limit; (3) the mechanism described first below, which +> fixes (2)'s limit too and is what's actually shipped. See the tests in +> `raster-tileset-2d-antimeridian.test.ts` and `antimeridian-cut.test.ts`, and the traversal fix this +> motivated in "Locating and selecting a crossing tile in the traversal" below. 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. -**As implemented**, the fix is a **symmetric per-point unwrap**, applied in common-space, after `projectPosition` — not piece-specific, not an input-longitude test. Both pieces' `forwardReproject` run every vertex's raw common-space X through a shared helper, `unwrapCommonSpaceX(x, tileSize)` ([`antimeridian-cut.ts`](../../packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts)), then each piece applies the mirror-image correction: +**As implemented**, the fix corrects each vertex's raw (proj4-wrapped) common-space `x` using a reference computed from *that vertex's own position*, not a single value shared by the whole piece. Every tile that crosses the antimeridian already gets its seam located by `antimeridianCut` ([`antimeridian-cut.ts`](../../packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts)), which — via `unwrapEastLng`, the GeoJSON (RFC 7946 §5.2) "west > east marks a crossing" idiom — locates the seam as a fraction `uCut` of the tile's width, and records the tile's total angular width `totalSpanDeg`. `unwrapCommonSpaceX(x, u, cut, tileSize)` reuses exactly that: for a vertex at fractional position `u` along the tile (0 at the west edge, 1 at the east edge — the same domain `uCut` lives in), it computes -- **West piece**: any vertex whose raw X wrapped onto the low end (`x < tileSize/2`) gets `+ tileSize`, joining the west piece's own high-x interior. (This is the rule the struck-through mechanism below also got right — it's only wrong for the *other* piece.) -- **East piece**: the mirror image — any vertex still sitting on the west piece's high-x frame (`x >= tileSize/2`, which includes the shared seam corner) gets `− tileSize`, joining the east piece's own near-0 interior. +```ts +const signedDegreesFromSeam = (u - cut.uCut) * cut.totalSpanDeg; +const expectedX = tileSize + signedDegreesFromSeam * (tileSize / 360); +return x + Math.round((expectedX - x) / tileSize) * tileSize; +``` -`inverseReproject` mirrors the correction (subtract for west, add for east, only where the corrected value would otherwise land outside `[0, tileSize)`), so round-tripping stays exact. +— an *expected* `x` derived purely from the vertex's own distance from the seam, then snaps the actual wrapped `x` to the representative nearest that expectation. The seam itself (`u = cut.uCut`) always maps to exactly `tileSize`; the west side extends below it, the east side above it — this is the "combined" form the traversal's bounding volume wants directly (one contiguous box around the seam). `buildPieceReprojection` reuses it for the west piece's `forwardReproject` unchanged, and re-anchors the east piece into its own local frame (seam at `0`, not `tileSize`) by subtracting `tileSize`. `inverseReproject` undoes that re-anchor and reduces into the canonical `[0, tileSize)` range (`((x % tileSize) + tileSize) % tileSize`) — this exactly inverts whatever integer multiple of `tileSize` the forward direction applied, without needing to replay the correction logic. One outcome worth calling out: this gives each piece its own **natural** common-space position (west near `x≈512`, east near `x≈0`) instead of always anchoring both near a single shared frame. That turned out to be load-bearing for the traversal fix below — a piece now needs no help from the other piece (or the seam) being in view to be found and drawn correctly. -**Why the original mechanism (below) doesn't work:** a single constant-per-piece shift, decided by *which piece* is rendering, is wrong exactly at that piece's own boundary corner — the vertex that coincides with the *other* piece's edge and needs no shift at all. A per-piece constant can't tell "this piece's own interior vertex that happens to be wrapped" apart from "this piece's own boundary vertex that must stay put," because whether a given vertex needs the shift depends on *that vertex's own position*, not on which piece asked. The per-point test above (`x < tileSize/2` vs. `x >= tileSize/2`) gets every vertex right, including the exact seam corner, because it tests the vertex, not the piece. +#### Why a per-point, position-derived reference — not a fixed one + +Two earlier, simpler mechanisms were tried and superseded before landing on the one above; both are worth keeping visible because they're the reasoning that rules out the "obvious" fixes. + +**Attempt 1 (original design, struck through below): a wrapped-piece-only sign test.** A single constant-per-piece shift, decided by *which piece* is rendering, is wrong exactly at that piece's own boundary corner — the vertex that coincides with the *other* piece's edge and needs no shift at all. A per-piece constant can't tell "this piece's own interior vertex that happens to be wrapped" apart from "this piece's own boundary vertex that must stay put," because whether a given vertex needs the shift depends on *that vertex's own position*, not on which piece asked. + +**Attempt 2: a symmetric per-point midpoint test** (`x < tileSize/2 → +tileSize`, mirrored for east) — fixed attempt 1's bug by testing every vertex rather than trusting the piece label, and shipped for a while. It has a subtler, width-dependent failure: it's a *magnitude* test against a fixed midpoint, and once a piece is wide enough (≳180° of longitude), some of its own legitimately-placed vertices land on the "wrong" side of that midpoint *without having wrapped at all* — the test can't tell "this vertex wrapped around the seam" apart from "this vertex is just far from the seam," because both look identical once reduced to one magnitude comparison against a constant. The result is a silently torn mesh for a wide enough tile. + +Reframed generally: **any correction anchored to a single, fixed reference for the whole piece — however that reference is chosen** (a midpoint, a piece-specific corner, a comparison done in longitude space instead of common-space) **— can only disambiguate up to half a period (180°) away from itself.** That's a property of the math (recovering a 360°-periodic signal's true phase from one sample more than half a period from your only reference is not possible), not an implementation gap. Two specific variants were tried and confirmed to hit this wall: + +- **Piece-relative fixed reference** (compare each vertex against its own piece's known seam corner, `TILE_SIZE` for west / `0` for east, instead of the bare `tileSize/2` constant) turns out to be *arithmetically identical* to attempt 2 — for this geometry, the only legitimate fixed reference for each piece already *is* `0` or `TILE_SIZE`, so renaming the constant changes nothing. +- **Unwrapping in longitude space before the linear rescale** (EPSG:3857's `x` is exactly linear in longitude, so correct the longitude via the `unwrapEastLng` comparison before that linear step, instead of correcting the wrapped `x` after) looked promising enough on a first pass to conclude it might extend the safe range to just under 360° — the reference sits at one *edge* of the piece, not its center, so the usable budget in the one direction the piece extends is still capped at just under 180°. Moving the correction earlier doesn't change how much information the projection already threw away. + +**What breaks the wall**: the reference doesn't have to be fixed — it can be *recomputed per vertex* from geometry that's already trusted and already width-independent. `unwrapEastLng` and the corner data `antimeridianCut` derives from it are exactly this: a one-shot, corner-level comparison (`normalizeSourceBbox` in `mosaic-layer.ts` uses the identical idiom for `MosaicLayer` source indexing) that works at *any* width, because comparing two known longitudes against each other never degrades the way a magnitude test against a constant does. `unwrapCommonSpaceX`'s `expectedX` is that same idiom's output, extended from "once, at the corners" to "at every vertex's own position" — each vertex's reference tracks where *that vertex* sits, so it stays close to the true value everywhere across the piece, no matter how wide the piece is. + +This also isn't the neighbor-relative phase-unwrap family that sank #374 (walking samples in spatial order, accumulating an offset from neighbor to neighbor — order-dependent, and one bad sample corrupts everything downstream). Each vertex's reference here is computed independently, straight from validated corner geometry; there's no walk order and no dependency between vertices. + +**The one limit that's left isn't a new one.** The correction is only meaningful if the tile's own corner longitudes describe a real, non-self-overlapping span — total width under 360°. A tile ≥360° wide already has two pixel columns claiming the same real-world longitude, which is invalid data with no correct rendering regardless of how it's cut or corrected — not a limitation this fix introduces or fails to solve. `antimeridianCut` rejects such a tile outright (`MAX_TOTAL_SPAN_DEG = 359.9°`, checked once against the tile's total width), falling back to a single full-mesh render the same way an unsupported slanted or curved cut does — see "Locating and selecting a crossing tile in the traversal" below. (An earlier revision of this fix instead rejected any *piece* ≥170° wide — `MAX_PIECE_SPAN_DEG` — before the per-vertex mechanism above removed that narrower limit.)
-Original text (superseded, kept for history) +Attempt 1's original text (superseded, kept for history) 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. @@ -75,6 +101,8 @@ The fix is local to the **wrapped (negative-side) piece** only: in its `forwardR The same three-defect pattern, adapted to `MosaicTileset2D`'s structurally different Flatbush/lng-lat-bbox spatial index (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts`), needed only the world-copy-gate fix (its own version of #3) — a crossing source's bbox is normalized onto a continuous frame by `normalizeSourceBbox` (`mosaic-layer.ts`) before indexing, so it's found only by a query shifted ±360°, ±720°…, which the same `subViewports != null` gate now permits once zoomed in tight. `MultiCOGLayer extends RasterTileLayer`, so it already inherited defects #1/#2's fixes (and the seam-handling fix above) for free via the shared mesh/traversal code. +Defect #3's `MAX_MAPS = 3` cap (the number of extra world-copy offset passes the traversal walks) turned out not to need any change for wide pieces either, despite the "Seam handling" fix above allowing much wider pieces than before: each piece's corrected `x` is bounded by construction to `[0, tileSize]` for any tile under the `MAX_TOTAL_SPAN_DEG` limit, so a piece never needs more than the world copies ordinary (non-crossing) tiles already rely on. See [`world-copies.md`](../world-copies.md) for the general offset-search mechanism this all sits on top of. + ### 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. @@ -105,7 +133,7 @@ RasterTileLayer._renderSubLayers (per tile) ← the only split point - **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** — originally planned as a **two-box bounding volume** for a crossing tile (west ≈ `[510,512]`, east ≈ `[0,2]`). **As implemented**, it's one tight box instead: the traversal's `_getGenericBoundingVolume` (`raster-tile-traversal.ts`) applies the same `unwrapCommonSpaceX` correction used by `forwardReproject` to its 9 sampled reference points before fitting the `OrientedBoundingBox`, so a crossing tile gets a single box straddling `x=512` rather than one spanning most of `[0,512]`. See "Locating and selecting a crossing tile in the traversal" below for why a second, independent fix was also needed here. +- **Traversal** — originally planned as a **two-box bounding volume** for a crossing tile (west ≈ `[510,512]`, east ≈ `[0,2]`). **As implemented**, it's one tight box instead: the traversal's `_getGenericBoundingVolume` (`raster-tile-traversal.ts`) applies the same `unwrapCommonSpaceX(x, u, cut, tileSize)` correction used by `forwardReproject` to its 9 sampled reference points (`u` = each point's own `relX`) before fitting the `OrientedBoundingBox`, so a crossing tile gets a single box straddling `x=512` rather than one spanning most of `[0,512]` — correct for a piece of any width up to the `MAX_TOTAL_SPAN_DEG` self-overlap limit, not just a narrow one. See "Locating and selecting a crossing tile in the traversal" below for why a second, independent fix was also needed here. ## Transparency to end users 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-example/src/App.tsx b/examples/antimeridian-example/src/App.tsx index debcf18e..6a022dcf 100644 --- a/examples/antimeridian-example/src/App.tsx +++ b/examples/antimeridian-example/src/App.tsx @@ -86,9 +86,8 @@ export default function App() { > All {redBandUrls.length || 12}{" "} - COGs in the - test catalog: 3 cross the ±180° antimeridian, the other 9 don't — - see{" "} + COGs in the test + catalog: 3 cross the ±180° antimeridian, the other 9 don't — see{" "} #575 diff --git a/examples/antimeridian-example/src/render-gray.ts b/examples/antimeridian-example/src/render-gray.ts index be6f6708..60589cfe 100644 --- a/examples/antimeridian-example/src/render-gray.ts +++ b/examples/antimeridian-example/src/render-gray.ts @@ -1,10 +1,10 @@ +import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; import { BlackIsZero, CreateTexture, LinearRescale, } from "@developmentseed/deck.gl-raster/gpu-modules"; -import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; import type { Texture } from "@luma.gl/core"; diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index 710d664d..743573fc 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -97,8 +97,8 @@ export default function App() { antimeridian-example , but composed as R/G/B through MosaicLayer +{" "} - MultiCOGLayer (the needed use pattern), - not just a single-band COGLayer. See{" "} + MultiCOGLayer (the needed use pattern), not just a + single-band COGLayer. See{" "} #575 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 7709e3fe..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 @@ -127,12 +127,16 @@ export class MosaicTileset2D extends Tileset2D { // 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)) { + if ( + !searchAtOffset(index, viewportBounds, worldOffset, matchedIndices) + ) { break; } } for (let worldOffset = 1; worldOffset <= MAX_MAPS; worldOffset++) { - if (!searchAtOffset(index, viewportBounds, worldOffset, matchedIndices)) { + if ( + !searchAtOffset(index, viewportBounds, worldOffset, matchedIndices) + ) { break; } } diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 7f132f8c..eb93479e 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -1118,8 +1118,12 @@ export function pieceBoxWgs84( const refLng = rawCorners[0]![0]; const path: [number, number][] = rawCorners.map(([lng, lat]) => { let unwrapped = lng; - while (unwrapped - refLng > 180) unwrapped -= 360; - while (unwrapped - refLng < -180) unwrapped += 360; + while (unwrapped - refLng > 180) { + unwrapped -= 360; + } + while (unwrapped - refLng < -180) { + unwrapped += 360; + } return [unwrapped, lat]; }); const xs = path.map((p) => p[0]); diff --git a/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts b/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts index 9c38bd92..f0c407eb 100644 --- a/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts +++ b/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts @@ -8,9 +8,15 @@ import { pieceBoxWgs84 } from "../src/multi-cog-layer.js"; * corner) values observed for the DEP GeoMAD `066/022` crossing item. */ function crossingProjectTo4326(px: number): [number, number] { - if (px === 0) return [179.9677978782272, -15.991730594838703]; - if (px === 11.949079327358657) return [180, -15.991730594838703]; - if (px === 320) return [-179.169819449018, -15.991730594838703]; + if (px === 0) { + return [179.9677978782272, -15.991730594838703]; + } + if (px === 11.949079327358657) { + return [180, -15.991730594838703]; + } + if (px === 320) { + return [-179.169819449018, -15.991730594838703]; + } throw new Error(`unexpected px ${px}`); } diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index f86dcb2b..06fafc60 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -23,6 +23,14 @@ export interface CornerLongitudes { export interface AntimeridianCut { /** UV u-coordinate (0..1) where the tile crosses ±180°. */ uCut: number; + /** + * Total angular width of the tile (both pieces combined, in degrees) — + * `unwrapEastLng(topLeft, topRight) - topLeft` from the edge used to + * locate the cut. Used by {@link unwrapCommonSpaceX} to convert a point's + * `u` distance from the seam into degrees, and by {@link antimeridianCut} + * itself to reject a self-overlapping (≥360°) tile. + */ + totalSpanDeg: number; } /** @@ -33,25 +41,20 @@ export interface AntimeridianCut { const U_EPSILON = 1e-6; /** - * Maximum longitude span (degrees) either piece may have after the cut. + * Maximum total angular width (degrees, both pieces combined) a tile may + * have and still be cut. * - * `unwrapCommonSpaceX`'s per-point correction (and `buildPieceReprojection`'s - * matching per-vertex correction) assumes a piece's own interior never - * legitimately crosses the common-space halfway point (x = tileSize/2, - * i.e. the piece spans less than 180° of longitude): any point found past - * that mark is treated as a wrapped artifact of the seam and shifted a full - * world-width. For a piece ≥ 180° wide that assumption breaks — a point can - * legitimately cross the halfway mark without having wrapped at all, and - * gets wrongly shifted, tearing the mesh. There is no simple per-point or - * per-piece fix for this (a true fix needs a continuous per-point unwrap, - * which doesn't generalize to arbitrary source CRSs — see the antimeridian - * design doc's "Locating and selecting a crossing tile in the traversal" - * section), so a piece exceeding this width is rejected outright, the same - * way a curved (non-vertical) cut is: the caller falls back to a single - * full-mesh render. 170° (not the mathematical limit of 180°) leaves a - * margin against floating-point noise right at the boundary. + * This is not an accuracy limit on {@link unwrapCommonSpaceX} — its per-point + * correction is derived from each point's own distance from the seam (in + * already-validated corner longitudes), not a magnitude test against a fixed + * midpoint, so it has no per-piece width limit (see + * `replace-256-x-heuristic.md`). The one limit that remains is physical, not + * a property of this code: a tile ≥360° wide has two pixel columns claiming + * the same real-world longitude — self-overlapping, invalid data with no + * correct rendering, cut or not. 359.9° (not the exact boundary of 360°) + * leaves a margin against floating-point noise. */ -const MAX_PIECE_SPAN_DEG = 170; +const MAX_TOTAL_SPAN_DEG = 359.9; /** * Unwrap a GeoJSON-flipped edge (RFC 7946 §5.2: west > east marks a @@ -66,15 +69,38 @@ export function unwrapEastLng(westLng: number, eastLng: number): number { /** * Correct one reprojected reference point's common-space x for an - * antimeridian-crossing tile: raw x in the lower half `[0, tileSize/2)` is - * on the wrapped side (proj4 normalized it back into range) and gets - * `+tileSize`, continuing past the near-180 side instead of wrapping to the - * start of the world. No-op for a point already on the near-180 side, - * including the exact seam boundary. See `buildPieceReprojection` for the - * full reasoning (same rule, applied there per-piece at render time). + * antimeridian-crossing tile, given the point's own fractional position `u` + * (0..1) along the tile's pixel/UV domain — the same domain `cut.uCut` is + * defined in. + * + * Rather than testing the already-wrapped `x` against a fixed midpoint + * (`tileSize/2`) — which can't tell "this point wrapped around the seam" + * apart from "this point is just far from the seam" once the tile is wide — + * this computes an *expected* `x` directly from the point's own signed + * distance from the seam (`(u - cut.uCut) * cut.totalSpanDeg`, in degrees, + * using the already-validated corner longitudes `antimeridianCut` located + * the seam from) and snaps `x` to the representative nearest that + * expectation. The seam itself (`u = cut.uCut`) always maps to exactly + * `tileSize`; the west side (`u < cut.uCut`) extends below it, the east side + * (`u > cut.uCut`) extends above it. See `replace-256-x-heuristic.md` for + * the full derivation and why this has no per-piece width limit. + * + * This is the "combined" form used directly by the traversal's bounding + * volume, which wants one contiguous box spanning both pieces around the + * seam (west below `tileSize`, east above it). `buildPieceReprojection`'s + * east-piece branch re-anchors the result into its own local frame (seam at + * `0`, not `tileSize`) by subtracting `tileSize` — see that method's doc + * comment. */ -export function unwrapCommonSpaceX(x: number, tileSize: number): number { - return x < tileSize / 2 ? x + tileSize : x; +export function unwrapCommonSpaceX( + x: number, + u: number, + cut: AntimeridianCut, + tileSize: number, +): number { + const signedDegreesFromSeam = (u - cut.uCut) * cut.totalSpanDeg; + const expectedX = tileSize + signedDegreesFromSeam * (tileSize / 360); + return x + Math.round((expectedX - x) / tileSize) * tileSize; } /** @@ -113,10 +139,9 @@ function edgeUCut(westLng: number, eastLng: number): number | undefined { * cuts (non-geographic CRSs) — but for now those return `undefined` and fall * back to a single full-mesh layer. See issue #575. * - * Also rejects a cut where either resulting piece would be ≥ - * {@link MAX_PIECE_SPAN_DEG} wide — see that constant's doc comment — falling - * back to a single full-mesh layer the same way an unsupported slanted or - * curved cut does. + * Also rejects a tile whose total width would be ≥ {@link MAX_TOTAL_SPAN_DEG} + * — see that constant's doc comment — falling back to a single full-mesh + * layer the same way an unsupported slanted or curved cut does. * * Assumes u increases eastward (standard north-up geotransform). Corner * longitudes may be in either encoding described on {@link CornerLongitudes}. @@ -139,17 +164,11 @@ export function antimeridianCut( } const uCut = (topUCut + bottomUCut) / 2; - // Reject a piece too wide for unwrapCommonSpaceX's halfway-point - // assumption to hold — see MAX_PIECE_SPAN_DEG's doc comment. + // Reject a self-overlapping tile — see MAX_TOTAL_SPAN_DEG's doc comment. const totalSpanDeg = unwrapEastLng(topLeft, topRight) - topLeft; - const westPieceSpanDeg = uCut * totalSpanDeg; - const eastPieceSpanDeg = totalSpanDeg - westPieceSpanDeg; - if ( - westPieceSpanDeg >= MAX_PIECE_SPAN_DEG || - eastPieceSpanDeg >= MAX_PIECE_SPAN_DEG - ) { + if (totalSpanDeg >= MAX_TOTAL_SPAN_DEG) { return undefined; } - return { uCut }; + return { uCut, totalSpanDeg }; } 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 c9c4143d..de7e5d7b 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 @@ -549,9 +549,16 @@ export class RasterTileNode { this.descriptor.projectTo4326, ); - const commonSpacePositions: [number, number][] = refPointsEPSG3857 - .map((xy) => rescaleEPSG3857ToCommonSpace(xy)) - .map(([x, y]) => (cut ? [unwrapCommonSpaceX(x, TILE_SIZE), y] : [x, y])); + const commonSpacePositions: [number, number][] = refPointsEPSG3857.map( + (xy, i) => { + const [x, y] = rescaleEPSG3857ToCommonSpace(xy); + if (!cut) { + return [x, y]; + } + const u = REF_POINTS_9[i]![0]; + return [unwrapCommonSpaceX(x, u, cut, TILE_SIZE), y]; + }, + ); const refPointPositions: [number, number, number][] = []; for (const p of commonSpacePositions) { @@ -999,10 +1006,14 @@ export function getTileIndices( // per-point unwrap rule used for a single tile's reference points. bottomLeft[0] = unwrapCommonSpaceX( lngLatToWorld([datasetWestLng, minLat])[0], + 0, + datasetCut, TILE_SIZE, ); topRight[0] = unwrapCommonSpaceX( lngLatToWorld([datasetEastLng, maxLat])[0], + 1, + datasetCut, TILE_SIZE, ); } 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 2739ae22..5e9bfc66 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 @@ -455,11 +455,15 @@ export class RasterTileset2D extends Tileset2D { forwardTransform, inverseTransform, "west", + _antimeridianCut, + tileWidth, ); _eastReprojection = this.buildPieceReprojection( forwardTransform, inverseTransform, "east", + _antimeridianCut, + tileWidth, ); } @@ -493,27 +497,19 @@ export class RasterTileset2D extends Tileset2D { /** * Build a per-piece reprojection bundle for an antimeridian-crossing tile. * - * A crossing tile's west piece always has native lngs just *below* +180° - * (e.g. 179.97°), which `projectPosition` maps to common-space x just - * *below* `TILE_SIZE` (e.g. 511.95) — no correction needed. The east - * piece's *interior* has native lngs just past −180° after proj4's - * wraparound (e.g. −179.17°), mapping to common-space x just *above* 0 - * (e.g. 1.18) — already its own natural, internally-consistent position. - * The east piece's own west edge *is* the seam itself (native lng exactly - * 180°), which `projectPosition` maps to common-space x exactly - * `TILE_SIZE` — the *same* boundary value as the west piece's east edge, - * but *belonging* to the east piece's natural (near-0) frame, so it needs - * `-TILE_SIZE` to join up with that piece's interior rather than sitting a - * full world away from it. + * `forwardReproject` corrects `projectPosition`'s raw (proj4-wrapped) + * common-space x per point, using {@link unwrapCommonSpaceX} — which + * derives an expected x directly from the point's own distance from the + * seam (via its fractional position `u` along the tile, recovered here + * with `inverseTransform`) rather than testing the wrapped output against + * a fixed midpoint. See that function's doc comment and + * `replace-256-x-heuristic.md` for why this is width-independent, unlike + * the constant-per-piece-shift approach it replaced. * - * So the correction can't be "shift this whole piece by one world" (a - * single piece can contain both a boundary point at exactly the seam and - * interior points already on its own natural side) — it has to be a - * per-point test, and it's *mirrored* between the two pieces: the west - * piece shifts raw x in `[0, TILE_SIZE/2)` up by `+TILE_SIZE` (a no-op for - * its own raw values, which never fall below `TILE_SIZE/2`); the east - * piece shifts raw x in `[TILE_SIZE/2, TILE_SIZE]` down by `-TILE_SIZE` (a - * no-op for its own interior, which never reaches `TILE_SIZE/2`). + * `unwrapCommonSpaceX` itself anchors the seam at `TILE_SIZE` — the west + * piece's own natural frame, needing no further adjustment. The east + * piece's natural frame instead anchors the seam at `0`, so its branch + * re-anchors by subtracting `TILE_SIZE`. * * Each piece therefore renders at its *own* natural common-space position * (west near `TILE_SIZE`, east near `0`) instead of both being forced into @@ -537,29 +533,28 @@ export class RasterTileset2D extends Tileset2D { forwardTransform: ProjectionFunction, inverseTransform: ProjectionFunction, piece: "west" | "east", + cut: AntimeridianCut, + tileWidth: number, ): ReprojectionFns { return { forwardTransform, inverseTransform, forwardReproject: (x, y) => { const [cx, cy] = this.projectPosition(x, y); + const [px] = inverseTransform(x, y); + const u = px / tileWidth; + const seamAnchored = unwrapCommonSpaceX(cx, u, cut, TILE_SIZE); const corrected = - piece === "west" - ? unwrapCommonSpaceX(cx, TILE_SIZE) - : cx >= TILE_SIZE / 2 - ? cx - TILE_SIZE - : cx; + piece === "west" ? seamAnchored : seamAnchored - TILE_SIZE; return [corrected, cy]; }, inverseReproject: (cx, cy) => { - const raw = - piece === "west" - ? cx >= TILE_SIZE - ? cx - TILE_SIZE - : cx - : cx < 0 - ? cx + TILE_SIZE - : cx; + // Undo the east branch's re-anchoring, then reduce into the + // canonical [0, TILE_SIZE) range `unprojectPosition` expects — the + // exact inverse of `forwardReproject`'s shift, regardless of how + // many world-widths it added or subtracted. + const seamAnchored = piece === "west" ? cx : cx + TILE_SIZE; + const raw = ((seamAnchored % TILE_SIZE) + TILE_SIZE) % TILE_SIZE; return this.unprojectPosition(raw, cy); }, }; diff --git a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts index 4c17755e..2bb5e9f5 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { AntimeridianCut } from "../../src/raster-tileset/antimeridian-cut.js"; import { antimeridianCut, + unwrapCommonSpaceX, unwrapEastLng, } from "../../src/raster-tileset/antimeridian-cut.js"; @@ -108,16 +110,84 @@ describe("antimeridianCut", () => { }), ).toBeUndefined(); }); + + it("locates a cut for a piece far wider than the old (now-removed) 170° per-piece limit", () => { + // Native lngs -100..190 (un-normalized, west= 360°)", () => { + // -200..200 is 400° wide -- two pixel columns would claim the same + // real-world longitude. Invalid data; no correct rendering exists, + // cut or not. + expect( + antimeridianCut({ + topLeft: -200, + topRight: 200, + bottomLeft: -200, + bottomRight: 200, + }), + ).toBeUndefined(); + }); }); -describe("unwrapEastLng", () => { - it("adds 360° to a GeoJSON-flipped eastLng (west > east)", () => { - expect(unwrapEastLng(179.967798, -179.169819)).toBeCloseTo( - 180.830181, +describe("unwrapCommonSpaceX", () => { + const TILE_SIZE = 512; + + it("leaves the seam itself unshifted (maps to exactly tileSize)", () => { + const cut: AntimeridianCut = { uCut: 0.5, totalSpanDeg: 40 }; + expect(unwrapCommonSpaceX(TILE_SIZE, 0.5, cut, TILE_SIZE)).toBeCloseTo( + TILE_SIZE, 9, ); }); + it("does not wrongly shift a wide piece's legitimate low-x point — the false positive the old x<256 heuristic hit", () => { + // Mirrors the -100..190 example in the antimeridian design doc's "Seam + // handling" section: native lng -90, 10° in from the tile's -100° + // corner (u = 10/290), inside a + // west piece spanning -100°..180° (280° wide). proj4 never wraps this + // point at all — it projects straight to common-space x=128, which the + // old `x < tileSize/2` test would have wrongly shifted by +tileSize. + const cut: AntimeridianCut = { uCut: 280 / 290, totalSpanDeg: 290 }; + const u = 10 / 290; + const actualX = 128; + expect(unwrapCommonSpaceX(actualX, u, cut, TILE_SIZE)).toBeCloseTo(128, 6); + }); + + it("shifts an east-side point that genuinely wrapped, even in a wide tile, into the combined (seam-anchored) frame", () => { + // Same tile; native lng 185° (5° past the seam, u = 285/290) wraps + // under proj4 to -175° (common-space x≈7.11), but the seam-anchored + // expectation for this u is TILE_SIZE plus 5° of common-space width — + // the "combined" box the traversal's bounding volume wants (west below + // TILE_SIZE, east above it). + const cut: AntimeridianCut = { uCut: 280 / 290, totalSpanDeg: 290 }; + const u = 285 / 290; + const actualX = (-175 / 360 + 0.5) * TILE_SIZE; + expect(unwrapCommonSpaceX(actualX, u, cut, TILE_SIZE)).toBeCloseTo( + TILE_SIZE + 5 * (TILE_SIZE / 360), + 6, + ); + }); +}); + +describe("unwrapEastLng", () => { + it("adds 360° to a GeoJSON-flipped eastLng (west > east)", () => { + expect(unwrapEastLng(179.967798, -179.169819)).toBeCloseTo(180.830181, 9); + }); + it("passes through a native un-normalized eastLng unchanged (west < east)", () => { expect(unwrapEastLng(-204, -162)).toBe(-162); }); 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 06332533..b4b2502e 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 @@ -175,4 +175,49 @@ describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { expect(y).toBeCloseTo(24, 6); }); }); + + describe("wide crossing tile (>170° per-piece width, previously rejected outright)", () => { + // Native lngs -100..190 (un-normalized, west170°-wide piece", () => { + const metadata = wideCrossingMetadata(); + expect(metadata._antimeridianCut).toBeDefined(); + expect(metadata._antimeridianCut?.uCut).toBeCloseTo(280 / 290, 5); + }); + + it("does not wrongly shift a west piece's legitimate low-x point (the false positive the old heuristic hit)", () => { + const metadata = wideCrossingMetadata(); + // Native lng -90°, 10° in from the tile's -100° corner: no proj4 + // wrap occurs at all, x=128 is already correct. The old + // `x < TILE_SIZE/2` test would have wrongly added TILE_SIZE here. + const [cx] = metadata._westReprojection!.forwardReproject(-90, 24); + expect(cx).toBeCloseTo(128, 3); + }); + + it("renders an east-piece point that genuinely wrapped at its own natural near-0 position", () => { + const metadata = wideCrossingMetadata(); + // Native lng 185°, 5° past the seam: proj4 wraps this to -175°. + const [cx] = metadata._eastReprojection!.forwardReproject(185, 24); + expect(cx).toBeCloseTo(7.11, 1); + }); + }); }); diff --git a/replace-256-x-heuristic.md b/replace-256-x-heuristic.md deleted file mode 100644 index 4b9cce3a..00000000 --- a/replace-256-x-heuristic.md +++ /dev/null @@ -1,165 +0,0 @@ -# Replacing the `x < 256` antimeridian heuristic - -## Problem - -An antimeridian-crossing tile gets cut into two pieces (west/east), each meshed independently. Every -mesh vertex is reprojected through proj4, which always normalizes longitude to `(−180°, 180°]` — so -a vertex whose true position is just past the seam comes back wrapped to the *opposite* edge of the -map. Each piece needs its wrapped vertices corrected back onto a single contiguous mesh. - -The current correction, `unwrapCommonSpaceX` (`packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts`): - -```ts -export function unwrapCommonSpaceX(x: number, tileSize: number): number { - return x < tileSize / 2 ? x + tileSize : x; -} -``` - -is a **magnitude test against a fixed midpoint** (256, half the world). It works for a normal, -narrow tile. It breaks for a piece wide enough that one of its own, legitimately-placed points -crosses that same 256 line without ever having wrapped at all — the test can't tell "this point -wrapped around the seam" apart from "this point is just far from the seam," because both look -identical once reduced to a single magnitude comparison. The result is a torn mesh, silently. - -## Solution - -Correct each point against a **reference computed from that point's own position**, instead of a -single fixed constant for the whole piece. - -`antimeridianCut` (`antimeridian-cut.ts`) already computes, for every tile, the true continuous -longitude range of each piece — via `unwrapEastLng`, the same "west > east marks a crossing, add -360°" comparison GeoJSON (RFC 7946 §5.2) uses for bboxes. That range is exact and doesn't degrade -with width; it's how `antimeridianCut` locates the seam (`uCut`) in the first place, for a piece of -*any* size. - -Reuse it. For a sample point at parameter `t ∈ [0,1]` along a piece, linearly interpolate between the -piece's two already-known corner longitudes to get an **expected** longitude for that point — pure -arithmetic on two numbers already in hand, no extra proj4 call. Then snap the point's real (wrapped) -projected longitude to the representative nearest that expected value: - -``` -corrected = actual + round((expected − actual) / 360) * 360 -``` - -**In plain terms:** the old test asks one global question — "is this point in the left half of the -whole map?" — and treats "yes" as proof it wrapped. But a wide piece can have points that are -genuinely, correctly in the left half without anything having wrapped at all; the test can't tell -the two apart because it only ever looks at one number in isolation. The new test asks a different, -local question instead — "is this point roughly where I'd expect it, given where it sits *within this -piece*?" — and only nudges it by a full world-width if the answer is no. Because the expectation -moves with the point instead of sitting fixed at one spot, it stays a useful check no matter how wide -the piece is. - -**Worked example.** Take a tile whose corners are native lng −100° and +190° (un-normalized, so -west < east as usual) — crossing the seam at 180°, `uCut = (180−(−100))/(190−(−100)) = 280/290`. The -west piece spans native lng −100° to 180°: 280° wide, entirely inside proj4's normal `(−180°,180°]` -range, so *nothing in this piece ever wraps at all*. Take a point near its far edge, native lng −90° -(`t = 10/280 ≈ 0.036`). proj4 returns it unchanged, −90° (no wrap — there's nothing to correct) → -common-space `x ≈ 128`. The old test only looks at that `x`: `128 < 256`, so it "corrects" a point -that was already correct, shifting it a full world-width to `x ≈ 640` and tearing the mesh — the -heuristic doesn't require an actual wrap to misfire, just a piece wide enough to have legitimate -points below the midpoint. - -The new test instead interpolates an expected value for this point: `expected = −100 + 0.036×280 = -−90°` — matching the actual value exactly. `round((−90 − (−90))/360) = 0`, so `corrected = −90°`, -unchanged. It correctly recognizes nothing needs fixing, because its reference was computed from -*this point's own position in the piece*, not a fixed line drawn through the middle of the world. - -## Why this is the most general and robust solution - -**It has no artificial width limit.** The old test's ±180°-from-256 limit comes entirely from using -one fixed reference for an entire piece — any single, fixed reference (256, a corner, anything) can -only disambiguate up to half a period away from itself, and that's true regardless of how the -reference is chosen or where the test is performed (common-space, longitude-space, doesn't matter — -same wall, different name). A *per-point* reference doesn't have this problem: it's derived from -where that specific point actually sits, so it stays close to the true value everywhere across the -piece, no matter how wide the piece is. - -**The only remaining constraint isn't a new one.** The interpolation is only meaningful if the -piece's two corner longitudes describe a real, non-self-overlapping span — i.e. the tile's total -width is under 360°. That's not a limitation this fix introduces or fails to solve: data ≥360° wide -already has two pixels claiming the same real-world longitude, which is invalid regardless of how -it's rendered. No correction, however general, can produce a "correct" placement for data that -doesn't have one. This fix's domain of validity is exactly the domain of valid input. - -**It reuses machinery that's already trusted and already width-independent.** `unwrapEastLng` and -the corner data it produces are the *detection* step, used today for cut location and (via -`normalizeSourceBbox`) Mosaic source indexing — both already work at any width, because comparing -two known longitudes against each other never degrades the way a magnitude test against a fixed -constant does. This fix doesn't add a new mechanism; it extends the reach of the one already proven -correct at the corners down to every interior point. - -**It doesn't reintroduce the fragility of a rejected prior approach.** A generic phase-unwrap that -walks samples in spatial order and accumulates an offset from neighbor to neighbor (tried and -rejected as PR #374) is order-dependent, and one bad sample corrupts everything downstream of it. -This fix computes each point's reference independently, straight from validated corner geometry — -no walk order, no accumulated error, no dependency between points. - -**It keeps the existing architecture.** No change to the cut-into-two-pieces design, no change to -where `RasterReprojector` runs or what it needs — only the per-point correction step inside a piece -that's already known to be isolated on one side of the seam. - -## Prior art already using this idiom (detection only, not placement — until now) - -- **`unwrapEastLng(westLng, eastLng)`** — the GeoJSON west>east idiom itself, used by - `edgeUCut`/`antimeridianCut` to detect a crossing and locate the seam. -- **`normalizeSourceBbox`** (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts`) — the same - idiom applied to a `MosaicLayer` source's bbox before Flatbush indexing. - -Both are one-shot, corner-level comparisons — this fix is the same idea, applied per interior point -instead of once at the corners. - -## Before implementing - -- **`antimeridianCut`'s return type needs extending** — currently just `{ uCut }`; the render and - traversal code need each piece's corner longitudes too, to interpolate against. Natural to add - `westLng`/`unwrappedEastLng` (already computed internally) to `AntimeridianCut`. -- **Mesh vertices for a very wide piece will legitimately land outside `[0, 512]`** (into - neighboring world copies) — expected, and already how deck.gl's repeat rendering works via - `worldOffset` translation. `MAX_MAPS = 3` in `raster-tile-traversal.ts` (and the equivalent in - `mosaic-tileset-2d.ts`) may need to scale with piece width for a genuinely extreme piece rather - than staying a fixed constant. -- **Validate the interpolation assumption against a pathological CRS.** The interpolated reference - only needs to land within 180° of the true value to disambiguate correctly — a low bar for any - reasonably-behaved projection over one tile's extent — but this hasn't been stress-tested outside - the straight/vertical-or-slanted-cut MVP scope this project already targets. -- **`MAX_PIECE_SPAN_DEG` (already implemented, `antimeridian-cut.ts`)** — its role changes from - "reject any piece ≥170° wide" to a check against genuine self-overlap (total tile width ≥360°); - the threshold should move accordingly rather than being dropped outright. - -## Appendix: two fixed-reference variants considered and rejected - -Both were tried first, before landing on the per-point version above — kept here because they're the -reasoning that rules out the "just use the GeoJSON idiom directly" instinct. - -**Piece-relative fixed reference.** Compare each point against its own piece's known corner instead -of a bare constant: - -```ts -function unwrap(x, reference, worldWidth) { - let corrected = x; - while (corrected - reference > worldWidth / 2) corrected -= worldWidth; - while (corrected - reference < -worldWidth / 2) corrected += worldWidth; - return corrected; -} -``` - -For the west piece (`reference = 512`): `x - 512 < -256 ⟺ x < 256` — identical to the current test. -Same for east (`reference = 0`). Not a coincidence: for this geometry, the only legitimate *fixed* -reference for each piece is 0 or 512, both literally the antimeridian viewed from that piece's own -frame — so an explicit reference-relative version collapses to the exact arithmetic already shipped. - -**Unwrap in longitude space before the linear rescale.** EPSG:3857's `x` is exactly linear in -longitude, so try correcting the longitude (via the `unwrapEastLng` comparison) before that linear -step instead of after. This looked promising enough on a first pass to conclude it might extend the -safe range to just under 360° — worth keeping the mistake visible, since it's the natural way to -talk yourself into thinking this direction works. The error: treating "±180° around the reference" -as the piece's *whole* width budget (360° total), when the reference is one *edge* of the piece, not -its center — the piece only extends away from that edge in one direction, so the usable budget in -that direction is still capped at just under 180°. Moving the correction earlier doesn't change how -much information the projection already threw away — same limit, different domain. - -Both share the same root cause: a single, fixed reference for the whole piece can only disambiguate -±180° around itself, regardless of how cleverly the reference is chosen or which domain the -comparison happens in. That part of the reasoning holds. What doesn't hold is that a fixed reference -was the only option — see "Solution" above. From 412bf0cd884ffa7aa46a6f3593d7ab22647c2ced Mon Sep 17 00:00:00 2001 From: willjnz Date: Thu, 1 Oct 2026 16:06:02 +1000 Subject: [PATCH 15/21] Try be concise --- ...05-27-antimeridian-crossing-tile-design.md | 31 ++++--- examples/_shared/index.ts | 2 - examples/_shared/package.json | 2 - examples/antimeridian-example/src/App.tsx | 83 ++++++++++++++----- .../antimeridian-example/src/render-gray.ts | 63 -------------- .../package.json | 2 + .../src/App.tsx | 10 +-- .../src/data.ts | 13 +-- .../src}/stac-geoparquet.ts | 15 ---- .../src/raster-tileset/antimeridian-cut.ts | 39 ++++----- .../src/raster-tileset/raster-tileset-2d.ts | 17 ++-- .../raster-tileset-2d-antimeridian.test.ts | 2 +- pnpm-lock.yaml | 12 +-- 13 files changed, 114 insertions(+), 177 deletions(-) delete mode 100644 examples/antimeridian-example/src/render-gray.ts rename examples/{_shared => antimeridian-mosaic-multi-example/src}/stac-geoparquet.ts (50%) 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 fa220c27..3425dde4 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 @@ -43,13 +43,10 @@ The antimeridian becomes *a tile boundary*, which the pipeline already handles, ### Seam handling -> **Revised twice since first written — 2026-09-29.** This section has gone through three -> mechanisms as the failure modes of each were found: (1) the original design (struck through -> below) — a wrapped-piece-only sign test; (2) a symmetric per-point midpoint test that fixed (1)'s -> bug but turned out to have its own width limit; (3) the mechanism described first below, which -> fixes (2)'s limit too and is what's actually shipped. See the tests in -> `raster-tileset-2d-antimeridian.test.ts` and `antimeridian-cut.test.ts`, and the traversal fix this -> motivated in "Locating and selecting a crossing tile in the traversal" below. +> **Revised twice since first written (2026-09-29).** 3 mechanisms: (1) a wrapped-piece-only sign +> test (struck through below), (2) a per-point midpoint test — fixed (1), had its own width limit, +> (3) the per-vertex mechanism below — fixes (2) too, and is what's shipped. Tests: +> `raster-tileset-2d-antimeridian.test.ts`, `antimeridian-cut.test.ts`. 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. @@ -61,28 +58,28 @@ const expectedX = tileSize + signedDegreesFromSeam * (tileSize / 360); return x + Math.round((expectedX - x) / tileSize) * tileSize; ``` -— an *expected* `x` derived purely from the vertex's own distance from the seam, then snaps the actual wrapped `x` to the representative nearest that expectation. The seam itself (`u = cut.uCut`) always maps to exactly `tileSize`; the west side extends below it, the east side above it — this is the "combined" form the traversal's bounding volume wants directly (one contiguous box around the seam). `buildPieceReprojection` reuses it for the west piece's `forwardReproject` unchanged, and re-anchors the east piece into its own local frame (seam at `0`, not `tileSize`) by subtracting `tileSize`. `inverseReproject` undoes that re-anchor and reduces into the canonical `[0, tileSize)` range (`((x % tileSize) + tileSize) % tileSize`) — this exactly inverts whatever integer multiple of `tileSize` the forward direction applied, without needing to replay the correction logic. +— an *expected* `x` derived purely from the vertex's own distance from the seam, then snaps the actual wrapped `x` to the copy nearest that expectation. The seam itself (`u = cut.uCut`) always maps to exactly `tileSize`; the west side extends below it, the east side above it — this is the "combined" form the traversal's bounding volume wants directly (one contiguous box around the seam). `buildPieceReprojection` reuses it for the west piece's `forwardReproject` unchanged, and re-anchors the east piece into its own local frame (seam at `0`, not `tileSize`) by subtracting `tileSize`. `inverseReproject` undoes that re-anchor and reduces into the canonical `[0, tileSize)` range (`((x % tileSize) + tileSize) % tileSize`) — this exactly inverts whatever integer multiple of `tileSize` the forward direction applied, without needing to replay the correction logic. One outcome worth calling out: this gives each piece its own **natural** common-space position (west near `x≈512`, east near `x≈0`) instead of always anchoring both near a single shared frame. That turned out to be load-bearing for the traversal fix below — a piece now needs no help from the other piece (or the seam) being in view to be found and drawn correctly. #### Why a per-point, position-derived reference — not a fixed one -Two earlier, simpler mechanisms were tried and superseded before landing on the one above; both are worth keeping visible because they're the reasoning that rules out the "obvious" fixes. +Two simpler mechanisms were tried and superseded first — kept visible here since they're the reasoning that rules out the "obvious" fixes. -**Attempt 1 (original design, struck through below): a wrapped-piece-only sign test.** A single constant-per-piece shift, decided by *which piece* is rendering, is wrong exactly at that piece's own boundary corner — the vertex that coincides with the *other* piece's edge and needs no shift at all. A per-piece constant can't tell "this piece's own interior vertex that happens to be wrapped" apart from "this piece's own boundary vertex that must stay put," because whether a given vertex needs the shift depends on *that vertex's own position*, not on which piece asked. +**Attempt 1 (original design, struck through below): a wrapped-piece-only sign test.** A single constant-per-piece shift is wrong exactly at that piece's own boundary corner — the vertex that coincides with the *other* piece's edge and needs no shift at all. Whether a vertex needs the shift depends on *that vertex's own position*, not on which piece asked, so a per-piece constant can't tell the two apart. -**Attempt 2: a symmetric per-point midpoint test** (`x < tileSize/2 → +tileSize`, mirrored for east) — fixed attempt 1's bug by testing every vertex rather than trusting the piece label, and shipped for a while. It has a subtler, width-dependent failure: it's a *magnitude* test against a fixed midpoint, and once a piece is wide enough (≳180° of longitude), some of its own legitimately-placed vertices land on the "wrong" side of that midpoint *without having wrapped at all* — the test can't tell "this vertex wrapped around the seam" apart from "this vertex is just far from the seam," because both look identical once reduced to one magnitude comparison against a constant. The result is a silently torn mesh for a wide enough tile. +**Attempt 2: a symmetric per-point midpoint test** (`x < tileSize/2 → +tileSize`, mirrored for east) — fixed attempt 1 by testing every vertex, and shipped for a while. Its failure is subtler: it's a *magnitude* test against a fixed midpoint, so once a piece is wide enough (≳180°), some of its own legitimately-placed vertices land on the "wrong" side *without having wrapped at all* — indistinguishable from a vertex that actually wrapped. The result is a silently torn mesh. -Reframed generally: **any correction anchored to a single, fixed reference for the whole piece — however that reference is chosen** (a midpoint, a piece-specific corner, a comparison done in longitude space instead of common-space) **— can only disambiguate up to half a period (180°) away from itself.** That's a property of the math (recovering a 360°-periodic signal's true phase from one sample more than half a period from your only reference is not possible), not an implementation gap. Two specific variants were tried and confirmed to hit this wall: +Generally: **any correction anchored to one fixed reference for the whole piece — a midpoint, a corner, a longitude-space comparison — can only disambiguate up to half a period (180°) away from itself.** Recovering a 360°-periodic signal's phase from a sample more than half a period from its only reference isn't possible — a math limit, not an implementation gap. Two variants confirmed it: -- **Piece-relative fixed reference** (compare each vertex against its own piece's known seam corner, `TILE_SIZE` for west / `0` for east, instead of the bare `tileSize/2` constant) turns out to be *arithmetically identical* to attempt 2 — for this geometry, the only legitimate fixed reference for each piece already *is* `0` or `TILE_SIZE`, so renaming the constant changes nothing. -- **Unwrapping in longitude space before the linear rescale** (EPSG:3857's `x` is exactly linear in longitude, so correct the longitude via the `unwrapEastLng` comparison before that linear step, instead of correcting the wrapped `x` after) looked promising enough on a first pass to conclude it might extend the safe range to just under 360° — the reference sits at one *edge* of the piece, not its center, so the usable budget in the one direction the piece extends is still capped at just under 180°. Moving the correction earlier doesn't change how much information the projection already threw away. +- **Piece-relative fixed reference** (`TILE_SIZE` for west / `0` for east instead of the bare `tileSize/2`) is *arithmetically identical* to attempt 2 — for this geometry the only legitimate fixed reference already *is* `0` or `TILE_SIZE`, so renaming the constant changes nothing. +- **Unwrapping in longitude space first** (EPSG:3857's `x` is linear in longitude, so correct via `unwrapEastLng` before the linear rescale instead of after) looked like it might reach 360° — but the reference sits at one *edge* of the piece, not its center, so the usable range is still capped near 180°. Moving the correction earlier doesn't change how much information the projection already threw away. -**What breaks the wall**: the reference doesn't have to be fixed — it can be *recomputed per vertex* from geometry that's already trusted and already width-independent. `unwrapEastLng` and the corner data `antimeridianCut` derives from it are exactly this: a one-shot, corner-level comparison (`normalizeSourceBbox` in `mosaic-layer.ts` uses the identical idiom for `MosaicLayer` source indexing) that works at *any* width, because comparing two known longitudes against each other never degrades the way a magnitude test against a constant does. `unwrapCommonSpaceX`'s `expectedX` is that same idiom's output, extended from "once, at the corners" to "at every vertex's own position" — each vertex's reference tracks where *that vertex* sits, so it stays close to the true value everywhere across the piece, no matter how wide the piece is. +**What breaks the wall**: the reference doesn't have to be fixed — it can be *recomputed per vertex* from already-trusted, width-independent geometry. `unwrapEastLng` (and the corner data `antimeridianCut` derives from it — the same idiom `normalizeSourceBbox` uses for `MosaicLayer` indexing) is a corner-level comparison that works at *any* width, since comparing two known longitudes never degrades the way a magnitude test does. `unwrapCommonSpaceX`'s `expectedX` extends that same idiom from "once, at the corners" to "at every vertex" — each vertex's reference tracks its own position, so it stays accurate no matter how wide the piece is. -This also isn't the neighbor-relative phase-unwrap family that sank #374 (walking samples in spatial order, accumulating an offset from neighbor to neighbor — order-dependent, and one bad sample corrupts everything downstream). Each vertex's reference here is computed independently, straight from validated corner geometry; there's no walk order and no dependency between vertices. +Nor is this the neighbor-relative phase-unwrap family that sank #374 (walking samples in order, accumulating an offset — order-dependent, one bad sample corrupts everything downstream). Each vertex's reference here is computed independently from validated corner geometry; no walk order, no inter-vertex dependency. -**The one limit that's left isn't a new one.** The correction is only meaningful if the tile's own corner longitudes describe a real, non-self-overlapping span — total width under 360°. A tile ≥360° wide already has two pixel columns claiming the same real-world longitude, which is invalid data with no correct rendering regardless of how it's cut or corrected — not a limitation this fix introduces or fails to solve. `antimeridianCut` rejects such a tile outright (`MAX_TOTAL_SPAN_DEG = 359.9°`, checked once against the tile's total width), falling back to a single full-mesh render the same way an unsupported slanted or curved cut does — see "Locating and selecting a crossing tile in the traversal" below. (An earlier revision of this fix instead rejected any *piece* ≥170° wide — `MAX_PIECE_SPAN_DEG` — before the per-vertex mechanism above removed that narrower limit.) +**The one remaining limit isn't new.** The correction only means something if the tile's corners describe a real, non-self-overlapping span — total width under 360°. A tile ≥360° wide already has two pixel columns claiming the same longitude: invalid data, no correct rendering regardless of cut. `antimeridianCut` rejects it outright (`MAX_TOTAL_SPAN_DEG = 360°`), falling back to a full-mesh render like an unsupported slanted/curved cut — see "Locating and selecting a crossing tile in the traversal" below. (An earlier revision instead rejected any *piece* ≥170° wide — `MAX_PIECE_SPAN_DEG` — before the per-vertex mechanism removed that narrower limit.)
Attempt 1's original text (superseded, kept for history) diff --git a/examples/_shared/index.ts b/examples/_shared/index.ts index aaa09b66..7236f82f 100644 --- a/examples/_shared/index.ts +++ b/examples/_shared/index.ts @@ -17,6 +17,4 @@ export { ExampleProvider } from "./components/provider.js"; export type { RangeSliderProps } from "./components/range-slider.js"; export { RangeSlider } from "./components/range-slider.js"; export { UIOverlay } from "./components/ui-overlay.js"; -export type { StacAssets, StacGeoparquetItem } from "./stac-geoparquet.js"; -export { fetchStacGeoparquetItems } from "./stac-geoparquet.js"; export { system } from "./styles/theme.js"; diff --git a/examples/_shared/package.json b/examples/_shared/package.json index 7ed20e1c..9c36aa6e 100644 --- a/examples/_shared/package.json +++ b/examples/_shared/package.json @@ -16,8 +16,6 @@ "@chakra-ui/react": "^3.34.0", "@deck.gl/mapbox": "^9.3.2", "@devseed-ui/collecticons-chakra": "^4.0.0", - "hyparquet": "^1.31.2", - "hyparquet-compressors": "^1.1.2", "maplibre-gl": "^5.24.0", "react": "^19.2.5", "react-map-gl": "^8.1.1" diff --git a/examples/antimeridian-example/src/App.tsx b/examples/antimeridian-example/src/App.tsx index 6a022dcf..8f5af5ba 100644 --- a/examples/antimeridian-example/src/App.tsx +++ b/examples/antimeridian-example/src/App.tsx @@ -1,40 +1,80 @@ import { Text } from "@chakra-ui/react"; import { PathLayer } from "@deck.gl/layers"; +import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; import { COGLayer } from "@developmentseed/deck.gl-geotiff"; +import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; +import { + BlackIsZero, + CreateTexture, + LinearRescale, +} from "@developmentseed/deck.gl-raster/gpu-modules"; +import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; +import type { Texture } from "@luma.gl/core"; import type { DebugState } from "deck.gl-raster-examples-shared"; import { ControlPanel, DebugControls, DeckGlOverlay, ExternalLink, - fetchStacGeoparquetItems, } from "deck.gl-raster-examples-shared"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; -import { getTileDataGray, renderGray } from "./render-gray.js"; -// Every item in the DEP Landsat GeoMAD test catalog (EPSG:3832 / PDC -// Mercator), shown together as they are — no reprojection-hiding fitBounds. -// The 3 items at column `066` cross the antimeridian (GeoJSON-flipped corner -// lngs: xmin 179.97 → xmax −179.17); the other 9 don't, for comparison. -const PARQUET_URL = - "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet"; +// 2 items from the DEP Landsat GeoMAD test catalog (EPSG:3832 / PDC +// Mercator): one crosses the antimeridian (GeoJSON-flipped corner lngs: +// xmin 179.97 → xmax −179.17), the other doesn't, for comparison. +const GEOMAD_BASE_URL = + "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test"; +const CROSSING_RED_URL = `${GEOMAD_BASE_URL}/066/022/2025/dep_ls_geomad_066_022_2025_red.tif`; +const NORMAL_RED_URL = `${GEOMAD_BASE_URL}/065/021/2025/dep_ls_geomad_065_021_2025_red.tif`; + +type GrayTileData = { + texture: Texture; + width: number; + height: number; +}; + +async function getTileDataGray( + image: GeoTIFF | Overview, + options: GetTileDataOptions, +): Promise { + const { device, x, y, signal } = options; + const tile = await image.fetchTile(x, y, { signal, boundless: false }); + const { array } = tile; + if (array.layout === "band-separate") { + throw new Error("Expected a pixel-interleaved (1-band) COG"); + } + const { width, height, data } = array; + const texture = device.createTexture({ + data, + format: "r16unorm", + width, + height, + }); + return { texture, width, height }; +} + +function renderGray(data: GrayTileData): RenderTileResult { + return { + renderPipeline: [ + { module: CreateTexture, props: { textureName: data.texture } }, + { + module: LinearRescale, + props: { rescaleMin: 7200 / 65535, rescaleMax: 12000 / 65535 }, + }, + { module: BlackIsZero }, + ], + }; +} export default function App() { - const [redBandUrls, setRedBandUrls] = useState([]); const [debugState, setDebugState] = useState({ debug: true, debugOpacity: 0.25, }); - useEffect(() => { - fetchStacGeoparquetItems(PARQUET_URL).then((items) => - setRedBandUrls(items.map((item) => item.assets.red!.href)), - ); - }, []); - - const layers = redBandUrls.map( + const layers = [CROSSING_RED_URL, NORMAL_RED_URL].map( (url) => new COGLayer({ id: `cog-layer-${url}`, @@ -49,8 +89,7 @@ export default function App() { }), ); - // Reference line at the true antimeridian (±180°), for eyeballing whether - // the split pieces line up with it. + // Reference line at the true antimeridian (±180°) const antimeridianLine = new PathLayer({ id: "antimeridian-line", data: [ @@ -85,9 +124,9 @@ export default function App() { sourcePath="examples/antimeridian-example" > - All {redBandUrls.length || 12}{" "} - COGs in the test - catalog: 3 cross the ±180° antimeridian, the other 9 don't — see{" "} + 2 COGs from the + DEP Landsat GeoMAD test catalog: one crosses the ±180° antimeridian, + the other doesn't — see{" "} #575 diff --git a/examples/antimeridian-example/src/render-gray.ts b/examples/antimeridian-example/src/render-gray.ts deleted file mode 100644 index 60589cfe..00000000 --- a/examples/antimeridian-example/src/render-gray.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; -import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; -import { - BlackIsZero, - CreateTexture, - LinearRescale, -} from "@developmentseed/deck.gl-raster/gpu-modules"; -import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; -import type { Texture } from "@luma.gl/core"; - -/** Texture payload for a 1-band grayscale tile. */ -export type GrayTileData = { - texture: Texture; - width: number; - height: number; -}; - -// DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees -// rawDN / 65535), so the display range needs the same division. Not tuned -// per-tile — this example is about antimeridian correctness, not radiometric -// accuracy, so a simple fixed linear ramp is enough. -const RESCALE_MIN = 7200 / 65535; -const RESCALE_MAX = 12000 / 65535; - -/** Tile loader for the 1-band (16-bit unsigned) DEP GeoMAD bands used in this example. */ -export async function getTileDataGray( - image: GeoTIFF | Overview, - options: GetTileDataOptions, -): Promise { - const { device, x, y, signal } = options; - const tile = await image.fetchTile(x, y, { signal, boundless: false }); - const { array } = tile; - if (array.layout === "band-separate") { - throw new Error("Expected a pixel-interleaved (1-band) COG"); - } - const { width, height, data } = array; - const texture = device.createTexture({ - data, - format: "r16unorm", - width, - height, - }); - return { texture, width, height }; -} - -/** - * Render pipeline: a plain linear ramp — dark = low value, bright = high - * value. `BlackIsZero` broadcasts the single band into RGB so it renders as - * grayscale instead of red-tinted (a bare `r16unorm` texture only has data - * in the red channel). - */ -export function renderGray(data: GrayTileData): RenderTileResult { - return { - renderPipeline: [ - { module: CreateTexture, props: { textureName: data.texture } }, - { - module: LinearRescale, - props: { rescaleMin: RESCALE_MIN, rescaleMax: RESCALE_MAX }, - }, - { module: BlackIsZero }, - ], - }; -} diff --git a/examples/antimeridian-mosaic-multi-example/package.json b/examples/antimeridian-mosaic-multi-example/package.json index 6d1b3932..4fab875e 100644 --- a/examples/antimeridian-mosaic-multi-example/package.json +++ b/examples/antimeridian-mosaic-multi-example/package.json @@ -22,6 +22,8 @@ "@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", diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index 743573fc..05dcd545 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -15,12 +15,6 @@ import { Map as MaplibreMap } from "react-map-gl/maplibre"; import type { GeomadItem } from "./data.js"; import { fetchGeomadItems } from "./data.js"; -// DEP GeoMAD reflectance stretch — uint16 sampled as r16unorm (shader sees -// rawDN / 65535), so the display range needs the same division. One -// item's STAC raster:bands stats state the red band range as [7014, 16807] -const RESCALE_MIN = 7200 / 65535; -const RESCALE_MAX = 12000 / 65535; - export default function App() { const [geomadItems, setGeomadItems] = useState([]); const [debugState, setDebugState] = useState({ @@ -48,7 +42,7 @@ export default function App() { renderPipeline: [ { module: LinearRescale, - props: { rescaleMin: RESCALE_MIN, rescaleMax: RESCALE_MAX }, + props: { rescaleMin: 7200 / 65535, rescaleMax: 12000 / 65535 }, }, ], debug: debugState.debug, @@ -57,7 +51,7 @@ export default function App() { }), }); - // Reference line at the true antimeridian (±180°). + // Reference line at the antimeridian (±180°). const antimeridianLine = new PathLayer({ id: "antimeridian-line", data: [ diff --git a/examples/antimeridian-mosaic-multi-example/src/data.ts b/examples/antimeridian-mosaic-multi-example/src/data.ts index 7c495b97..c60e1ec3 100644 --- a/examples/antimeridian-mosaic-multi-example/src/data.ts +++ b/examples/antimeridian-mosaic-multi-example/src/data.ts @@ -1,6 +1,5 @@ -import { fetchStacGeoparquetItems } from "deck.gl-raster-examples-shared"; +import { fetchStacGeoparquetItems } from "./stac-geoparquet.js"; -/** Minimal per-item shape this example needs — id, bbox, and R/G/B asset URLs. */ export type GeomadItem = { id: string; bbox: [number, number, number, number]; @@ -10,16 +9,6 @@ export type GeomadItem = { const PARQUET_URL = "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test/dep_ls_geomad.parquet"; -/** - * Fetch every item in the DEP Landsat GeoMAD test catalog (a 4×3 grid, 12 - * items) from its STAC-geoparquet and pull out just what this example needs. - * The 3 items at column `066` cross the antimeridian; the other 9 don't. - * - * The STAC bbox for a crossing item is GeoJSON-flipped (RFC 7946 §5.2: crosses - * ±180° → xmin > xmax, e.g. `066/022`'s `179.97, -179.17`) — passed straight - * through here; `MosaicLayer` unwraps a flipped bbox onto a continuous frame - * internally. - */ export async function fetchGeomadItems(): Promise { const items = await fetchStacGeoparquetItems(PARQUET_URL); return items.map(({ id, bbox, assets }) => ({ diff --git a/examples/_shared/stac-geoparquet.ts b/examples/antimeridian-mosaic-multi-example/src/stac-geoparquet.ts similarity index 50% rename from examples/_shared/stac-geoparquet.ts rename to examples/antimeridian-mosaic-multi-example/src/stac-geoparquet.ts index 6c2e516a..c20fc5cd 100644 --- a/examples/_shared/stac-geoparquet.ts +++ b/examples/antimeridian-mosaic-multi-example/src/stac-geoparquet.ts @@ -1,29 +1,14 @@ import { asyncBufferFromUrl, parquetReadObjects } from "hyparquet"; import { compressors } from "hyparquet-compressors"; -/** A STAC item's `assets` entries, keyed by asset name. */ export type StacAssets = Record; -/** Minimal STAC item shape read from a STAC-geoparquet file. */ export type StacGeoparquetItem = { id: string; - /** `[minX, minY, maxX, maxY]`, as stored — not unwrapped for a crossing item. */ bbox: [number, number, number, number]; assets: StacAssets; }; -/** - * Read `id`, `bbox`, and `assets` for every item in a STAC-geoparquet file, - * fetched directly in the browser (no server-side indexing step) via - * `hyparquet` — a pure-JS, zero-dependency Parquet reader that uses HTTP - * range requests, so only the 3 needed columns are actually downloaded. - * - * `bbox` here is the geoparquet `STRUCT(xmin, ymin, xmax, ymax)` column - * as-is — an antimeridian-crossing item's bbox is GeoJSON-flipped (RFC 7946 - * §5.2: xmin > xmax), same as a STAC item's own JSON `bbox`. Callers that - * need a continuous frame (e.g. for a Flatbush spatial index) must unwrap it - * themselves. - */ export async function fetchStacGeoparquetItems( url: string, ): Promise { diff --git a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts index 06fafc60..ce2f35fa 100644 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ b/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts @@ -45,16 +45,15 @@ const U_EPSILON = 1e-6; * have and still be cut. * * This is not an accuracy limit on {@link unwrapCommonSpaceX} — its per-point - * correction is derived from each point's own distance from the seam (in + * correction derives from each point's own distance from the seam (in * already-validated corner longitudes), not a magnitude test against a fixed - * midpoint, so it has no per-piece width limit (see - * `replace-256-x-heuristic.md`). The one limit that remains is physical, not - * a property of this code: a tile ≥360° wide has two pixel columns claiming - * the same real-world longitude — self-overlapping, invalid data with no - * correct rendering, cut or not. 359.9° (not the exact boundary of 360°) - * leaves a margin against floating-point noise. + * midpoint, so it has no per-piece width limit (see the design doc's "Seam + * handling" section). The limit that remains is physical: a tile ≥360° wide + * has two pixel columns claiming the same real-world longitude — + * self-overlapping, invalid data with no correct rendering, cut or not. The + * `>=` check below rejects exactly that case and nothing short of it. */ -const MAX_TOTAL_SPAN_DEG = 359.9; +const MAX_TOTAL_SPAN_DEG = 360; /** * Unwrap a GeoJSON-flipped edge (RFC 7946 §5.2: west > east marks a @@ -73,21 +72,19 @@ export function unwrapEastLng(westLng: number, eastLng: number): number { * (0..1) along the tile's pixel/UV domain — the same domain `cut.uCut` is * defined in. * - * Rather than testing the already-wrapped `x` against a fixed midpoint - * (`tileSize/2`) — which can't tell "this point wrapped around the seam" - * apart from "this point is just far from the seam" once the tile is wide — - * this computes an *expected* `x` directly from the point's own signed + * A fixed-midpoint test (`tileSize/2`) can't tell "this point wrapped around + * the seam" apart from "this point is just far from the seam" once the tile + * is wide. Instead, this derives an *expected* x from the point's own signed * distance from the seam (`(u - cut.uCut) * cut.totalSpanDeg`, in degrees, - * using the already-validated corner longitudes `antimeridianCut` located - * the seam from) and snaps `x` to the representative nearest that - * expectation. The seam itself (`u = cut.uCut`) always maps to exactly - * `tileSize`; the west side (`u < cut.uCut`) extends below it, the east side - * (`u > cut.uCut`) extends above it. See `replace-256-x-heuristic.md` for - * the full derivation and why this has no per-piece width limit. + * using the corner longitudes `antimeridianCut` already validated) and snaps + * the real `x` to the copy nearest that expectation. The seam itself + * (`u = cut.uCut`) always maps to exactly `tileSize`; the west side + * (`u < cut.uCut`) extends below it, the east side (`u > cut.uCut`) extends + * above it. See the design doc's "Seam handling" section for the full + * derivation and why this has no per-piece width limit. * - * This is the "combined" form used directly by the traversal's bounding - * volume, which wants one contiguous box spanning both pieces around the - * seam (west below `tileSize`, east above it). `buildPieceReprojection`'s + * This is the "combined" form the traversal's bounding volume uses directly + * (one contiguous box spanning both pieces around the seam). `buildPieceReprojection`'s * east-piece branch re-anchors the result into its own local frame (seam at * `0`, not `tileSize`) by subtracting `tileSize` — see that method's doc * comment. 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 5e9bfc66..5babd942 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 @@ -131,11 +131,12 @@ export type RasterTileMetadata = { * are the stock, unmodified pixel↔source-CRS transform — the antimeridian * discontinuity doesn't live there (it's a property of the projection, not * the geotransform). Instead `forwardReproject`/`inverseReproject` correct - * for it post-projection, in common-space units: any raw common-space x - * below `TILE_SIZE / 2` is on the wrapped side and gets `+TILE_SIZE`, - * placing it in the world-copy adjacent to the other piece — see - * `buildPieceReprojection`. Built once in `getTileMetadata` for reference - * stability across renders. + * for it post-projection, in common-space units, via `unwrapCommonSpaceX`: + * each point's own signed distance from the seam (from its fractional + * position `u` and the already-located cut) decides whether it needs + * `+TILE_SIZE`, placing it in the world-copy adjacent to the other piece — + * see `buildPieceReprojection`. Built once in `getTileMetadata` for + * reference stability across renders. */ _westReprojection?: ReprojectionFns; @@ -502,9 +503,9 @@ export class RasterTileset2D extends Tileset2D { * derives an expected x directly from the point's own distance from the * seam (via its fractional position `u` along the tile, recovered here * with `inverseTransform`) rather than testing the wrapped output against - * a fixed midpoint. See that function's doc comment and - * `replace-256-x-heuristic.md` for why this is width-independent, unlike - * the constant-per-piece-shift approach it replaced. + * a fixed midpoint. See that function's doc comment and the design doc's + * "Seam handling" section for why this is width-independent, unlike the + * constant-per-piece-shift approach it replaced. * * `unwrapCommonSpaceX` itself anchors the seam at `TILE_SIZE` — the west * piece's own natural frame, needing no further adjustment. The east 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 b4b2502e..076858c1 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 @@ -180,7 +180,7 @@ describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { // Native lngs -100..190 (un-normalized, west Date: Thu, 1 Oct 2026 16:50:32 +1000 Subject: [PATCH 16/21] Remove uneeded example --- examples/antimeridian-example/README.md | 28 ---- examples/antimeridian-example/index.html | 22 --- examples/antimeridian-example/package.json | 37 ----- examples/antimeridian-example/src/App.tsx | 145 ------------------ examples/antimeridian-example/src/main.tsx | 12 -- .../antimeridian-example/src/vite-env.d.ts | 1 - examples/antimeridian-example/tsconfig.json | 4 - examples/antimeridian-example/vite.config.ts | 11 -- 8 files changed, 260 deletions(-) delete mode 100644 examples/antimeridian-example/README.md delete mode 100644 examples/antimeridian-example/index.html delete mode 100644 examples/antimeridian-example/package.json delete mode 100644 examples/antimeridian-example/src/App.tsx delete mode 100644 examples/antimeridian-example/src/main.tsx delete mode 100644 examples/antimeridian-example/src/vite-env.d.ts delete mode 100644 examples/antimeridian-example/tsconfig.json delete mode 100644 examples/antimeridian-example/vite.config.ts diff --git a/examples/antimeridian-example/README.md b/examples/antimeridian-example/README.md deleted file mode 100644 index e8a5ec00..00000000 --- a/examples/antimeridian-example/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Antimeridian Crossing Example - -Renders every `COGLayer` in the [Digital Earth Pacific](https://digitalearthpacific.org) Landsat GeoMAD test catalog (EPSG:3832 / PDC Mercator) — a 4×3 grid of 12 tiles, read live from the catalog's STAC-geoparquet — side by side, as-is: - -- The 3 tiles at column `066` (`dep_ls_geomad_066_020/021/022_2025`) cross the ±180° antimeridian. Their corner longitudes come out GeoJSON-flipped ([RFC 7946 §5.2](https://datatracker.ietf.org/doc/html/rfc7946#section-5.2): west > east, e.g. `179.97 → −179.17`), since `proj4`'s inverse projection normalizes output to `(−180°, 180°]`. -- The other 9 tiles, same catalog, don't cross — for comparison. - -See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575) for the antimeridian-handling design. - -## 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-example - pnpm dev - ``` - -4. Open your browser to http://localhost:3000 diff --git a/examples/antimeridian-example/index.html b/examples/antimeridian-example/index.html deleted file mode 100644 index 316c0191..00000000 --- a/examples/antimeridian-example/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - Antimeridian Crossing Example - - - -
- - - diff --git a/examples/antimeridian-example/package.json b/examples/antimeridian-example/package.json deleted file mode 100644 index c2314c59..00000000 --- a/examples/antimeridian-example/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "deck.gl-antimeridian-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-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:*", - "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-example/src/App.tsx b/examples/antimeridian-example/src/App.tsx deleted file mode 100644 index 8f5af5ba..00000000 --- a/examples/antimeridian-example/src/App.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Text } from "@chakra-ui/react"; -import { PathLayer } from "@deck.gl/layers"; -import type { GetTileDataOptions } from "@developmentseed/deck.gl-geotiff"; -import { COGLayer } from "@developmentseed/deck.gl-geotiff"; -import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; -import { - BlackIsZero, - CreateTexture, - LinearRescale, -} from "@developmentseed/deck.gl-raster/gpu-modules"; -import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; -import type { Texture } from "@luma.gl/core"; -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 { useState } from "react"; -import { Map as MaplibreMap } from "react-map-gl/maplibre"; - -// 2 items from the DEP Landsat GeoMAD test catalog (EPSG:3832 / PDC -// Mercator): one crosses the antimeridian (GeoJSON-flipped corner lngs: -// xmin 179.97 → xmax −179.17), the other doesn't, for comparison. -const GEOMAD_BASE_URL = - "https://s3.us-west-2.amazonaws.com/dep-public-staging/dep_ls_geomad/0-3-1-test"; -const CROSSING_RED_URL = `${GEOMAD_BASE_URL}/066/022/2025/dep_ls_geomad_066_022_2025_red.tif`; -const NORMAL_RED_URL = `${GEOMAD_BASE_URL}/065/021/2025/dep_ls_geomad_065_021_2025_red.tif`; - -type GrayTileData = { - texture: Texture; - width: number; - height: number; -}; - -async function getTileDataGray( - image: GeoTIFF | Overview, - options: GetTileDataOptions, -): Promise { - const { device, x, y, signal } = options; - const tile = await image.fetchTile(x, y, { signal, boundless: false }); - const { array } = tile; - if (array.layout === "band-separate") { - throw new Error("Expected a pixel-interleaved (1-band) COG"); - } - const { width, height, data } = array; - const texture = device.createTexture({ - data, - format: "r16unorm", - width, - height, - }); - return { texture, width, height }; -} - -function renderGray(data: GrayTileData): RenderTileResult { - return { - renderPipeline: [ - { module: CreateTexture, props: { textureName: data.texture } }, - { - module: LinearRescale, - props: { rescaleMin: 7200 / 65535, rescaleMax: 12000 / 65535 }, - }, - { module: BlackIsZero }, - ], - }; -} - -export default function App() { - const [debugState, setDebugState] = useState({ - debug: true, - debugOpacity: 0.25, - }); - - const layers = [CROSSING_RED_URL, NORMAL_RED_URL].map( - (url) => - new COGLayer({ - id: `cog-layer-${url}`, - geotiff: url, - getTileData: getTileDataGray, - renderTile: renderGray, - debug: debugState.debug, - debugOpacity: debugState.debugOpacity, - // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps - // doesn't know about it. - beforeId: "boundary_country_outline", - }), - ); - - // Reference line at the true 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 ( -
- - - - - - - 2 COGs from the - DEP Landsat GeoMAD test catalog: one crosses the ±180° antimeridian, - the other doesn't — see{" "} - - #575 - - . - - - - Digital Earth Pacific - {" "} - Landsat GeoMAD mosaic. - - - -
- ); -} diff --git a/examples/antimeridian-example/src/main.tsx b/examples/antimeridian-example/src/main.tsx deleted file mode 100644 index 171b36eb..00000000 --- a/examples/antimeridian-example/src/main.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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-example/src/vite-env.d.ts b/examples/antimeridian-example/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/examples/antimeridian-example/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/antimeridian-example/tsconfig.json b/examples/antimeridian-example/tsconfig.json deleted file mode 100644 index 4bd6962d..00000000 --- a/examples/antimeridian-example/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "include": ["src"] -} diff --git a/examples/antimeridian-example/vite.config.ts b/examples/antimeridian-example/vite.config.ts deleted file mode 100644 index b2a43968..00000000 --- a/examples/antimeridian-example/vite.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [react()], - base: "/deck.gl-raster/examples/antimeridian-example/", - worker: { format: "es" }, - server: { - port: 3000, - }, -}); From acc55432783f138ba599583ab0b7c56375d5cc83 Mon Sep 17 00:00:00 2001 From: willjnz Date: Thu, 1 Oct 2026 17:18:06 +1000 Subject: [PATCH 17/21] Support 4326 COG with lon range -204.0 to -162.0 --- examples/cog-basic/src/App.tsx | 8 ++- .../src/raster-tileset/raster-tileset-2d.ts | 25 +++++-- .../raster-tileset-2d-antimeridian.test.ts | 17 +++++ pnpm-lock.yaml | 67 ------------------- 4 files changed, 43 insertions(+), 74 deletions(-) diff --git a/examples/cog-basic/src/App.tsx b/examples/cog-basic/src/App.tsx index 1db1620b..69d93222 100644 --- a/examples/cog-basic/src/App.tsx +++ b/examples/cog-basic/src/App.tsx @@ -19,8 +19,12 @@ 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: "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: "Sentinel-2 True Color Image (New York, 2026)", 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 5babd942..ba2e8dd1 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 @@ -550,13 +550,28 @@ export class RasterTileset2D extends Tileset2D { return [corrected, cy]; }, inverseReproject: (cx, cy) => { - // Undo the east branch's re-anchoring, then reduce into the - // canonical [0, TILE_SIZE) range `unprojectPosition` expects — the - // exact inverse of `forwardReproject`'s shift, regardless of how - // many world-widths it added or subtracted. + // Undo the east branch's re-anchoring first, same as before. From + // here, `unprojectPosition` alone isn't a true inverse of + // `forwardReproject`: it reduces into canonical [0, TILE_SIZE) + // before unprojecting, and proj4's own inverse re-normalizes + // longitude into (-180°,180°] — losing which world-copy the point + // came from whenever that's outside ±180°. Y is unaffected (Mercator's + // inverse doesn't mix X into Y), so get `sourceY` the same way as + // before and use it, via the stock geotransform, to recover `py`. + // For X, invert `unwrapCommonSpaceX`'s own linear formula directly — + // exact, since it's the same formula `forwardReproject` used to + // produce `seamAnchored` — to get `u`, then let the stock + // `forwardTransform` (un-normalized-domain, CRS-correct) produce the + // true source point. This never assumes degree units, so it works + // identically for a projected (e.g. EPSG:3832) source CRS. const seamAnchored = piece === "west" ? cx : cx + TILE_SIZE; const raw = ((seamAnchored % TILE_SIZE) + TILE_SIZE) % TILE_SIZE; - return this.unprojectPosition(raw, cy); + const [, sourceY] = this.unprojectPosition(raw, cy); + const [, py] = inverseTransform(0, sourceY); + const u = + cut.uCut + + ((seamAnchored - TILE_SIZE) * 360) / (cut.totalSpanDeg * TILE_SIZE); + return forwardTransform(u * tileWidth, py); }, }; } 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 076858c1..30994091 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 @@ -174,6 +174,23 @@ describe("RasterTileset2D.getTileMetadata — _antimeridianCut", () => { expect(x).toBeCloseTo(-162, 6); expect(y).toBeCloseTo(24, 6); }); + + it("round-trips forwardReproject/inverseReproject for a west-piece point whose native lng is outside ±180 (the case that actually wraps)", () => { + // Unlike the east-piece round-trip above (native lng -162, already + // inside (-180,180], so a naive inverse is a no-op there), -191.4 is + // native west-piece territory that genuinely wraps: proj4 normalizes + // it to +168.6 going forward. A naive inverse that reduces to + // [0, TILE_SIZE) and unprojects would wrongly return +168.6 instead of + // -191.4 — exactly the bug `RasterReprojector`'s mesh refinement hit + // (reported as `currentError=360`, a full-period disagreement). + const metadata = crossingMetadata(); + const { forwardReproject, inverseReproject } = + metadata._westReprojection!; + const [cx, cy] = forwardReproject(-191.4, 24); + const [x, y] = inverseReproject(cx, cy); + expect(x).toBeCloseTo(-191.4, 6); + expect(y).toBeCloseTo(24, 6); + }); }); describe("wide crossing tile (>170° per-piece width, previously rejected outright)", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3a2eefb..75c34ded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,73 +206,6 @@ 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-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 - 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/antimeridian-mosaic-multi-example: dependencies: '@chakra-ui/react': From e8541c89f228b79a4c2684a4b96e49e3558b1372 Mon Sep 17 00:00:00 2001 From: willjnz Date: Thu, 1 Oct 2026 19:56:41 +1000 Subject: [PATCH 18/21] Plan for wider support (non-vertical) --- antimeridian-plan.md | 78 ++++++++++++++++++++++++++++++++++ examples/cog-basic/src/App.tsx | 8 ++++ fixtures/geotiff-test-data | 2 +- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 antimeridian-plan.md diff --git a/antimeridian-plan.md b/antimeridian-plan.md new file mode 100644 index 00000000..77ae6cbd --- /dev/null +++ b/antimeridian-plan.md @@ -0,0 +1,78 @@ +# Antimeridian: mesh-driven unwrap + clip (any seam shape) + +## Context + +Today `antimeridianCut` (`packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts`) estimates where the seam is from 4 corner longitudes and only accepts **vertical** cuts. Any other crossing falls back to one full mesh and diverges (#366). The approach is replaced with two CRS-agnostic operations, so each seam shape becomes a test case rather than a code path: + +1. **Unwrap in the reprojector.** Each new mesh vertex's output x snaps to the copy nearest its parent triangle's *interpolated* x. Delatin already computes that value for its error check. The interpolation is off only by reprojection error, which is far inside the ±256 (180°) snap tolerance. +2. **Clip the finished mesh at x = k·512.** Clip the unwrapped mesh where it crosses each world boundary, and shift each part back into [0, 512]. That is the actual discontinuity, so the cut is exact for vertical, slanted, curved, and polar seams. The result is one mesh and one layer per tile, with no west and east pieces. + +Fixtures (`fixtures/geotiff-test-data/rasterio_generated/fixtures/`): `antimeridian.tif` (vertical), `antimeridian_utm60.tif` (curved), `antimeridian_360.tif` (world-spanning). The slanted fixture is added below. + +## Step 0: verify single-mesh world-copy visibility first + +Before building anything, check whether deck.gl draws a mesh with x in [500, 530] when zoomed tight on the east side (x ≈ 14, only the offset-0 subViewport). If it does, step 3's clip only needs splitting with no shift. Expected: it does not, so the clip and shift are needed. Do a throwaway check in `examples/cog-basic` by hard-coding a +512 shift on a normal tile. + +## Changes + +### 1. `packages/raster-reproject/src/delatin.ts`: optional periodic x +- Add a constructor option `wrapX?: number` (the period; deck.gl-raster passes `TILE_SIZE`). When it is unset, behavior is unchanged for other consumers. +- Add a helper `snapToCopy(x, ref, period) = x + Math.round((ref - x) / period) * period`, exported for the traversal to reuse. +- `_findReprojectionCandidate`: store the max-error sample's interpolated `outSampleX` in a new `_candidatesOutX[t]`, next to `_candidatesUV`. +- `_addPoint(u, v, refX?)`: if `wrapX` and `refX` are both set, snap the exact output x to `refX`. `_step` passes `_candidatesOutX[t]`. +- **Seed vertices have no parent triangle.** Unwrap each one along the path from the previous seed vertex. Bisect the uv segment and snap each sample to the one before it, until every step is under `period / 4` (with a depth cap). This is needed for a −180..180 tile, whose corners both land on x = 0 ≡ 512. Without it, they collapse. +- The error check needs `inverseReproject` to accept unwrapped x outside [0, 512). See step 2. + +### 2. `packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts` +- `_unprojectPosition` becomes exact for unwrapped x without being told about the CRS: `lng = cx · 360 / 512 − 180` (no wrap), `lat` from `cy` (Mercator inverse), then `descriptor.projectFrom4326(lng, lat)`. For a 4326 source, this passes un-normalized longitudes straight through (e.g. −204). For projected sources, proj4's `adjlon` handles longitudes past ±180. +- Delete `_antimeridianCut`, `_westReprojection`, `_eastReprojection`, `buildPieceReprojection`, and the cut detection in `getTileMetadata`. +- **World-spanning:** tiles are boundless, so the 36×2 overview tile of `antimeridian_360` nominally spans 640°. Clip the seed to the tile's valid data rect by mapping `descriptor.projectedBounds` through the tile's `inverseTransform`. Intersect it with the existing ±85.051° band in `web-mercator-clamp.ts`, which gains a `uvRect` param. This stops the reprojector from meshing padding, which would overlap itself after unwrapping. + +### 3. Mesh clip, in `reprojectorToMesh` (deck.gl-raster) +- Given `wrapX`, find each triangle's `k = floor(x / 512)` range. If it falls in a single copy, shift by `−k·512`. Otherwise, clip the triangle against each `x = k·512` line (Sutherland–Hodgman against vertical lines). Fan-triangulate each part; the parts are convex. Interpolate the new vertices' uv and y linearly along the cut edge, so texture mapping matches the unclipped mesh exactly. Shift each part into [0, 512]. The 64-bit high/low position split runs after this, unchanged. +- Debug triangles (`renderDebugLayer`) read `exactOutputPositions`. Draw them from the clipped mesh instead, or accept that the debug wireframe is unclipped. + +### 4. Traversal: `packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts` +- `_getGenericBoundingVolume`: drop `antimeridianCut` and `unwrapCommonSpaceX`. Unwrap the 9 reference points with `snapToCopy`, each against its already-unwrapped grid neighbour (the same bisect-along-edge rule as the seed). That gives one tight box straddling 512, as now. +- Dataset bounds (~L990): unwrap the densified dataset edge the same way. +- Keep the `subViewports != null` world-copy gate fix. + +### 5. Render and debug consumers +- `raster-tile-layer.ts`: delete `_renderAntimeridianTile`. Every Web Mercator tile goes through `_renderNormalTile`, with `wrapX: TILE_SIZE` passed through a new `RasterLayer` prop to the reprojector and `reprojectorToMesh`. +- `layer-utils.ts` `renderDebugTileOutline` and `deck.gl-geotiff/src/multi-cog-layer.ts` (~L866): delete the west/east piece outline branches (`pieceBoxPath`, `pieceBoxWgs84`, `unwrapBoxLngs`). Draw the plain corner outline in lng/lat with `PathLayer`'s `wrapLongitude: true`. +- Delete `antimeridian-cut.ts` and its `_unwrapEastLng` export, **except** where `mosaic-layer.ts` `normalizeSourceBbox` uses `unwrapEastLng`. Move that one-liner into `mosaic-layer.ts`. + +### 6. Fixtures and example +- Add a slanted fixture to the geotiff-test-data submodule as `antimeridian_rotated.py`: 4326, `Affine.translation(-204, 24) * Affine.rotation(20) * Affine.scale(1, -1)`, 42×42. Generate its `.tif` and `_info.md`. +- Add "vertical" and "rotated" entries to `examples/cog-basic/src/App.tsx`, next to the uncommitted UTM60 and 360° entries. + +### 7. Docs +- Rewrite the "Approach", "Seam handling", and "Locating the cut" sections of `dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md` around unwrap-plus-clip. Keep cut-in-two as history: it was the vertical-only MVP. + +## Tests + +Run the tests with `pnpm vitest` in `packages/raster-reproject` and `packages/deck.gl-raster`. + +- `raster-reproject`: + - With `wrapX` unset, the output is identical to today's. + - With `wrapX` set and a wrapping projection mock: the mesh converges, and adjacent vertices never jump more than 256. Test vertical, slanted (rotated affine), curved (UTM-like nonlinear), and a −180..180 tile, which must not collapse. + - `snapToCopy` boundary cases. +- Mesh clip: + - Total area is preserved. + - Every position is in [0, 512]. + - At a seam-cut vertex, uv matches the lerp. + - No triangle straddles a boundary. +- `raster-tileset-2d-antimeridian.test.ts`: rewrite against the new behavior. For each of the 4 shapes, cover the 4326 identity mock and the wrapping (projected) mock: + - The mesh converges with no `did not converge` warning. + - `inverseReproject(forwardReproject(p)) ≈ p` after the snap. + - World-spanning overview tile: the seed is clipped to `u ≤ 36/64`. +- Traversal: a bounding volume for vertical, slanted, and curved tiles is one tight box around 512. +- **Visual** (`examples/cog-basic`, `pnpm dev`), all 4 fixtures: + - Continuous across ±180°. + - No seam gap. + - Visible when zoomed tight on either side alone. + - Before/after against the current branch for `antimeridian.tif` and the 3832 DEP COG. + +## Risks / out of scope +- The refinement snap needs each coarse triangle's interpolation error to be under 180°. A seed triangle spanning a wildly nonlinear region, such as a polar stereographic tile covering the pole, could violate this. Mitigation: the seed path-unwrap already densifies; add a `ponytail:` note and seed a denser grid if a polar fixture shows it. +- Globe view is unchanged; it doesn't pass `wrapX`. diff --git a/examples/cog-basic/src/App.tsx b/examples/cog-basic/src/App.tsx index 69d93222..55cd49bb 100644 --- a/examples/cog-basic/src/App.tsx +++ b/examples/cog-basic/src/App.tsx @@ -26,6 +26,14 @@ const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [ 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) (360°)", + url: `${window.location.origin}/__fixtures/antimeridian_360.tif`, + }, { title: "Sentinel-2 True Color Image (New York, 2026)", url: "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif", diff --git a/fixtures/geotiff-test-data b/fixtures/geotiff-test-data index 7fa07ac1..3c1521c4 160000 --- a/fixtures/geotiff-test-data +++ b/fixtures/geotiff-test-data @@ -1 +1 @@ -Subproject commit 7fa07ac10f9d54b2ac2e557117432cf8f2f80eef +Subproject commit 3c1521c407c6e071b977256d3d3b1816db063238 From 9407a5723187e354e8c78cef3e9560097060176f Mon Sep 17 00:00:00 2001 From: willjnz Date: Thu, 1 Oct 2026 20:54:13 +1000 Subject: [PATCH 19/21] =?UTF-8?q?Support=20all=20test=20cases:=204326,=204?= =?UTF-8?q?326=20rotated,=203832,=20utm60,=20and=20360=C2=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...05-27-antimeridian-crossing-tile-design.md | 21 +- examples/cog-basic/src/App.tsx | 24 +- examples/cog-basic/vite.config.ts | 5 +- fixtures/geotiff-test-data | 2 +- .../src/mosaic-layer/mosaic-layer.ts | 4 +- .../deck.gl-geotiff/src/multi-cog-layer.ts | 127 +----- .../tests/antimeridian-fixtures.test.ts | 160 +++++++ .../multi-cog-layer-debug-outline.test.ts | 57 --- .../deck.gl-raster/src/clip-mesh-to-world.ts | 106 +++++ packages/deck.gl-raster/src/index.ts | 1 - packages/deck.gl-raster/src/layer-utils.ts | 107 +---- packages/deck.gl-raster/src/raster-layer.ts | 62 ++- .../raster-tile-layer/raster-tile-layer.ts | 76 +--- .../src/raster-tileset/antimeridian-cut.ts | 171 ------- .../src/raster-tileset/index.ts | 1 - .../raster-tileset/raster-tile-traversal.ts | 167 +++---- .../src/raster-tileset/raster-tileset-2d.ts | 229 +++------- .../tests/clip-mesh-to-world.test.ts | 60 +++ .../raster-tileset/antimeridian-cut.test.ts | 198 -------- .../raster-tileset-2d-antimeridian.test.ts | 421 +++++++++--------- packages/raster-reproject/src/delatin.ts | 114 ++++- packages/raster-reproject/src/index.ts | 7 +- .../raster-reproject/tests/wrap-x.test.ts | 113 +++++ 23 files changed, 1037 insertions(+), 1196 deletions(-) create mode 100644 packages/deck.gl-geotiff/tests/antimeridian-fixtures.test.ts delete mode 100644 packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts create mode 100644 packages/deck.gl-raster/src/clip-mesh-to-world.ts delete mode 100644 packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts create mode 100644 packages/deck.gl-raster/tests/clip-mesh-to-world.test.ts delete mode 100644 packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts create mode 100644 packages/raster-reproject/tests/wrap-x.test.ts 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 3425dde4..ef27241e 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 described from "Approach: cut the tile in two" on - **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,6 +29,25 @@ 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. +## Current approach (2026-10-01): unwrap in the reprojector, clip the mesh + +The cut-in-two MVP below 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: + +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. + +Supporting changes: + +- **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). The `subViewports != null` world-copy gate fix below still applies. +- **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.) + +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). + +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. + +The rest of this document is the cut-in-two MVP's design, kept for history. + ## Approach: cut the tile in two 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: diff --git a/examples/cog-basic/src/App.tsx b/examples/cog-basic/src/App.tsx index 55cd49bb..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,7 +19,7 @@ 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. + // crosses native −180° at u ≈ 24/42 — exercises antimeridian unwrap + clip. title: "Antimeridian fixture (dev only) (4326)", url: `${window.location.origin}/__fixtures/antimeridian.tif`, }, @@ -30,6 +31,10 @@ const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [ 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`, @@ -109,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); @@ -153,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 3c1521c4..cd755360 160000 --- a/fixtures/geotiff-test-data +++ b/fixtures/geotiff-test-data @@ -1 +1 @@ -Subproject commit 3c1521c407c6e071b977256d3d3b1816db063238 +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 9e83dbba..0b362996 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -13,7 +13,6 @@ import { } from "@deck.gl/core"; import type { TileLayerProps } from "@deck.gl/geo-layers"; import { TileLayer } from "@deck.gl/geo-layers"; -import { _unwrapEastLng as unwrapEastLng } from "@developmentseed/deck.gl-raster"; import type { ConcurrencyLimiter, Priority } from "@developmentseed/geotiff"; import Flatbush from "flatbush"; import { DEFAULT_CONCURRENCY_LIMITER } from "../default-concurrency-limiter.js"; @@ -137,7 +136,8 @@ const defaultProps: Partial = { */ function normalizeSourceBbox(source: T): T { const [minX, minY, maxX, maxY] = source.bbox; - return { ...source, bbox: [minX, minY, unwrapEastLng(minX, maxX), maxY] }; + const east = maxX < minX ? maxX + 360 : maxX; + return { ...source, bbox: [minX, minY, east, maxY] }; } /** diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index eb93479e..a8c72e77 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -16,7 +16,6 @@ import type { MultiRasterTilesetDescriptor, ProjectionFunction, RasterModule, - RasterTileMetadata, RasterTilesetDescriptor, RasterTilesetLevel, RenderTileResult, @@ -48,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"; @@ -862,53 +862,15 @@ export class MultiCOGLayer extends RasterTileLayer< } // --- Primary tile outline and label --- - // - // An antimeridian-crossing tile renders as two RasterLayer pieces (see - // RasterTileLayer._renderAntimeridianTile); the debug outline needs to - // match, or PathLayer connects a corner like 179.97° to −179.17° the - // "short" way in lng/lat space — the long way around the globe. Each - // piece's own corners stay entirely on one side of ±180°, so no unwrap - // correction is needed within a piece — just split at the same `uCut` - // pixel used to build the actual render pieces. - const { _antimeridianCut, forwardTransform, tileWidth, tileHeight } = - tile as unknown as RasterTileMetadata; - - const primaryBoxes: Array<{ - path: [number, number][]; - center: [number, number]; - labelSuffix: string; - }> = _antimeridianCut - ? [ - { - ...pieceBoxWgs84( - forwardTransform, - forwardTo4326, - 0, - _antimeridianCut.uCut * tileWidth, - tileHeight, - ), - labelSuffix: " (west)", - }, - { - ...pieceBoxWgs84( - forwardTransform, - forwardTo4326, - _antimeridianCut.uCut * tileWidth, - tileWidth, - tileHeight, - ), - labelSuffix: " (east)", - }, - ] - : [ - { - ...cornersToWgs84Path( - primaryLevel.projectedTileCorners(x, y), - forwardTo4326, - ), - labelSuffix: "", - }, - ]; + const primaryBoxes = [ + { + ...cornersToWgs84Path( + primaryLevel.projectedTileCorners(x, y), + forwardTo4326, + ), + labelSuffix: "", + }, + ]; const primaryColor = DEBUG_COLORS[0]!; @@ -921,6 +883,8 @@ export class MultiCOGLayer extends RasterTileLayer< getWidth: 2, widthUnits: "pixels", pickable: false, + // Split the outline of a tile crossing ±180° at the antimeridian. + wrapLongitude: true, }), ); @@ -933,9 +897,8 @@ export class MultiCOGLayer extends RasterTileLayer< primaryDetail += ` ${primaryLevel.metersPerPixel.toFixed(1)}m/px`; } - // Secondary labels are anchored to the first primary piece's center and - // stacked below it. A second piece (the "east" half of a crossing tile) - // sits at its own separate position, so it needs no stack offset. + // 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. @@ -990,6 +953,7 @@ export class MultiCOGLayer extends RasterTileLayer< getWidth: 2, widthUnits: "pixels", pickable: false, + wrapLongitude: true, }), ); } @@ -1079,64 +1043,6 @@ function createBandTexture(device: Device, array: RasterArray): Texture { }); } -/** - * Closed 5-point box path (in WGS84) for a pixel-space rectangle `[x0, 0] .. - * [x1, height]` within a tile, mapped through `forwardTransform` (pixel → - * source CRS) then `projectTo4326` (source CRS → WGS84). - * - * Used to draw one piece of an antimeridian-crossing tile's debug outline — - * mirrors `pieceBoxPath` in `@developmentseed/deck.gl-raster`'s - * `layer-utils.ts`, but stops at WGS84 rather than continuing on to - * common-space, since `_renderDebugLayers` draws in plain lng/lat. - * - * `projectTo4326` normalizes lng to `(−180°, 180°]` — the seam corner shared - * with the *other* piece projects to the same `+180°` regardless of which - * piece is asking. For the west piece that's already continuous with its - * other corners (~179.97°..180°), but for the east piece it isn't - * (180°..−179.17° would draw as a ~359° span, not the real ~0.83° gap), so - * every corner is unwrapped by ±360° relative to the box's first corner - * before PathLayer sees it. - */ -export function pieceBoxWgs84( - forwardTransform: ProjectionFunction, - projectTo4326: ProjectionFunction, - x0: number, - x1: number, - height: number, -): { path: [number, number][]; center: [number, number] } { - const corners: [number, number][] = [ - [x0, 0], - [x1, 0], - [x1, height], - [x0, height], - [x0, 0], - ]; - const rawCorners = corners.map(([px, py]) => { - const [sx, sy] = forwardTransform(px!, py!); - return projectTo4326(sx, sy) as [number, number]; - }); - const refLng = rawCorners[0]![0]; - const path: [number, number][] = rawCorners.map(([lng, lat]) => { - let unwrapped = lng; - while (unwrapped - refLng > 180) { - unwrapped -= 360; - } - while (unwrapped - refLng < -180) { - unwrapped += 360; - } - return [unwrapped, lat]; - }); - const xs = path.map((p) => p[0]); - const ys = path.map((p) => p[1]); - return { - path, - center: [ - (Math.min(...xs) + Math.max(...xs)) / 2, - (Math.min(...ys) + Math.max(...ys)) / 2, - ], - }; -} - /** * Project CRS tile corners to WGS84 and return a closed path suitable for * PathLayer, plus the center point for label placement. @@ -1162,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/multi-cog-layer-debug-outline.test.ts b/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts deleted file mode 100644 index f0c407eb..00000000 --- a/packages/deck.gl-geotiff/tests/multi-cog-layer-debug-outline.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { pieceBoxWgs84 } from "../src/multi-cog-layer.js"; - -/** - * `projectTo4326` normalizes lng to `(−180°, 180°]`. For a point sitting - * exactly on an antimeridian-crossing tile's cut line, it returns `+180°` - * regardless of which piece asked — real (west corner, cut corner, east - * corner) values observed for the DEP GeoMAD `066/022` crossing item. - */ -function crossingProjectTo4326(px: number): [number, number] { - if (px === 0) { - return [179.9677978782272, -15.991730594838703]; - } - if (px === 11.949079327358657) { - return [180, -15.991730594838703]; - } - if (px === 320) { - return [-179.169819449018, -15.991730594838703]; - } - throw new Error(`unexpected px ${px}`); -} - -const identityForwardTransform = (px: number, py: number): [number, number] => [ - px, - py, -]; -const forwardTo4326 = (sx: number, _sy: number): [number, number] => - crossingProjectTo4326(sx); - -describe("pieceBoxWgs84", () => { - it("keeps the west piece's box within a small, continuous lng range", () => { - const { path } = pieceBoxWgs84( - identityForwardTransform, - forwardTo4326, - 0, - 11.949079327358657, - 320, - ); - const lngs = path.map((p) => p[0]); - expect(Math.max(...lngs) - Math.min(...lngs)).toBeLessThan(1); - }); - - it("unwraps the east piece's cut corner so it stays continuous with the far corner, instead of belting the globe", () => { - const { path } = pieceBoxWgs84( - identityForwardTransform, - forwardTo4326, - 11.949079327358657, - 320, - 320, - ); - const lngs = path.map((p) => p[0]); - // Un-fixed, the cut corner comes back as +180 while the far corner is - // -179.17 — a ~359° span. Fixed, the cut corner is unwrapped to -180, - // a ~0.83° span. - expect(Math.max(...lngs) - Math.min(...lngs)).toBeLessThan(1); - }); -}); 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/index.ts b/packages/deck.gl-raster/src/index.ts index b9552696..4bf51c25 100644 --- a/packages/deck.gl-raster/src/index.ts +++ b/packages/deck.gl-raster/src/index.ts @@ -39,5 +39,4 @@ export { // Not a public export, but we want to share across modules sortItemsByDistanceFromViewportCenter as _sortItemsByDistanceFromViewportCenter, TileMatrixSetAdaptor, - unwrapEastLng as _unwrapEastLng, } from "./raster-tileset/index.js"; diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts index e28c5a2d..3d2bbc22 100644 --- a/packages/deck.gl-raster/src/layer-utils.ts +++ b/packages/deck.gl-raster/src/layer-utils.ts @@ -1,112 +1,14 @@ 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"; -import type { ProjectionFunction } from "./raster-tileset/types.js"; - -/** - * Closed 5-point box path for a pixel-space rectangle `[x0, 0] .. [x1, - * height]`, mapped through `forwardTransform` (pixel → source CRS) then - * `forwardReproject` (source CRS → common space). - */ -function pieceBoxPath( - forwardTransform: ProjectionFunction, - forwardReproject: ReprojectionFns["forwardReproject"], - x0: number, - x1: number, - height: number, -): [number, number][] { - const corners: [number, number][] = [ - [x0, 0], - [x1, 0], - [x1, height], - [x0, height], - [x0, 0], - ]; - return corners.map(([px, py]) => { - const [sx, sy] = forwardTransform(px!, py!); - return forwardReproject(sx, sy); - }); -} - -function boxCenter(path: [number, number][]): [number, number] { - const xs = path.map((p) => p[0]); - const ys = path.map((p) => p[1]); - return [ - (Math.min(...xs) + Math.max(...xs)) / 2, - (Math.min(...ys) + Math.max(...ys)) / 2, - ]; -} export function renderDebugTileOutline( id: string, tile: Tile2DHeader & RasterTileMetadata, forwardTo4326: ReprojectionFns["forwardReproject"], ) { - const { _antimeridianCut, _westReprojection, _eastReprojection } = tile; - - // A crossing tile renders as two `RasterLayer`s, each at its own natural - // common-space position (see `RasterTileLayer._renderAntimeridianTile` and - // `RasterTileset2D.buildPieceReprojection`) — the debug outline needs to - // match, using EACH piece's own reprojection, rather than drawing one WGS84 - // box whose corners (e.g. lng 179.97° and −179.17°) `PathLayer` would - // connect the long way around the globe, or using one piece's reprojection - // for both (which would place the east box a full world away from where - // it actually renders). - if (_antimeridianCut && _westReprojection && _eastReprojection) { - const { uCut } = _antimeridianCut; - const { tileWidth, tileHeight, forwardTransform } = tile; - const cutPx = uCut * tileWidth; - const westPath = pieceBoxPath( - forwardTransform, - _westReprojection.forwardReproject, - 0, - cutPx, - tileHeight, - ); - const eastPath = pieceBoxPath( - forwardTransform, - _eastReprojection.forwardReproject, - cutPx, - tileWidth, - tileHeight, - ); - - const labelLayer = new TextLayer({ - id: `${id}-label`, - data: [ - { - position: boxCenter(westPath), - text: `x=${tile.index.x} y=${tile.index.y} z=${tile.index.z} (west)`, - }, - { - position: boxCenter(eastPath), - text: `x=${tile.index.x} y=${tile.index.y} z=${tile.index.z} (east)`, - }, - ], - getColor: [255, 255, 255, 255], - getSize: 24, - sizeUnits: "pixels", - outlineWidth: 3, - outlineColor: [0, 0, 0, 255], - fontSettings: { sdf: true }, - coordinateSystem: "cartesian", - }); - - const outlineLayer = new PathLayer({ - id, - data: [westPath, eastPath], - getPath: (d) => d, - getColor: [255, 0, 0, 255], // Red - getWidth: 2, - widthUnits: "pixels", - pickable: false, - coordinateSystem: "cartesian", - }); - - return [outlineLayer, labelLayer]; - } - const { projectedCorners } = tile; // Create a closed path in WGS84 projection around the tile bounds @@ -133,8 +35,11 @@ export function renderDebugTileOutline( topLeftWgs84, ]; + // Unwrap so a tile crossing ±180° (corners e.g. 179.97° and −179.17°) + // gets its label at the seam, not on the far side of the globe. const center = [ - (topLeftWgs84[0] + bottomRightWgs84[0]) / 2, + (topLeftWgs84[0] + snapToCopy(bottomRightWgs84[0], topLeftWgs84[0], 360)) / + 2, (topLeftWgs84[1] + bottomRightWgs84[1]) / 2, ]; const labelLayer = new TextLayer({ @@ -161,6 +66,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 88e5d21e..1c108b0f 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"; /** @@ -391,24 +391,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, - descriptor, - }) - : 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]; } @@ -483,50 +473,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, - * which corrects for the antimeridian discontinuity post-projection, in - * common-space units — see `RasterTileset2D.buildPieceReprojection`. The - * two pieces thus render in adjacent 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; - descriptor: RasterTilesetDescriptor; - }): Layer[] { - const { baseId, tile, data, tileResult, uCut } = opts; - 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 ce2f35fa..00000000 --- a/packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * WGS84 longitudes of a tile's four corners, as returned by - * `descriptor.projectTo4326(corner)[0]`. A crossing edge is accepted in - * either of two encodings: - * - **Native un-normalized**: for an identity 4326→4326 source whose - * `ModelTiepoint` sits past ±180°, proj4 does not renormalize, so west < - * east always and a crossing edge shows up as e.g. `(−204, −162)`. - * - **GeoJSON-flipped** (RFC 7946 §5.2): for any *projected* source, proj4's - * inverse projection normalizes its output to (−180°, 180°], so a - * crossing edge instead shows up with west > east, e.g. - * `(179.97, −179.17)` — the same convention the GeoJSON spec uses for an - * antimeridian-crossing bbox. - * A non-crossing edge always has west < east. - */ -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; - /** - * Total angular width of the tile (both pieces combined, in degrees) — - * `unwrapEastLng(topLeft, topRight) - topLeft` from the edge used to - * locate the cut. Used by {@link unwrapCommonSpaceX} to convert a point's - * `u` distance from the seam into degrees, and by {@link antimeridianCut} - * itself to reject a self-overlapping (≥360°) tile. - */ - totalSpanDeg: 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; - -/** - * Maximum total angular width (degrees, both pieces combined) a tile may - * have and still be cut. - * - * This is not an accuracy limit on {@link unwrapCommonSpaceX} — its per-point - * correction derives from each point's own distance from the seam (in - * already-validated corner longitudes), not a magnitude test against a fixed - * midpoint, so it has no per-piece width limit (see the design doc's "Seam - * handling" section). The limit that remains is physical: a tile ≥360° wide - * has two pixel columns claiming the same real-world longitude — - * self-overlapping, invalid data with no correct rendering, cut or not. The - * `>=` check below rejects exactly that case and nothing short of it. - */ -const MAX_TOTAL_SPAN_DEG = 360; - -/** - * Unwrap a GeoJSON-flipped edge (RFC 7946 §5.2: west > east marks a - * crossing) onto the continuous native scale `edgeUCut`'s seam search - * expects, by adding 360° to `eastLng`. A no-op for an edge already in - * native un-normalized form (west < east) or for a degenerate zero-width - * edge (west === east). - */ -export function unwrapEastLng(westLng: number, eastLng: number): number { - return eastLng < westLng ? eastLng + 360 : eastLng; -} - -/** - * Correct one reprojected reference point's common-space x for an - * antimeridian-crossing tile, given the point's own fractional position `u` - * (0..1) along the tile's pixel/UV domain — the same domain `cut.uCut` is - * defined in. - * - * A fixed-midpoint test (`tileSize/2`) can't tell "this point wrapped around - * the seam" apart from "this point is just far from the seam" once the tile - * is wide. Instead, this derives an *expected* x from the point's own signed - * distance from the seam (`(u - cut.uCut) * cut.totalSpanDeg`, in degrees, - * using the corner longitudes `antimeridianCut` already validated) and snaps - * the real `x` to the copy nearest that expectation. The seam itself - * (`u = cut.uCut`) always maps to exactly `tileSize`; the west side - * (`u < cut.uCut`) extends below it, the east side (`u > cut.uCut`) extends - * above it. See the design doc's "Seam handling" section for the full - * derivation and why this has no per-piece width limit. - * - * This is the "combined" form the traversal's bounding volume uses directly - * (one contiguous box spanning both pieces around the seam). `buildPieceReprojection`'s - * east-piece branch re-anchors the result into its own local frame (seam at - * `0`, not `tileSize`) by subtracting `tileSize` — see that method's doc - * comment. - */ -export function unwrapCommonSpaceX( - x: number, - u: number, - cut: AntimeridianCut, - tileSize: number, -): number { - const signedDegreesFromSeam = (u - cut.uCut) * cut.totalSpanDeg; - const expectedX = tileSize + signedDegreesFromSeam * (tileSize / 360); - return x + Math.round((expectedX - x) / tileSize) * tileSize; -} - -/** - * 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. Accepts either corner - * longitude encoding described on {@link CornerLongitudes} — a GeoJSON-flipped - * edge is unwrapped via {@link unwrapEastLng} before the seam search, which - * finds 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 { - const unwrappedEastLng = unwrapEastLng(westLng, eastLng); - // Degenerate or non-monotonic edge — caller is expected to pass - // west-then-east in the source CRS's native ordering. - if (unwrappedEastLng <= 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 >= unwrappedEastLng) { - return undefined; - } - return (seam - westLng) / (unwrappedEastLng - 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. - * - * Also rejects a tile whose total width would be ≥ {@link MAX_TOTAL_SPAN_DEG} - * — see that constant's doc comment — falling back to a single full-mesh - * layer the same way an unsupported slanted or curved cut does. - * - * Assumes u increases eastward (standard north-up geotransform). Corner - * longitudes may be in either encoding described on {@link CornerLongitudes}. - */ -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; - } - const uCut = (topUCut + bottomUCut) / 2; - - // Reject a self-overlapping tile — see MAX_TOTAL_SPAN_DEG's doc comment. - const totalSpanDeg = unwrapEastLng(topLeft, topRight) - topLeft; - if (totalSpanDeg >= MAX_TOTAL_SPAN_DEG) { - return undefined; - } - - return { uCut, totalSpanDeg }; -} diff --git a/packages/deck.gl-raster/src/raster-tileset/index.ts b/packages/deck.gl-raster/src/raster-tileset/index.ts index c09e1146..4f768cc2 100644 --- a/packages/deck.gl-raster/src/raster-tileset/index.ts +++ b/packages/deck.gl-raster/src/raster-tileset/index.ts @@ -2,7 +2,6 @@ export type { AffineTilesetOptions } from "./affine-tileset.js"; export { AffineTileset } from "./affine-tileset.js"; export type { AffineTilesetLevelOptions } from "./affine-tileset-level.js"; export { AffineTilesetLevel } from "./affine-tileset-level.js"; -export { unwrapEastLng } from "./antimeridian-cut.js"; export type { RasterTileMetadata } from "./raster-tileset-2d.js"; export { RasterTileset2D } from "./raster-tileset-2d.js"; export { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js"; 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 de7e5d7b..3d8ed7dd 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, @@ -29,7 +30,6 @@ import { } from "@math.gl/culling"; import { lngLatToWorld, worldToLngLat } from "@math.gl/web-mercator"; -import { antimeridianCut, unwrapCommonSpaceX } from "./antimeridian-cut.js"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import type { RasterTilesetDescriptor, @@ -427,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; } /** @@ -529,36 +538,36 @@ export class RasterTileNode { const tileCorners = this.level.projectedTileCorners(this.x, this.y); - // Detect whether this tile crosses ±180°. proj4's forward-to-3857 - // (below) wraps longitudes outside (-180°, 180°] back into range, so a - // crossing tile's reference points past the seam land on the opposite - // edge of common space unless corrected — see `unwrapCommonSpaceX`. - const cornerLng = (corner: Point): number => - this.descriptor.projectTo4326(corner[0], corner[1])[0]; - const cut = antimeridianCut({ - topLeft: cornerLng(tileCorners.topLeft), - topRight: cornerLng(tileCorners.topRight), - bottomLeft: cornerLng(tileCorners.bottomLeft), - bottomRight: cornerLng(tileCorners.bottomRight), - }); - - const refPointsEPSG3857 = sampleReferencePointsInEPSG3857( - REF_POINTS_9, - tileCorners, - this.descriptor.projectTo3857, - this.descriptor.projectTo4326, - ); - - const commonSpacePositions: [number, number][] = refPointsEPSG3857.map( - (xy, i) => { - const [x, y] = rescaleEPSG3857ToCommonSpace(xy); - if (!cut) { - return [x, y]; - } - const u = REF_POINTS_9[i]![0]; - return [unwrapCommonSpaceX(x, u, cut, TILE_SIZE), y]; - }, - ); + // 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) { @@ -975,47 +984,14 @@ export function getTileIndices( const bottomLeft = lngLatToWorld([minLng, minLat]); const topRight = lngLatToWorld([maxLng, maxLat]); - // `wgs84Bounds` is derived from `descriptor.projectedBounds` via a plain - // min/max over densified samples (see `RasterTileset2D`'s constructor), - // which — like a single tile's naive reference-point reprojection — loses - // antimeridian awareness for a crossing dataset. Detect crossing directly - // from the dataset's own corner longitudes (mirroring the per-tile - // `cornerLng`/`antimeridianCut` check in `_getGenericBoundingVolume`) and - // apply the same `unwrapCommonSpaceX` correction, so this bounds pre-filter - // agrees with the now-corrected per-tile bounding volumes it's compared - // against in `RasterTileNode.update`'s `insideBounds` check — otherwise a - // crossing tile's corrected box (starting at x=TILE_SIZE) and this - // uncorrected dataset box (ending at x=TILE_SIZE) merely touch instead of - // overlapping, and the tile is wrongly rejected at every zoom. - const [projMinX, , projMaxX, projMaxY] = descriptor.projectedBounds; - const datasetWestLng = descriptor.projectTo4326(projMinX, projMaxY)[0]; - const datasetEastLng = descriptor.projectTo4326(projMaxX, projMaxY)[0]; - const datasetCut = antimeridianCut({ - topLeft: datasetWestLng, - topRight: datasetEastLng, - bottomLeft: datasetWestLng, - bottomRight: datasetEastLng, - }); - if (datasetCut) { - // `bottomLeft`/`topRight` above came from `wgs84Bounds`'s already-lossy - // min/max (mixed together from densified samples on both sides of the - // seam) — patching those two numbers independently would collapse the - // box, since they no longer correspond to "the west corner" and "the - // east corner" individually. Recompute each corner's common-space x - // directly from its own real longitude instead, then apply the same - // per-point unwrap rule used for a single tile's reference points. - bottomLeft[0] = unwrapCommonSpaceX( - lngLatToWorld([datasetWestLng, minLat])[0], - 0, - datasetCut, - TILE_SIZE, - ); - topRight[0] = unwrapCommonSpaceX( - lngLatToWorld([datasetEastLng, maxLat])[0], - 1, - datasetCut, - TILE_SIZE, - ); + // `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 = [ @@ -1239,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 ba2e8dd1..d9d31db9 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,13 @@ 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, unwrapCommonSpaceX } from "./antimeridian-cut.js"; +import { lngLatToWorld, worldToLngLat } from "@math.gl/web-mercator"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import { getTileIndices, rescaleCommonSpaceToEPSG3857, - rescaleEPSG3857ToCommonSpace, TILE_SIZE, } from "./raster-tile-traversal.js"; import { sortItemsByDistanceFromViewportCenter } from "./sort-by-distance.js"; @@ -40,6 +35,9 @@ import type { } from "./types.js"; import { createInitialWebMercatorTriangulation } from "./web-mercator-clamp.js"; +/** Web Mercator's latitude limit, in degrees. */ +const MAX_LAT = 85.0511287798066; + /** Type returned by {@link RasterTileset2D.getTileMetadata} */ export type RasterTileMetadata = { /** @@ -113,35 +111,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. `forwardTransform`/`inverseTransform` - * are the stock, unmodified pixel↔source-CRS transform — the antimeridian - * discontinuity doesn't live there (it's a property of the projection, not - * the geotransform). Instead `forwardReproject`/`inverseReproject` correct - * for it post-projection, in common-space units, via `unwrapCommonSpaceX`: - * each point's own signed distance from the seam (from its fractional - * position `u` and the already-located cut) decides whether it needs - * `+TILE_SIZE`, placing it in the world-copy adjacent to the other piece — - * see `buildPieceReprojection`. 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; }; /** @@ -213,11 +192,29 @@ 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_LAT, Math.min(MAX_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( @@ -229,7 +226,6 @@ export class RasterTileset2D extends Tileset2D { // 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; this.wgs84Bounds = [ rawBounds[0], Math.max(rawBounds[1], -MAX_LAT), @@ -418,55 +414,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), - }); - // Each piece of a crossing tile needs its `forwardReproject` output - // (common space) corrected per-point, not by one constant shift — see - // `buildPieceReprojection` for why. West and east each get their OWN - // bundle (mirror-image corrections), so each piece renders at its own - // natural, internally-consistent position rather than both being forced - // into the west piece's frame — see that method's doc comment for why a - // single shared bundle breaks visibility when only one piece is in view. - let _westReprojection: ReprojectionFns | undefined; - let _eastReprojection: ReprojectionFns | undefined; - if (_antimeridianCut) { - _westReprojection = this.buildPieceReprojection( - forwardTransform, - inverseTransform, - "west", - _antimeridianCut, - tileWidth, - ); - _eastReprojection = this.buildPieceReprojection( - forwardTransform, - inverseTransform, - "east", - _antimeridianCut, - tileWidth, - ); - } + // ±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: { @@ -489,90 +456,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. - * - * `forwardReproject` corrects `projectPosition`'s raw (proj4-wrapped) - * common-space x per point, using {@link unwrapCommonSpaceX} — which - * derives an expected x directly from the point's own distance from the - * seam (via its fractional position `u` along the tile, recovered here - * with `inverseTransform`) rather than testing the wrapped output against - * a fixed midpoint. See that function's doc comment and the design doc's - * "Seam handling" section for why this is width-independent, unlike the - * constant-per-piece-shift approach it replaced. - * - * `unwrapCommonSpaceX` itself anchors the seam at `TILE_SIZE` — the west - * piece's own natural frame, needing no further adjustment. The east - * piece's natural frame instead anchors the seam at `0`, so its branch - * re-anchors by subtracting `TILE_SIZE`. - * - * Each piece therefore renders at its *own* natural common-space position - * (west near `TILE_SIZE`, east near `0`) instead of both being forced into - * one shared, artificially-combined frame. This matters once the viewport - * is zoomed into just one piece, away from the seam: deck.gl's own - * world-copy/repeat rendering already knows how to place a layer's raw, - * unmodified position on screen regardless of which world copy the camera - * is centered on (the same mechanism that already renders ordinary, - * non-crossing tiles correctly at any zoom) — but only if that position is - * the piece's genuine one. Forcing the east piece into the west piece's - * frame put it a full world away from wherever the camera was actually - * looking whenever only the east piece was in view, leaving it selected - * (fetched) but invisible. When *both* pieces are in view (viewport - * straddling the seam), deck.gl's own repeat rendering draws each piece at - * every world copy it's visible in, so the west piece (near `TILE_SIZE`) - * and the east piece (near `0`, repeat-rendered a world copy over at - * `TILE_SIZE`) still meet up seamlessly — no manual cross-piece shift - * needed for that case either. - */ - private buildPieceReprojection( - forwardTransform: ProjectionFunction, - inverseTransform: ProjectionFunction, - piece: "west" | "east", - cut: AntimeridianCut, - tileWidth: number, - ): ReprojectionFns { - return { - forwardTransform, - inverseTransform, - forwardReproject: (x, y) => { - const [cx, cy] = this.projectPosition(x, y); - const [px] = inverseTransform(x, y); - const u = px / tileWidth; - const seamAnchored = unwrapCommonSpaceX(cx, u, cut, TILE_SIZE); - const corrected = - piece === "west" ? seamAnchored : seamAnchored - TILE_SIZE; - return [corrected, cy]; - }, - inverseReproject: (cx, cy) => { - // Undo the east branch's re-anchoring first, same as before. From - // here, `unprojectPosition` alone isn't a true inverse of - // `forwardReproject`: it reduces into canonical [0, TILE_SIZE) - // before unprojecting, and proj4's own inverse re-normalizes - // longitude into (-180°,180°] — losing which world-copy the point - // came from whenever that's outside ±180°. Y is unaffected (Mercator's - // inverse doesn't mix X into Y), so get `sourceY` the same way as - // before and use it, via the stock geotransform, to recover `py`. - // For X, invert `unwrapCommonSpaceX`'s own linear formula directly — - // exact, since it's the same formula `forwardReproject` used to - // produce `seamAnchored` — to get `u`, then let the stock - // `forwardTransform` (un-normalized-domain, CRS-correct) produce the - // true source point. This never assumes degree units, so it works - // identically for a projected (e.g. EPSG:3832) source CRS. - const seamAnchored = piece === "west" ? cx : cx + TILE_SIZE; - const raw = ((seamAnchored % TILE_SIZE) + TILE_SIZE) % TILE_SIZE; - const [, sourceY] = this.unprojectPosition(raw, cy); - const [, py] = inverseTransform(0, sourceY); - const u = - cut.uCut + - ((seamAnchored - TILE_SIZE) * 360) / (cut.totalSpanDeg * TILE_SIZE); - return forwardTransform(u * tileWidth, py); - }, }; } } 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/raster-tileset/antimeridian-cut.test.ts b/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts deleted file mode 100644 index 2bb5e9f5..00000000 --- a/packages/deck.gl-raster/tests/raster-tileset/antimeridian-cut.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AntimeridianCut } from "../../src/raster-tileset/antimeridian-cut.js"; -import { - antimeridianCut, - unwrapCommonSpaceX, - unwrapEastLng, -} from "../../src/raster-tileset/antimeridian-cut.js"; - -// cornerLngs are WGS84 longitudes as returned by -// descriptor.projectTo4326(corner)[0]. Two encodings are accepted for a -// crossing edge — see CornerLongitudes' doc: -// - native un-normalized (identity 4326→4326 source): west < east always, -// e.g. (−204, −162). -// - GeoJSON-flipped (RFC 7946 §5.2 — what a projected source emits, since -// proj4's inverse projection normalizes to (−180°, 180°]): west > east, -// e.g. (179.97, −179.17). -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(); - }); - - it("locates a vertical cut for a GeoJSON-flipped crossing tile (projected-CRS lngs)", () => { - // dep_ls_geomad_066_022_2025 (EPSG:3832, PDC Mercator): projectTo4326 - // normalizes to (−180°, 180°], so the crossing edge is flipped — - // topLeft/bottomLeft = 179.967798°, topRight/bottomRight = −179.169819°. - const cut = antimeridianCut({ - topLeft: 179.967798, - topRight: -179.169819, - bottomLeft: 179.967798, - bottomRight: -179.169819, - }); - expect(cut).toBeDefined(); - // Unwrapped span is (179.967798, 180.830181); the 180° seam sits at - // (180 − 179.967798) / (180.830181 − 179.967798) ≈ 0.0373 of the way in. - expect(cut?.uCut).toBeCloseTo(0.037344, 5); - }); - - it("returns undefined for a degenerate zero-width edge (west === east)", () => { - expect( - antimeridianCut({ - topLeft: 170, - topRight: 170, - bottomLeft: 170, - bottomRight: 170, - }), - ).toBeUndefined(); - }); - - it("locates a cut for a piece far wider than the old (now-removed) 170° per-piece limit", () => { - // Native lngs -100..190 (un-normalized, west= 360°)", () => { - // -200..200 is 400° wide -- two pixel columns would claim the same - // real-world longitude. Invalid data; no correct rendering exists, - // cut or not. - expect( - antimeridianCut({ - topLeft: -200, - topRight: 200, - bottomLeft: -200, - bottomRight: 200, - }), - ).toBeUndefined(); - }); -}); - -describe("unwrapCommonSpaceX", () => { - const TILE_SIZE = 512; - - it("leaves the seam itself unshifted (maps to exactly tileSize)", () => { - const cut: AntimeridianCut = { uCut: 0.5, totalSpanDeg: 40 }; - expect(unwrapCommonSpaceX(TILE_SIZE, 0.5, cut, TILE_SIZE)).toBeCloseTo( - TILE_SIZE, - 9, - ); - }); - - it("does not wrongly shift a wide piece's legitimate low-x point — the false positive the old x<256 heuristic hit", () => { - // Mirrors the -100..190 example in the antimeridian design doc's "Seam - // handling" section: native lng -90, 10° in from the tile's -100° - // corner (u = 10/290), inside a - // west piece spanning -100°..180° (280° wide). proj4 never wraps this - // point at all — it projects straight to common-space x=128, which the - // old `x < tileSize/2` test would have wrongly shifted by +tileSize. - const cut: AntimeridianCut = { uCut: 280 / 290, totalSpanDeg: 290 }; - const u = 10 / 290; - const actualX = 128; - expect(unwrapCommonSpaceX(actualX, u, cut, TILE_SIZE)).toBeCloseTo(128, 6); - }); - - it("shifts an east-side point that genuinely wrapped, even in a wide tile, into the combined (seam-anchored) frame", () => { - // Same tile; native lng 185° (5° past the seam, u = 285/290) wraps - // under proj4 to -175° (common-space x≈7.11), but the seam-anchored - // expectation for this u is TILE_SIZE plus 5° of common-space width — - // the "combined" box the traversal's bounding volume wants (west below - // TILE_SIZE, east above it). - const cut: AntimeridianCut = { uCut: 280 / 290, totalSpanDeg: 290 }; - const u = 285 / 290; - const actualX = (-175 / 360 + 0.5) * TILE_SIZE; - expect(unwrapCommonSpaceX(actualX, u, cut, TILE_SIZE)).toBeCloseTo( - TILE_SIZE + 5 * (TILE_SIZE / 360), - 6, - ); - }); -}); - -describe("unwrapEastLng", () => { - it("adds 360° to a GeoJSON-flipped eastLng (west > east)", () => { - expect(unwrapEastLng(179.967798, -179.169819)).toBeCloseTo(180.830181, 9); - }); - - it("passes through a native un-normalized eastLng unchanged (west < east)", () => { - expect(unwrapEastLng(-204, -162)).toBe(-162); - }); - - it("passes through a degenerate zero-width edge unchanged (west === east)", () => { - expect(unwrapEastLng(170, 170)).toBe(170); - }); -}); 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 30994091..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,240 +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"; +import type { RasterTilesetDescriptor } from "../../src/raster-tileset/tileset-interface.js"; -const identity = (x: number, y: number): [number, number] => [x, y]; +// End-to-end: tile metadata → RasterReprojector (wrapX) → clipMeshToWorld, +// for each antimeridian seam shape in +// fixtures/geotiff-test-data/rasterio_generated/fixtures/. -const PROJECTIONS = { - projectTo3857: identity, - projectFrom3857: identity, - projectTo4326: identity, - projectFrom4326: identity, -}; - -function tilesetProps(): Tileset2DProps { - return { getTileData: () => new Promise(() => {}) } as Tileset2DProps; -} - -// EPSG:3857's well-known half-circumference (meters at ±180°). -const HALF_CIRCUMFERENCE = 20037508.342789244; 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, +]; /** - * Wraps `lngDegrees` to `(−180°, 180°]` before converting to 3857 meters — - * mimicking what a *projected* source CRS's forward-to-3857 composition - * does in practice: it passes through a periodic geographic intermediate - * that proj4 normalizes, so a native lng outside that range wraps (see - * `buildPieceReprojection`'s doc comment). A source CRS that's *directly* - * EPSG:4326 (the `antimeridianCut` detection tests below) does not — hence - * two separate projection mocks in this file. + * An EPSG:4326 source: proj4 4326→4326 passes native longitudes through + * un-normalized (e.g. −204°), but 4326→3857 wraps them. */ -function lngDegreesToWrappedMercatorX(lngDegrees: number): number { - let wrapped = lngDegrees; - while (wrapped > 180) { - wrapped -= 360; - } - while (wrapped <= -180) { - wrapped += 360; - } - return (wrapped / 180) * HALF_CIRCUMFERENCE; -} - -const WRAPPING_PROJECTIONS = { - projectTo3857: (x: number, y: number): [number, number] => [ - lngDegreesToWrappedMercatorX(x), - y, - ], - projectFrom3857: (x: number, y: number): [number, number] => [ - (x / HALF_CIRCUMFERENCE) * 180, - y, - ], - // Detection (`antimeridianCut`) needs native, un-normalized corner lngs — - // see the `antimeridian.tif` comment below. Identity here matches an - // EPSG:4326 source (proj4 4326→4326 does not renormalize). - projectTo4326: identity, - projectFrom4326: identity, +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, }; -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 }); +/** + * 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).toBeDefined(); - expect(metadata._antimeridianCut?.uCut).toBeCloseTo(24 / 42, 9); +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("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); +/** + * 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 }; +} - const metadata = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); +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, + ), +}; - expect(metadata._antimeridianCut).toBeUndefined(); - expect(metadata._westReprojection).toBeUndefined(); - expect(metadata._eastReprojection).toBeUndefined(); - }); +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); - describe("piece reprojection (buildPieceReprojection)", () => { - // Same antimeridian.tif shape (native lngs −204..−162, uCut = 24/42), - // but with WRAPPING_PROJECTIONS so `forwardReproject` actually exercises - // proj4-style wraparound — the mechanism the fix corrects for. - function crossingMetadata() { - 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], - ...WRAPPING_PROJECTIONS, + it("converges", () => { + expect(reprojector.getMaxError()).toBeLessThanOrEqual(0.125); }); - const tileset = new RasterTileset2D(tilesetProps(), descriptor); - return tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); - } - - it("leaves forwardTransform/inverseTransform untouched — the correction lives in forwardReproject, not the geotransform", () => { - const metadata = crossingMetadata(); - expect(metadata._westReprojection!.forwardTransform(5, 5)).toEqual( - metadata.forwardTransform(5, 5), - ); - expect(metadata._eastReprojection!.inverseTransform(5, 5)).toEqual( - metadata.inverseTransform(5, 5), - ); - }); - - it("leaves a point that wraps to common-space x ≥ TILE_SIZE/2 unshifted (west piece's own territory)", () => { - const metadata = crossingMetadata(); - // Native lng −204° wraps to +156° → common-space x ≈ 477.9 (≥ 256). - const [cx] = metadata._westReprojection!.forwardReproject(-204, 24); - expect(cx).toBeGreaterThanOrEqual(TILE_SIZE / 2); - expect(cx).toBeCloseTo(477.87, 1); - }); - - it("leaves the east piece's interior unshifted (already its own natural, near-0 position)", () => { - const metadata = crossingMetadata(); - // Native lng −162° wraps to −162° (already in range) → common-space - // x ≈ 25.6 (< 256) — left as-is, since this IS the east piece's own - // natural position. Forcing it to join the west piece's frame (the old - // +TILE_SIZE behavior) put it a full world away from wherever the - // camera was actually looking whenever only the east piece was in - // view — see `buildPieceReprojection`'s doc comment. - const [cx] = metadata._eastReprojection!.forwardReproject(-162, 24); - expect(cx).toBeCloseTo(25.6, 1); - }); - - it("shifts the seam corner by -TILE_SIZE for the east piece, joining its own interior instead of the west piece's frame", () => { - // The seam itself: native lng −180° wraps to exactly +180° → - // common-space x = TILE_SIZE exactly (512). For the WEST piece this is - // its own natural east edge — no shift. For the EAST piece this same - // raw value is its own west edge, but belongs to the east piece's - // natural (near-0) frame, so it shifts by -TILE_SIZE to join up with - // that piece's interior (≈25.6) rather than sitting a full world away. - const metadata = crossingMetadata(); - const [westCx] = metadata._westReprojection!.forwardReproject(-180, 24); - const [eastCx] = metadata._eastReprojection!.forwardReproject(-180, 24); - expect(westCx).toBeCloseTo(TILE_SIZE, 9); - expect(eastCx).toBeCloseTo(0, 9); - }); - it("round-trips forwardReproject/inverseReproject for a shifted point", () => { - const metadata = crossingMetadata(); - const { forwardReproject, inverseReproject } = - metadata._eastReprojection!; - const [cx, cy] = forwardReproject(-162, 24); - const [x, y] = inverseReproject(cx, cy); - expect(x).toBeCloseTo(-162, 6); - expect(y).toBeCloseTo(24, 6); - }); - - it("round-trips forwardReproject/inverseReproject for a west-piece point whose native lng is outside ±180 (the case that actually wraps)", () => { - // Unlike the east-piece round-trip above (native lng -162, already - // inside (-180,180], so a naive inverse is a no-op there), -191.4 is - // native west-piece territory that genuinely wraps: proj4 normalizes - // it to +168.6 going forward. A naive inverse that reduces to - // [0, TILE_SIZE) and unprojects would wrongly return +168.6 instead of - // -191.4 — exactly the bug `RasterReprojector`'s mesh refinement hit - // (reported as `currentError=360`, a full-period disagreement). - const metadata = crossingMetadata(); - const { forwardReproject, inverseReproject } = - metadata._westReprojection!; - const [cx, cy] = forwardReproject(-191.4, 24); - const [x, y] = inverseReproject(cx, cy); - expect(x).toBeCloseTo(-191.4, 6); - expect(y).toBeCloseTo(24, 6); - }); - }); + 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); + } + }); - describe("wide crossing tile (>170° per-piece width, previously rejected outright)", () => { - // Native lngs -100..190 (un-normalized, west { + 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); + } }); - const descriptor = new AffineTileset({ - levels: [level], - ...WRAPPING_PROJECTIONS, + + 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); + } }); - const tileset = new RasterTileset2D(tilesetProps(), descriptor); - return tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); - } - - it("still detects and cuts a >170°-wide piece", () => { - const metadata = wideCrossingMetadata(); - expect(metadata._antimeridianCut).toBeDefined(); - expect(metadata._antimeridianCut?.uCut).toBeCloseTo(280 / 290, 5); - }); - it("does not wrongly shift a west piece's legitimate low-x point (the false positive the old heuristic hit)", () => { - const metadata = wideCrossingMetadata(); - // Native lng -90°, 10° in from the tile's -100° corner: no proj4 - // wrap occurs at all, x=128 is already correct. The old - // `x < TILE_SIZE/2` test would have wrongly added TILE_SIZE here. - const [cx] = metadata._westReprojection!.forwardReproject(-90, 24); - expect(cx).toBeCloseTo(128, 3); + 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); + } + } + } + }); }); + } - it("renders an east-piece point that genuinely wrapped at its own natural near-0 position", () => { - const metadata = wideCrossingMetadata(); - // Native lng 185°, 5° past the seam: proj4 wraps this to -175°. - const [cx] = metadata._eastReprojection!.forwardReproject(185, 24); - expect(cx).toBeCloseTo(7.11, 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); + } }); }); }); 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); + }); +}); From bc02cec3372ece4cd4932b503fefb33cfa1367cd Mon Sep 17 00:00:00 2001 From: willjnz Date: Thu, 1 Oct 2026 22:03:09 +1000 Subject: [PATCH 20/21] Clean up --- antimeridian-plan.md | 78 -------- ...05-27-antimeridian-crossing-tile-design.md | 171 +----------------- .../README.md | 6 +- .../src/App.tsx | 13 +- .../raster-tileset/raster-tile-traversal.ts | 2 +- .../src/raster-tileset/raster-tileset-2d.ts | 15 +- 6 files changed, 15 insertions(+), 270 deletions(-) delete mode 100644 antimeridian-plan.md diff --git a/antimeridian-plan.md b/antimeridian-plan.md deleted file mode 100644 index 77ae6cbd..00000000 --- a/antimeridian-plan.md +++ /dev/null @@ -1,78 +0,0 @@ -# Antimeridian: mesh-driven unwrap + clip (any seam shape) - -## Context - -Today `antimeridianCut` (`packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts`) estimates where the seam is from 4 corner longitudes and only accepts **vertical** cuts. Any other crossing falls back to one full mesh and diverges (#366). The approach is replaced with two CRS-agnostic operations, so each seam shape becomes a test case rather than a code path: - -1. **Unwrap in the reprojector.** Each new mesh vertex's output x snaps to the copy nearest its parent triangle's *interpolated* x. Delatin already computes that value for its error check. The interpolation is off only by reprojection error, which is far inside the ±256 (180°) snap tolerance. -2. **Clip the finished mesh at x = k·512.** Clip the unwrapped mesh where it crosses each world boundary, and shift each part back into [0, 512]. That is the actual discontinuity, so the cut is exact for vertical, slanted, curved, and polar seams. The result is one mesh and one layer per tile, with no west and east pieces. - -Fixtures (`fixtures/geotiff-test-data/rasterio_generated/fixtures/`): `antimeridian.tif` (vertical), `antimeridian_utm60.tif` (curved), `antimeridian_360.tif` (world-spanning). The slanted fixture is added below. - -## Step 0: verify single-mesh world-copy visibility first - -Before building anything, check whether deck.gl draws a mesh with x in [500, 530] when zoomed tight on the east side (x ≈ 14, only the offset-0 subViewport). If it does, step 3's clip only needs splitting with no shift. Expected: it does not, so the clip and shift are needed. Do a throwaway check in `examples/cog-basic` by hard-coding a +512 shift on a normal tile. - -## Changes - -### 1. `packages/raster-reproject/src/delatin.ts`: optional periodic x -- Add a constructor option `wrapX?: number` (the period; deck.gl-raster passes `TILE_SIZE`). When it is unset, behavior is unchanged for other consumers. -- Add a helper `snapToCopy(x, ref, period) = x + Math.round((ref - x) / period) * period`, exported for the traversal to reuse. -- `_findReprojectionCandidate`: store the max-error sample's interpolated `outSampleX` in a new `_candidatesOutX[t]`, next to `_candidatesUV`. -- `_addPoint(u, v, refX?)`: if `wrapX` and `refX` are both set, snap the exact output x to `refX`. `_step` passes `_candidatesOutX[t]`. -- **Seed vertices have no parent triangle.** Unwrap each one along the path from the previous seed vertex. Bisect the uv segment and snap each sample to the one before it, until every step is under `period / 4` (with a depth cap). This is needed for a −180..180 tile, whose corners both land on x = 0 ≡ 512. Without it, they collapse. -- The error check needs `inverseReproject` to accept unwrapped x outside [0, 512). See step 2. - -### 2. `packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts` -- `_unprojectPosition` becomes exact for unwrapped x without being told about the CRS: `lng = cx · 360 / 512 − 180` (no wrap), `lat` from `cy` (Mercator inverse), then `descriptor.projectFrom4326(lng, lat)`. For a 4326 source, this passes un-normalized longitudes straight through (e.g. −204). For projected sources, proj4's `adjlon` handles longitudes past ±180. -- Delete `_antimeridianCut`, `_westReprojection`, `_eastReprojection`, `buildPieceReprojection`, and the cut detection in `getTileMetadata`. -- **World-spanning:** tiles are boundless, so the 36×2 overview tile of `antimeridian_360` nominally spans 640°. Clip the seed to the tile's valid data rect by mapping `descriptor.projectedBounds` through the tile's `inverseTransform`. Intersect it with the existing ±85.051° band in `web-mercator-clamp.ts`, which gains a `uvRect` param. This stops the reprojector from meshing padding, which would overlap itself after unwrapping. - -### 3. Mesh clip, in `reprojectorToMesh` (deck.gl-raster) -- Given `wrapX`, find each triangle's `k = floor(x / 512)` range. If it falls in a single copy, shift by `−k·512`. Otherwise, clip the triangle against each `x = k·512` line (Sutherland–Hodgman against vertical lines). Fan-triangulate each part; the parts are convex. Interpolate the new vertices' uv and y linearly along the cut edge, so texture mapping matches the unclipped mesh exactly. Shift each part into [0, 512]. The 64-bit high/low position split runs after this, unchanged. -- Debug triangles (`renderDebugLayer`) read `exactOutputPositions`. Draw them from the clipped mesh instead, or accept that the debug wireframe is unclipped. - -### 4. Traversal: `packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts` -- `_getGenericBoundingVolume`: drop `antimeridianCut` and `unwrapCommonSpaceX`. Unwrap the 9 reference points with `snapToCopy`, each against its already-unwrapped grid neighbour (the same bisect-along-edge rule as the seed). That gives one tight box straddling 512, as now. -- Dataset bounds (~L990): unwrap the densified dataset edge the same way. -- Keep the `subViewports != null` world-copy gate fix. - -### 5. Render and debug consumers -- `raster-tile-layer.ts`: delete `_renderAntimeridianTile`. Every Web Mercator tile goes through `_renderNormalTile`, with `wrapX: TILE_SIZE` passed through a new `RasterLayer` prop to the reprojector and `reprojectorToMesh`. -- `layer-utils.ts` `renderDebugTileOutline` and `deck.gl-geotiff/src/multi-cog-layer.ts` (~L866): delete the west/east piece outline branches (`pieceBoxPath`, `pieceBoxWgs84`, `unwrapBoxLngs`). Draw the plain corner outline in lng/lat with `PathLayer`'s `wrapLongitude: true`. -- Delete `antimeridian-cut.ts` and its `_unwrapEastLng` export, **except** where `mosaic-layer.ts` `normalizeSourceBbox` uses `unwrapEastLng`. Move that one-liner into `mosaic-layer.ts`. - -### 6. Fixtures and example -- Add a slanted fixture to the geotiff-test-data submodule as `antimeridian_rotated.py`: 4326, `Affine.translation(-204, 24) * Affine.rotation(20) * Affine.scale(1, -1)`, 42×42. Generate its `.tif` and `_info.md`. -- Add "vertical" and "rotated" entries to `examples/cog-basic/src/App.tsx`, next to the uncommitted UTM60 and 360° entries. - -### 7. Docs -- Rewrite the "Approach", "Seam handling", and "Locating the cut" sections of `dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md` around unwrap-plus-clip. Keep cut-in-two as history: it was the vertical-only MVP. - -## Tests - -Run the tests with `pnpm vitest` in `packages/raster-reproject` and `packages/deck.gl-raster`. - -- `raster-reproject`: - - With `wrapX` unset, the output is identical to today's. - - With `wrapX` set and a wrapping projection mock: the mesh converges, and adjacent vertices never jump more than 256. Test vertical, slanted (rotated affine), curved (UTM-like nonlinear), and a −180..180 tile, which must not collapse. - - `snapToCopy` boundary cases. -- Mesh clip: - - Total area is preserved. - - Every position is in [0, 512]. - - At a seam-cut vertex, uv matches the lerp. - - No triangle straddles a boundary. -- `raster-tileset-2d-antimeridian.test.ts`: rewrite against the new behavior. For each of the 4 shapes, cover the 4326 identity mock and the wrapping (projected) mock: - - The mesh converges with no `did not converge` warning. - - `inverseReproject(forwardReproject(p)) ≈ p` after the snap. - - World-spanning overview tile: the seed is clipped to `u ≤ 36/64`. -- Traversal: a bounding volume for vertical, slanted, and curved tiles is one tight box around 512. -- **Visual** (`examples/cog-basic`, `pnpm dev`), all 4 fixtures: - - Continuous across ±180°. - - No seam gap. - - Visible when zoomed tight on either side alone. - - Before/after against the current branch for `antimeridian.tif` and the 3832 DEP COG. - -## Risks / out of scope -- The refinement snap needs each coarse triangle's interpolation error to be under 180°. A seed triangle spanning a wildly nonlinear region, such as a polar stereographic tile covering the pole, could violate this. Mitigation: the seed path-unwrap already densifies; add a `ponytail:` note and seed a denser grid if a polar fixture shows it. -- Globe view is unchanged; it doesn't pass `wrapX`. 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 ef27241e..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:** Implemented as unwrap + clip (2026-10-01), superseding the cut-in-two MVP described from "Approach: cut the tile in two" on +- **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) @@ -31,7 +31,7 @@ projectPosition = (x, y) => rescaleEPSG3857ToCommonSpace(descriptor.projectTo385 ## Current approach (2026-10-01): unwrap in the reprojector, clip the mesh -The cut-in-two MVP below 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 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: 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. @@ -39,174 +39,9 @@ The cut-in-two MVP below only handled **vertical** seams: it estimated where the Supporting changes: - **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). The `subViewports != null` world-copy gate fix below still applies. +- **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.) 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). 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. - -The rest of this document is the cut-in-two MVP's design, kept for history. - -## Approach: cut the tile in two - -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 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 - -> **Revised twice since first written (2026-09-29).** 3 mechanisms: (1) a wrapped-piece-only sign -> test (struck through below), (2) a per-point midpoint test — fixed (1), had its own width limit, -> (3) the per-vertex mechanism below — fixes (2) too, and is what's shipped. Tests: -> `raster-tileset-2d-antimeridian.test.ts`, `antimeridian-cut.test.ts`. - -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. - -**As implemented**, the fix corrects each vertex's raw (proj4-wrapped) common-space `x` using a reference computed from *that vertex's own position*, not a single value shared by the whole piece. Every tile that crosses the antimeridian already gets its seam located by `antimeridianCut` ([`antimeridian-cut.ts`](../../packages/deck.gl-raster/src/raster-tileset/antimeridian-cut.ts)), which — via `unwrapEastLng`, the GeoJSON (RFC 7946 §5.2) "west > east marks a crossing" idiom — locates the seam as a fraction `uCut` of the tile's width, and records the tile's total angular width `totalSpanDeg`. `unwrapCommonSpaceX(x, u, cut, tileSize)` reuses exactly that: for a vertex at fractional position `u` along the tile (0 at the west edge, 1 at the east edge — the same domain `uCut` lives in), it computes - -```ts -const signedDegreesFromSeam = (u - cut.uCut) * cut.totalSpanDeg; -const expectedX = tileSize + signedDegreesFromSeam * (tileSize / 360); -return x + Math.round((expectedX - x) / tileSize) * tileSize; -``` - -— an *expected* `x` derived purely from the vertex's own distance from the seam, then snaps the actual wrapped `x` to the copy nearest that expectation. The seam itself (`u = cut.uCut`) always maps to exactly `tileSize`; the west side extends below it, the east side above it — this is the "combined" form the traversal's bounding volume wants directly (one contiguous box around the seam). `buildPieceReprojection` reuses it for the west piece's `forwardReproject` unchanged, and re-anchors the east piece into its own local frame (seam at `0`, not `tileSize`) by subtracting `tileSize`. `inverseReproject` undoes that re-anchor and reduces into the canonical `[0, tileSize)` range (`((x % tileSize) + tileSize) % tileSize`) — this exactly inverts whatever integer multiple of `tileSize` the forward direction applied, without needing to replay the correction logic. - -One outcome worth calling out: this gives each piece its own **natural** common-space position (west near `x≈512`, east near `x≈0`) instead of always anchoring both near a single shared frame. That turned out to be load-bearing for the traversal fix below — a piece now needs no help from the other piece (or the seam) being in view to be found and drawn correctly. - -#### Why a per-point, position-derived reference — not a fixed one - -Two simpler mechanisms were tried and superseded first — kept visible here since they're the reasoning that rules out the "obvious" fixes. - -**Attempt 1 (original design, struck through below): a wrapped-piece-only sign test.** A single constant-per-piece shift is wrong exactly at that piece's own boundary corner — the vertex that coincides with the *other* piece's edge and needs no shift at all. Whether a vertex needs the shift depends on *that vertex's own position*, not on which piece asked, so a per-piece constant can't tell the two apart. - -**Attempt 2: a symmetric per-point midpoint test** (`x < tileSize/2 → +tileSize`, mirrored for east) — fixed attempt 1 by testing every vertex, and shipped for a while. Its failure is subtler: it's a *magnitude* test against a fixed midpoint, so once a piece is wide enough (≳180°), some of its own legitimately-placed vertices land on the "wrong" side *without having wrapped at all* — indistinguishable from a vertex that actually wrapped. The result is a silently torn mesh. - -Generally: **any correction anchored to one fixed reference for the whole piece — a midpoint, a corner, a longitude-space comparison — can only disambiguate up to half a period (180°) away from itself.** Recovering a 360°-periodic signal's phase from a sample more than half a period from its only reference isn't possible — a math limit, not an implementation gap. Two variants confirmed it: - -- **Piece-relative fixed reference** (`TILE_SIZE` for west / `0` for east instead of the bare `tileSize/2`) is *arithmetically identical* to attempt 2 — for this geometry the only legitimate fixed reference already *is* `0` or `TILE_SIZE`, so renaming the constant changes nothing. -- **Unwrapping in longitude space first** (EPSG:3857's `x` is linear in longitude, so correct via `unwrapEastLng` before the linear rescale instead of after) looked like it might reach 360° — but the reference sits at one *edge* of the piece, not its center, so the usable range is still capped near 180°. Moving the correction earlier doesn't change how much information the projection already threw away. - -**What breaks the wall**: the reference doesn't have to be fixed — it can be *recomputed per vertex* from already-trusted, width-independent geometry. `unwrapEastLng` (and the corner data `antimeridianCut` derives from it — the same idiom `normalizeSourceBbox` uses for `MosaicLayer` indexing) is a corner-level comparison that works at *any* width, since comparing two known longitudes never degrades the way a magnitude test does. `unwrapCommonSpaceX`'s `expectedX` extends that same idiom from "once, at the corners" to "at every vertex" — each vertex's reference tracks its own position, so it stays accurate no matter how wide the piece is. - -Nor is this the neighbor-relative phase-unwrap family that sank #374 (walking samples in order, accumulating an offset — order-dependent, one bad sample corrupts everything downstream). Each vertex's reference here is computed independently from validated corner geometry; no walk order, no inter-vertex dependency. - -**The one remaining limit isn't new.** The correction only means something if the tile's corners describe a real, non-self-overlapping span — total width under 360°. A tile ≥360° wide already has two pixel columns claiming the same longitude: invalid data, no correct rendering regardless of cut. `antimeridianCut` rejects it outright (`MAX_TOTAL_SPAN_DEG = 360°`), falling back to a full-mesh render like an unsupported slanted/curved cut — see "Locating and selecting a crossing tile in the traversal" below. (An earlier revision instead rejected any *piece* ≥170° wide — `MAX_PIECE_SPAN_DEG` — before the per-vertex mechanism removed that narrower limit.) - -
-Attempt 1's original text (superseded, kept for history) - -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. - -
- -### Locating and selecting a crossing tile in the traversal - -> **Added 2026-09-29.** Not in the original design — found once the mechanism above shipped: a crossing tile disappeared once zoomed in tight enough on one side alone that ±180° itself scrolled out of view, reappearing as soon as the seam came back into view. Root cause was that `raster-tile-traversal.ts`'s frustum-culling traversal had no antimeridian awareness at all — three compounding defects, all in `packages/deck.gl-raster/src/raster-tileset/`: - -1. **Malformed per-tile bounding volume** — see the updated "Traversal" bullet above. Fixed by reusing `unwrapCommonSpaceX` in `_getGenericBoundingVolume`. -2. **Malformed dataset-level bounds.** The same problem one level up: `wgs84Bounds` (the `insideBounds` pre-filter checked before frustum culling on every tile) is a naive min/max over densified samples, which for a crossing dataset doesn't correspond to the real west/east corners. Once (1) was fixed this caused a *new* failure — the corrected tile box and the uncorrected dataset box only touched at `x=512` instead of overlapping, rejecting the tile at every zoom. Fixed in `getTileIndices` by recomputing the dataset's own bounds from its real corner longitudes when the dataset itself crosses. -3. **World-copy offset-pass gate tied to the wrong condition.** The extra ±1..±`MAX_MAPS` bounding-volume passes (for `renderWorldCopies`/repeat mode) were gated on `viewport.subViewports.length > 1` — whether the *viewport's own* visible span currently straddles a ±180° multiple. That's unrelated to whether a *tile's* position is on a different world-copy frame than the viewport's canonical one, which is what the offset passes actually need to answer. Once zoomed in tight to one side, this wrongly skipped the exact offset pass that would have placed the (now correctly shaped) bounding volume in front of the frustum. Fixed by gating on `viewport.subViewports != null` instead — "repeat mode is active at all," not "the viewport itself currently straddles a seam." The early-break in the offset walk keeps this cheap when nothing is near a seam. - -The same three-defect pattern, adapted to `MosaicTileset2D`'s structurally different Flatbush/lng-lat-bbox spatial index (`packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts`), needed only the world-copy-gate fix (its own version of #3) — a crossing source's bbox is normalized onto a continuous frame by `normalizeSourceBbox` (`mosaic-layer.ts`) before indexing, so it's found only by a query shifted ±360°, ±720°…, which the same `subViewports != null` gate now permits once zoomed in tight. `MultiCOGLayer extends RasterTileLayer`, so it already inherited defects #1/#2's fixes (and the seam-handling fix above) for free via the shared mesh/traversal code. - -Defect #3's `MAX_MAPS = 3` cap (the number of extra world-copy offset passes the traversal walks) turned out not to need any change for wide pieces either, despite the "Seam handling" fix above allowing much wider pieces than before: each piece's corrected `x` is bounded by construction to `[0, tileSize]` for any tile under the `MAX_TOTAL_SPAN_DEG` limit, so a piece never needs more than the world copies ordinary (non-crossing) tiles already rely on. See [`world-copies.md`](../world-copies.md) for the general offset-search mechanism this all sits on top of. - -### 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** — originally planned as a **two-box bounding volume** for a crossing tile (west ≈ `[510,512]`, east ≈ `[0,2]`). **As implemented**, it's one tight box instead: the traversal's `_getGenericBoundingVolume` (`raster-tile-traversal.ts`) applies the same `unwrapCommonSpaceX(x, u, cut, tileSize)` correction used by `forwardReproject` to its 9 sampled reference points (`u` = each point's own `relX`) before fitting the `OrientedBoundingBox`, so a crossing tile gets a single box straddling `x=512` rather than one spanning most of `[0,512]` — correct for a piece of any width up to the `MAX_TOTAL_SPAN_DEG` self-overlap limit, not just a narrow one. See "Locating and selecting a crossing tile in the traversal" below for why a second, independent fix was also needed here. - -## 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 common-x 0 — each piece sits at its own natural position (see "Seam handling" above) rather than a shared frame, and abut across the world-copy boundary via deck.gl's own repeat-rendering. 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. -- Bounding volume for a crossing tile is a single tight box straddling the seam (not one spanning most of `[0,512]`); correct selection under the world-copy traversal, including when zoomed in tight to one piece alone (see "Locating and selecting a crossing tile in the traversal" above). - -**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.) diff --git a/examples/antimeridian-mosaic-multi-example/README.md b/examples/antimeridian-mosaic-multi-example/README.md index ecc0a61c..792a7b94 100644 --- a/examples/antimeridian-mosaic-multi-example/README.md +++ b/examples/antimeridian-mosaic-multi-example/README.md @@ -1,10 +1,6 @@ # Antimeridian: Mosaic + MultiCOG Example -Same DEP GeoMAD test catalog as [`antimeridian-example`](../antimeridian-example), but composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite) instead of a single-band `COGLayer` (the needed use pattern). See [#575](https://github.com/developmentseed/deck.gl-raster/issues/575). - -`src/data.ts` reads every item directly from the catalog's STAC-geoparquet (via `hyparquet`, no server-side indexing step) and pulls out the bbox and R/G/B asset hrefs. `MosaicLayer` indexes its `sources` by `bbox` in a Flatbush R-tree, unwrapping a GeoJSON-flipped bbox (`minX > maxX`, RFC 7946 §5.2 — the crossing item's encoding) onto a continuous frame internally before indexing. - -The debug overlay toggle exercises `MultiCOGLayer`'s own tile-outline rendering (`debug`/`debugOpacity`/`debugLevel`), confirming it also knows about the antimeridian mesh split. +Digital Earth Pacific GeoMAD composed through `MosaicLayer` + `MultiCOGLayer` (R/G/B band composite). ## Setup diff --git a/examples/antimeridian-mosaic-multi-example/src/App.tsx b/examples/antimeridian-mosaic-multi-example/src/App.tsx index 05dcd545..409edeb2 100644 --- a/examples/antimeridian-mosaic-multi-example/src/App.tsx +++ b/examples/antimeridian-mosaic-multi-example/src/App.tsx @@ -86,17 +86,8 @@ export default function App() { sourcePath="examples/antimeridian-mosaic-multi-example" > - Same {geomadItems.length || 12} DEP GeoMAD items as{" "} - - antimeridian-example - - , but composed as R/G/B through MosaicLayer +{" "} - MultiCOGLayer (the needed use pattern), not just a - single-band COGLayer. See{" "} - - #575 - - . + Digital Earth Pacific GeoMAD composed through `MosaicLayer` +{" "} + `MultiCOGLayer` (R/G/B band composite). 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 3d8ed7dd..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 @@ -125,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. 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 d9d31db9..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 @@ -20,6 +20,7 @@ import { lngLatToWorld, worldToLngLat } from "@math.gl/web-mercator"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import { getTileIndices, + MAX_WEB_MERCATOR_LAT, rescaleCommonSpaceToEPSG3857, TILE_SIZE, } from "./raster-tile-traversal.js"; @@ -35,9 +36,6 @@ import type { } from "./types.js"; import { createInitialWebMercatorTriangulation } from "./web-mercator-clamp.js"; -/** Web Mercator's latitude limit, in degrees. */ -const MAX_LAT = 85.0511287798066; - /** Type returned by {@link RasterTileset2D.getTileMetadata} */ export type RasterTileMetadata = { /** @@ -206,7 +204,10 @@ export class RasterTileset2D extends Tileset2D { // one. this.projectPosition = (x, y) => { const [lng, lat] = descriptor.projectTo4326(x, y); - return lngLatToWorld([lng, Math.max(-MAX_LAT, Math.min(MAX_LAT, lat))]); + return lngLatToWorld([ + lng, + Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat)), + ]); }; this.unprojectPosition = (cx, cy) => { if (cx >= 0 && cx <= TILE_SIZE) { @@ -225,12 +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. + // ±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), ]; } From e08aff2a1cabbfd1a9b41abbc35ecd0ffdb79a85 Mon Sep 17 00:00:00 2001 From: willjnz Date: Thu, 1 Oct 2026 22:19:08 +1000 Subject: [PATCH 21/21] Fix debug overlay. It was off for many examples --- packages/deck.gl-raster/src/layer-utils.ts | 64 +++++++++++-------- .../raster-tile-layer/raster-tile-layer.ts | 3 +- .../deck.gl-raster/tests/layer-utils.test.ts | 30 +++++++++ 3 files changed, 68 insertions(+), 29 deletions(-) create mode 100644 packages/deck.gl-raster/tests/layer-utils.test.ts diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts index 3d2bbc22..83f6fb61 100644 --- a/packages/deck.gl-raster/src/layer-utils.ts +++ b/packages/deck.gl-raster/src/layer-utils.ts @@ -4,43 +4,51 @@ 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]); + } + } - // Unwrap so a tile crossing ±180° (corners e.g. 179.97° and −179.17°) - // gets its label at the seam, not on the far side of the globe. + // Label at the middle of the unwrapped tile. const center = [ - (topLeftWgs84[0] + snapToCopy(bottomRightWgs84[0], topLeftWgs84[0], 360)) / - 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`, 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 1c108b0f..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 @@ -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, ); } 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); + }); +});