Skip to content

Commit a56f239

Browse files
kylebarronclaude
andauthored
feat: reprojector initial-triangulation seed + clamp Web Mercator meshes to ±85.051° (#574)
* docs(specs): add antimeridian crossing-tile (cut-in-two) design Design for rendering imagery crossing ±180° in Web Mercator (issues #171, #366). Splits a crossing tile at the antimeridian into west/east pieces so each reprojects as a normal non-crossing tile — avoiding the proj4-rewrap unwrap that prior attempts (#353/#374/#269) stumbled on. Generalizes the RasterReprojector to accept a delaunator-shaped initial-triangulation seed (subsuming #351 uvBounds / pole clamp), splits in _renderSubLayers into two single-mesh RasterLayers, and uses a two-box bounding volume composing with the merged world-copy traversal (#518). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(specs): refine antimeridian spec — add test fixture, handle slanted cuts - Use the vendored geotiff-test-data antimeridian.tif fixture (42x42 EPSG:4326, crosses -180 at column 24) as the primary deterministic crossing test. - Handle slanted (rotated-geotransform) cuts, not just vertical: any straight cut yields convex pieces delaunator triangulates exactly; error only on curved/concave cuts. createInitialConditions is therefore part of the MVP crossing path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raster-reproject): add createInitialConditions (delaunator-backed seed) Add the InitialTriangulation type and a tree-shakeable createInitialConditions helper that builds a Delaunay seed from a UV point set. delaunator is confined to its own module (initial-conditions.ts); delatin.ts does not import it and the package is sideEffects:false, so it tree-shakes out for consumers that don't use it. Foundation for antimeridian cut-in-two and the sub-domain capability in #351. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raster-reproject): document delaunator seed pattern, don't ship a wrapper Per review: a one-line delaunator wrapper isn't worth a runtime dependency. Expose only the InitialTriangulation type and show the delaunator one-liner in its docstring. delaunator moves to devDependencies — used by tests to validate winding compatibility, not shipped to consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(specs): drop createInitialConditions wrapper from antimeridian design raster-reproject exposes only the InitialTriangulation type + documents the delaunator one-liner; delaunator is a dev/test dep (winding validation), not shipped. Runtime seed-building for crossing tiles is the deck.gl-raster builder's job (follow-up plan). Mark stage 1 done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raster-reproject): seed RasterReprojector from an initial triangulation Generalize the constructor to accept an optional initialTriangulation seed (delaunator's data shape), defaulting to a hardcoded unit-square seed so behavior is unchanged and the package needs no runtime triangulation dep. Refinement only ever splits existing triangles, so a sub-domain seed confines the mesh to that region. Tests build seeds via delaunator (the documented pattern) to validate winding compatibility + sub-domain confinement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(raster-reproject): add rectangleSeed helper for sub-rectangle seeds Build an axis-aligned 2-triangle rectangle seed for a UV sub-rectangle (no delaunator, runtime-safe). UNIT_SQUARE_SEED is now rectangleSeed(0,0,1,1). Used to clamp a mesh to a UV band — e.g. the valid Web Mercator latitude band (#182 / #351) — and reused by the antimeridian vertical-cut case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deck.gl-raster): add webMercatorClampSeed (clamp mesh to ±85.051°) Pure helper that returns a rectangleSeed clamping a north-up geographic tile's reprojection mesh to the Web Mercator latitude band, or undefined when no clamp is needed/possible (rotated/projected tiles, fully-polar tiles). Avoids the degenerate near-pole triangles from #182 / #351. Unit-tested with synthetic corner latitudes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deck.gl-raster): clamp Web Mercator meshes to valid latitude band Wire the reprojector seed through the render path: RasterLayer gains an initialTriangulation prop (passed to RasterReprojector, regenerated on change); getTileMetadata computes a per-tile _webMercatorReprojectorSeed via webMercatorClampSeed; _renderSubLayers passes it in the Web Mercator branch only (globe shows the poles, full mesh). Fixes the degenerate near-pole triangles for EPSG:4326 imagery reaching ±90° (#182 / #351). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(deck.gl-raster): name clamp seed as initialTriangulation Rename webMercatorClampSeed → webMercatorInitialTriangulation and the tile metadata field _webMercatorReprojectorSeed → _webMercatorInitialTriangulation, for consistency with the InitialTriangulation type and the RasterLayer.initialTriangulation prop (drops the ad-hoc 'seed'/'ReprojectorSeed' terms). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(deck.gl-raster): rename clamp fn to createInitialWebMercatorTriangulation Verb-prefixed name for the builder (was webMercatorInitialTriangulation); the tile metadata field stays _webMercatorInitialTriangulation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(raster-reproject): rename rectangleSeed -> triangulateRectangle Active verb name for the helper that triangulates a UV rectangle into an InitialTriangulation; internal UNIT_SQUARE_SEED -> UNIT_SQUARE_TRIANGULATION; test file renamed to match. Updates the deck.gl-raster clamp caller too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: apply triangulateRectangle rename across source + caller Completes the rename (a657eb8 only moved the test file): rectangleSeed -> triangulateRectangle in delatin.ts + index export + the renamed test, and the deck.gl-raster web-mercator-clamp caller. UNIT_SQUARE_SEED -> UNIT_SQUARE_TRIANGULATION. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8a329b3 commit a56f239

12 files changed

Lines changed: 548 additions & 16 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Render imagery crossing the antimeridian by cutting tiles in two
2+
3+
- **Date:** 2026-05-27
4+
- **Issues:** [#171](https://github.com/developmentseed/deck.gl-raster/issues/171), [#366](https://github.com/developmentseed/deck.gl-raster/issues/366)
5+
- **Status:** Proposed
6+
- **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
7+
- **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)
8+
- **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)
9+
10+
## Problem
11+
12+
A single raster whose source extent crosses ±180° longitude does not render correctly in a Web Mercator viewport. This covers:
13+
14+
- A global EPSG:4326 COG whose bounds touch or slightly overhang ±180° (e.g. `[-180.0012, …, 179.9987, …]`), where the dateline-edge tile straddles the seam.
15+
- A genuine crossing scene whose source grid wraps past ±180° (stored with longitudes running e.g. 170° → 190°).
16+
17+
"Antimeridian" decomposes into three problems: **A** — tile *selection* across world copies (#517, fixed in #518); **B** — global-COG mesh divergence (#366); **C** — true crossing imagery (#171). A is merged. This spec addresses **B + C**, which are the same underlying problem at different tile geometries: a tile whose source extent crosses ±180° needs a *continuous* projection to mesh and place correctly.
18+
19+
## Why it's hard
20+
21+
The Web Mercator render path projects each tile through
22+
[`raster-tileset-2d.ts`](../../packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts) `projectPosition`:
23+
24+
```ts
25+
projectPosition = (x, y) => rescaleEPSG3857ToCommonSpace(descriptor.projectTo3857(x, y));
26+
```
27+
28+
`projectTo3857` is proj4 (source CRS → 3857 m). proj4 normalizes longitude to (−180°, 180°], so a tile straddling the dateline has corners at +179° → 3857 x ≈ **+19.9 Mm** (common-x ≈ 510) and +181°/−179° → 3857 x ≈ **−19.9 Mm** (common-x ≈ 2). The `RasterReprojector` (Delatin) mesh triangle spanning those corners covers the whole world, and its pixel-space error never converges (#366: `error=43200` after 10 000 iterations).
29+
30+
**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.
31+
32+
## Approach: cut the tile in two
33+
34+
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:
35+
36+
- The west piece is monotonic in 3857 (all +x → common-x up to 512); the east piece all −x → common-x from 0. **The discontinuity never exists within a piece.**
37+
- `projectTo3857` stays stock — no unwrap, no proj4 reconfiguration, no `+over`.
38+
- The `RasterReprojector` needs zero antimeridian awareness — Delatin converges normally on each piece.
39+
- Mesh vertices stay within `[0, 512]`, so the fp64 high-zoom precision scheme ([`coordinate-systems.md`](../coordinate-systems.md)) is untouched.
40+
- Each piece is a normal tile that the merged world-copy traversal (#518) selects and draws across copies.
41+
42+
The antimeridian becomes *a tile boundary*, which the pipeline already handles, instead of a coordinate-space discontinuity.
43+
44+
### Why not render-as-one
45+
46+
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.
47+
48+
## Locating the cut
49+
50+
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).
51+
52+
The cut's **shape** determines feasibility:
53+
54+
- **Straight cut** (axis-aligned EPSG:4326 → vertical; rotated geotransform → slanted): a straight line splits the unit square into two **convex** pieces.
55+
- **Curved cut** (curved-meridian CRS): at least one piece is **concave**.
56+
57+
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.
58+
59+
## Architecture
60+
61+
The split lives in **one place** — the per-tile sublayer factory — and every other component keeps its single-mesh contract.
62+
63+
```
64+
RasterTileLayer._renderSubLayers (per tile) ← the only split point
65+
├─ normal tile → 1 RasterLayer → 1 RasterReprojector → 1 mesh → 1 MeshTextureLayer
66+
└─ crossing tile → 2 RasterLayers → (each) 1 reprojector → 1 mesh → 1 MeshTextureLayer
67+
```
68+
69+
- **`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.
70+
- **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.
71+
- **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.
72+
- **`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.
73+
- **`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`).
74+
- **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).
75+
76+
## Transparency to end users
77+
78+
The split is entirely below the tile-data boundary:
79+
80+
- **`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).
81+
- **`_renderSubLayers` is library-internal** — standard `COGLayer` / `RasterTileLayer` users never write it.
82+
83+
Caveat: a user who *subclasses* and overrides `_renderSubLayers` would bypass the split.
84+
85+
## Unification
86+
87+
The initial-triangulation seed subsumes several pending needs into one primitive — *the caller hands the reprojector a seed*:
88+
89+
- Normal tile → full unit square → 1 layer (unchanged behavior).
90+
- Antimeridian crossing → west + east seeds → 2 layers.
91+
- Pole clamp (#182) / `uvBounds` (#351) → one clamped-rectangle seed → 1 layer (data beyond ±85.051° is not meshed).
92+
- Collar cutline → one inset-domain seed → 1 layer.
93+
94+
## Scope
95+
96+
**In scope:**
97+
- Web Mercator viewport.
98+
- Straight cut (convex pieces): axis-aligned EPSG:4326 (vertical) *and* rotated geotransforms (slanted).
99+
- Test datasets:
100+
- **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).
101+
- **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).
102+
103+
**Out of scope (deferred):**
104+
- Globe view (separate prototype).
105+
- 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.
106+
107+
## Edge cases & risks
108+
109+
- **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.
110+
- **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)).
111+
- **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).
112+
- **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.
113+
114+
## Test plan
115+
116+
**Unit**
117+
- 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.
118+
- 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.
119+
- Two-box bounding volume for a crossing tile (west/east boxes; correct selection under the world-copy traversal).
120+
121+
**Integration / visual (cog-basic)**
122+
- 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.
123+
- A global EPSG:4326 COG (WorldPop / GEDTM30) renders correctly at the dateline (no `error=43200` divergence; no mislocated rectangles).
124+
- Before/after comparison against current main.
125+
126+
## Implementation stages (high level)
127+
128+
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.)**
129+
2. Cut location (inverse-project the antimeridian) + convexity check (error on a curved/concave cut), on tile metadata.
130+
3. Two-box bounding volume in traversal for crossing tiles.
131+
4. `RasterLayer` `initialTriangulation` prop; `_renderSubLayers` emits 1 or 2 `RasterLayer`s, each seeded from its cut sub-domain.
132+
5. Example wiring + visual validation in cog-basic.
133+
134+
(Detailed task breakdown lives in the implementation plan, not here.)

packages/deck.gl-raster/src/raster-layer.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import type {
77
} from "@deck.gl/core";
88
import { CompositeLayer } from "@deck.gl/core";
99
import { PolygonLayer } from "@deck.gl/layers";
10-
import type { ReprojectionFns } from "@developmentseed/raster-reproject";
10+
import type {
11+
InitialTriangulation,
12+
ReprojectionFns,
13+
} from "@developmentseed/raster-reproject";
1114
import { RasterReprojector } from "@developmentseed/raster-reproject";
1215
import { splitFloat64Array } from "./fp64.js";
1316
import { buildUniformGridMesh } from "./globe-grid-mesh.js";
@@ -76,6 +79,14 @@ export interface RasterLayerProps extends CompositeLayerProps {
7679
*/
7780
reprojectionFns: ReprojectionFns;
7881

82+
/**
83+
* Optional seed triangulation for the reprojector — e.g. to clamp the mesh to
84+
* a UV sub-region (such as the valid Web Mercator latitude band). Defaults to
85+
* the full image. Must be reference-stable across renders to avoid
86+
* regenerating the mesh every frame.
87+
*/
88+
initialTriangulation?: InitialTriangulation;
89+
7990
/**
8091
* The image to display. Accepts any luma.gl `TextureSource` (e.g. a URL,
8192
* `HTMLImageElement`, `ImageData`, etc.). deck.gl manages the texture
@@ -187,7 +198,8 @@ export class RasterLayer extends CompositeLayer<RasterLayerProps> {
187198
props.width !== oldProps.width ||
188199
props.height !== oldProps.height ||
189200
reprojectionFnsChanged ||
190-
props.maxError !== oldProps.maxError;
201+
props.maxError !== oldProps.maxError ||
202+
props.initialTriangulation !== oldProps.initialTriangulation;
191203

192204
if (needsMeshUpdate) {
193205
this._generateMesh();
@@ -199,6 +211,7 @@ export class RasterLayer extends CompositeLayer<RasterLayerProps> {
199211
width,
200212
height,
201213
reprojectionFns,
214+
initialTriangulation,
202215
maxError = DEFAULT_MAX_ERROR,
203216
} = this.props;
204217

@@ -238,6 +251,7 @@ export class RasterLayer extends CompositeLayer<RasterLayerProps> {
238251
reprojectionFns,
239252
width + 1,
240253
height + 1,
254+
{ initialTriangulation },
241255
);
242256
reprojector.run(maxError);
243257
const { indices, positions64High, positions64Low, texCoords } =

packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,11 @@ export class RasterTileLayer<
443443
renderPipeline,
444444
maxError,
445445
reprojectionFns,
446+
// Web Mercator: clamp the mesh to the valid latitude band for tiles
447+
// past ±85.051°. Globe renders the full mesh (it shows the poles).
448+
initialTriangulation: isGlobe
449+
? undefined
450+
: tile._webMercatorInitialTriangulation,
446451
debug,
447452
debugOpacity,
448453
coordinateSystem,

packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from "@deck.gl/geo-layers";
1515
import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers";
1616
import { transformBounds } from "@developmentseed/proj";
17+
import type { InitialTriangulation } from "@developmentseed/raster-reproject";
1718
import type { Matrix4 } from "@math.gl/core";
1819
import { BoundingVolumeCache } from "./bounding-volume-cache.js";
1920
import {
@@ -31,6 +32,7 @@ import type {
3132
TileIndex,
3233
ZRange,
3334
} from "./types.js";
35+
import { createInitialWebMercatorTriangulation } from "./web-mercator-clamp.js";
3436

3537
/** Type returned by {@link RasterTileset2D.getTileMetadata} */
3638
export type RasterTileMetadata = {
@@ -99,6 +101,14 @@ export type RasterTileMetadata = {
99101
* as {@link RasterTileMetadata._projectPosition}.
100102
*/
101103
_unprojectPosition: ProjectionFunction;
104+
105+
/**
106+
* Seed triangulation that clamps this tile's reprojection mesh to the valid
107+
* Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed.
108+
* Consumed only by the Web Mercator render path; the globe path renders the
109+
* full mesh. See {@link createInitialWebMercatorTriangulation}.
110+
*/
111+
_webMercatorInitialTriangulation?: InitialTriangulation;
102112
};
103113

104114
/**
@@ -373,6 +383,19 @@ export class RasterTileset2D extends Tileset2D {
373383
const { forwardTransform, inverseTransform } =
374384
levelDescriptor.tileTransform(x, y);
375385

386+
// Clamp the reprojection mesh to the valid Web Mercator latitude band for
387+
// tiles that extend past ±85.051° (e.g. a global EPSG:4326 image reaching
388+
// ±90°). Computed once here so the reference is stable across renders.
389+
const cornerLat = (corner: [number, number]) =>
390+
this.descriptor.projectTo4326(corner[0], corner[1])[1];
391+
const _webMercatorInitialTriangulation =
392+
createInitialWebMercatorTriangulation({
393+
topLeft: cornerLat(topLeft),
394+
topRight: cornerLat(topRight),
395+
bottomLeft: cornerLat(bottomLeft),
396+
bottomRight: cornerLat(bottomRight),
397+
});
398+
376399
return {
377400
bbox: {
378401
west,
@@ -393,6 +416,7 @@ export class RasterTileset2D extends Tileset2D {
393416
inverseTransform,
394417
_projectPosition: this.projectPosition,
395418
_unprojectPosition: this.unprojectPosition,
419+
_webMercatorInitialTriangulation,
396420
};
397421
}
398422
}

0 commit comments

Comments
 (0)