From 4116a9f57fbeddb1bfed14e9e9a9ef5244b515f2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 11:29:18 -0400 Subject: [PATCH 01/13] docs(specs): add antimeridian crossing-tile (cut-in-two) design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...05-27-antimeridian-crossing-tile-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md 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 new file mode 100644 index 00000000..7851e369 --- /dev/null +++ b/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md @@ -0,0 +1,134 @@ +# Render imagery crossing the antimeridian by cutting tiles in two + +- **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 +- **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) + +## Problem + +A single raster whose source extent crosses ±180° longitude does not render correctly in a Web Mercator viewport. This covers: + +- 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. +- A genuine crossing scene whose source grid wraps past ±180° (stored with longitudes running e.g. 170° → 190°). + +"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. + +## Why it's hard + +The Web Mercator render path projects each tile through +[`raster-tileset-2d.ts`](../../packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts) `projectPosition`: + +```ts +projectPosition = (x, y) => rescaleEPSG3857ToCommonSpace(descriptor.projectTo3857(x, y)); +``` + +`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). + +**Unwrapping in source-longitude space does not work:** proj4 re-normalizes any longitude you hand it (190° → −170°), re-introducing the jump (dcherian, [#269](https://github.com/developmentseed/deck.gl-raster/pull/269)). Any unwrap must therefore act at/after the transform output — which is what makes the "keep it as one tile" approaches fragile. + +## Approach: cut the tile in two + +Rather than keep the crossing tile as one mesh and fight proj4 to make its coordinates continuous (the **render-as-one** family: #374 output-space shift, #269 reprojector unwrap, #353 global `+over`), **split the tile at the antimeridian into a west piece and an east piece.** Each piece lies wholly on one side of the dateline, so: + +- The west piece is monotonic in 3857 (all +x → common-x up to 512); the east piece all −x → common-x from 0. **The discontinuity never exists within a piece.** +- `projectTo3857` stays stock — no unwrap, no proj4 reconfiguration, no `+over`. +- The `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. + +### 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 requires a straight cut (convex pieces) and **errors clearly** if the inverse-projected meridian is not straight within tolerance. + +## 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. +- **`createInitialConditions(points)`** — a **separate, tree-shakeable module** in `raster-reproject` that runs delaunator to produce a seed from arbitrary vertices. `delatin.ts` must **not** import it (so it tree-shakes when unused); mark the package `"sideEffects": false`. delaunator is ~8 KB, zero-dependency, same author/data-model as delatin (near-direct array transfer). The MVP can hand-roll the rectangle seed and skip delaunator entirely; delaunator earns its place for arbitrary/curved seeds. +- **Cut builder** (deck.gl-raster) — computes the cut (inverse-project the antimeridian) → 1 or 2 sub-domain seeds. Lives in the tileset's `getTileMetadata` and is stored on tile metadata (per the "tile state on the tile" convention), so it is computed once and shared by both the render and the bounding volume. +- **`RasterLayer`** ([`raster-layer.ts`](../../packages/deck.gl-raster/src/raster-layer.ts)) — one mesh, one `MeshTextureLayer`, unchanged except a new `initialTriangulation` prop (default: full square) passed to its reprojector. +- **`RasterTileLayer._renderSubLayers`** — reads the tile's cut info and emits 1 or 2 `RasterLayer`s. Both crossing sub-layers share the **same** `reprojectionFns` (the tile's `_projectPosition`); they differ only in `initialTriangulation` and sublayer id (`…-raster-west` / `…-raster-east`). +- **Traversal** — a **two-box bounding volume** for a crossing tile (west ≈ `[510,512]`, east ≈ `[0,2]`), each a normal `[0,512]` box, mapping 1:1 to the two `RasterLayer`s and composing with the world-copy traversal's per-offset selection (a crossing tile natively occupies two world bands at offset 0). + +## Transparency to end users + +The split is entirely below the tile-data boundary: + +- **`getTileData` is unchanged.** A crossing tile is one tile index `(x, y, z)` and a single *contiguous* source-pixel fetch — the discontinuity appears only when projecting to 3857, after fetch. Any data source (COG, zarr, user-supplied) needs zero antimeridian awareness, and the tile is decoded once (both pieces sample the one texture). +- **`_renderSubLayers` is library-internal** — standard `COGLayer` / `RasterTileLayer` users never write it. + +Caveat: a user who *subclasses* and overrides `_renderSubLayers` would bypass the split. + +## Unification + +The initial-triangulation seed subsumes several pending needs into one primitive — *the caller hands the reprojector a seed*: + +- Normal tile → full unit square → 1 layer (unchanged behavior). +- Antimeridian crossing → west + east seeds → 2 layers. +- Pole clamp (#182) / `uvBounds` (#351) → one clamped-rectangle seed → 1 layer (data beyond ±85.051° is not meshed). +- Collar cutline → one inset-domain seed → 1 layer. + +## Scope + +**In scope:** +- Web Mercator viewport. +- Straight cut (convex pieces): axis-aligned EPSG:4326 *and* rotated geotransforms. +- Test datasets: a global EPSG:4326 COG that triggers #366 — e.g. the WorldPop `ppp_2020_1km_Aggregated.tif` named in the issue, or the GEDTM30 global DEM from #353 — and (if sourced) a regional EPSG:4326 scene spanning ±180°. + +**Out of scope (deferred):** +- Globe view (separate prototype). +- Curved-meridian / polar CRS (concave pieces). delaunator fills the convex hull, so a concave piece would gain triangles across the seam; handling needs centroid-filtering or constrained Delaunay, or the render-as-one fallback. The MVP errors on a non-straight cut. + +## Edge cases & risks + +- **Degenerate slivers:** the half-pixel-overhang case (`−180.0012°`) splits into a sub-pixel sliver + a main piece. Skip pieces below an ε UV width so we don't emit a degenerate mesh. +- **Seam between pieces:** west's cut edge lands at common-x 512, east's at 0 ≡ 512 in the +1 world copy — they abut across the world-copy boundary. Encode the shared edge bit-identically (same discipline as adjacent tiles, [`coordinate-systems.md`](../coordinate-systems.md)). +- **delaunator ↔ delatin orientation:** this repo's delatin works in UV (y-down). Verify winding/`inCircle` compatibility with a test (delaunator on the 4 unit-square corners → seed → delatin refines identically to the current hardcoded init). +- **Texture upload:** both sublayers reference the same tile image; without a shared luma `Texture` it uploads twice. Negligible for the prototype (dateline tiles are a thin strip); optimize later if needed. + +## Test plan + +**Unit** +- `createInitialConditions`: delaunator seed for the unit square and a sub-rectangle has expected `{ uvs, triangles, halfedges }`. +- Reprojector with a sub-rectangle seed converges and adds no vertices outside the seed domain; with a delaunator unit-square seed produces output equivalent to the current default. +- Cut location: inverse-projecting the antimeridian for an EPSG:4326 tile yields the expected vertical UV column; a non-straight cut is detected and errors. +- Two-box bounding volume for a crossing tile (west/east boxes; correct selection under the world-copy traversal). + +**Integration / visual (cog-basic)** +- A global EPSG:4326 COG renders correctly across ±180° (no `error=43200` divergence; no mislocated rectangles); pan across the seam stays continuous. +- (If sourced) a regional ±180°-spanning scene renders as a single contiguous image. +- Before/after comparison against current main. + +## Implementation stages (high level) + +1. `RasterReprojector` accepts an initial-triangulation seed; default unchanged; tests including the delaunator-unit-square equivalence check. +2. `createInitialConditions` utility (separate module, delaunator, tree-shakeable) + `sideEffects: false`. +3. Cut location (inverse-project the antimeridian) + straight-cut detection/error, on tile metadata. +4. Two-box bounding volume in traversal for crossing tiles. +5. `RasterLayer` `initialTriangulation` prop; `_renderSubLayers` emits 1 or 2 `RasterLayer`s. +6. Example wiring + visual validation in cog-basic. + +(Detailed task breakdown lives in the implementation plan, not here.) From 712516e162fd9a04d2b0ce28e2c0d04d2453b0be Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 11:41:12 -0400 Subject: [PATCH 02/13] =?UTF-8?q?docs(specs):=20refine=20antimeridian=20sp?= =?UTF-8?q?ec=20=E2=80=94=20add=20test=20fixture,=20handle=20slanted=20cut?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ...05-27-antimeridian-crossing-tile-design.md | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md b/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md index 7851e369..c125617d 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 @@ -54,7 +54,7 @@ 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 requires a straight cut (convex pieces) and **errors clearly** if the inverse-projected meridian is not straight within tolerance. +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 @@ -67,7 +67,7 @@ RasterTileLayer._renderSubLayers (per tile) ← the only split point ``` - **`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. -- **`createInitialConditions(points)`** — a **separate, tree-shakeable module** in `raster-reproject` that runs delaunator to produce a seed from arbitrary vertices. `delatin.ts` must **not** import it (so it tree-shakes when unused); mark the package `"sideEffects": false`. delaunator is ~8 KB, zero-dependency, same author/data-model as delatin (near-direct array transfer). The MVP can hand-roll the rectangle seed and skip delaunator entirely; delaunator earns its place for arbitrary/curved seeds. +- **`createInitialConditions(points)`** — a **separate, tree-shakeable module** in `raster-reproject` that runs delaunator to produce a seed from arbitrary vertices. `delatin.ts` must **not** import it (so it tree-shakes when unused); mark the package `"sideEffects": false`. delaunator is ~8 KB, zero-dependency, same author/data-model as delatin (near-direct array transfer). The crossing-tile path uses it to seed each convex piece — a rectangle for a vertical cut, a convex quad/pentagon for a slanted cut (which can't be a hand-rolled rectangle) — so delaunator is part of the MVP crossing path. It still tree-shakes out for consumers that only render non-crossing tiles, since the default seed path doesn't import it. - **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`). @@ -95,8 +95,10 @@ The initial-triangulation seed subsumes several pending needs into one primitive **In scope:** - Web Mercator viewport. -- Straight cut (convex pieces): axis-aligned EPSG:4326 *and* rotated geotransforms. -- Test datasets: a global EPSG:4326 COG that triggers #366 — e.g. the WorldPop `ppp_2020_1km_Aggregated.tif` named in the issue, or the GEDTM30 global DEM from #353 — and (if sourced) a regional EPSG:4326 scene spanning ±180°. +- 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). @@ -114,21 +116,21 @@ The initial-triangulation seed subsumes several pending needs into one primitive **Unit** - `createInitialConditions`: delaunator seed for the unit square and a sub-rectangle has expected `{ uvs, triangles, halfedges }`. - Reprojector with a sub-rectangle seed converges and adds no vertices outside the seed domain; with a delaunator unit-square seed produces output equivalent to the current default. -- Cut location: inverse-projecting the antimeridian for an EPSG:4326 tile yields the expected vertical UV column; a non-straight cut is detected and errors. +- Cut location: inverse-projecting the antimeridian yields the expected cut line — a vertical UV column for axis-aligned EPSG:4326 (the `antimeridian.tif` fixture cuts at column 24 / `u ≈ 0.571`), a slanted line for a rotated geotransform; a *curved* cut is detected and errors. - Two-box bounding volume for a crossing tile (west/east boxes; correct selection under the world-copy traversal). **Integration / visual (cog-basic)** -- A global EPSG:4326 COG renders correctly across ±180° (no `error=43200` divergence; no mislocated rectangles); pan across the seam stays continuous. -- (If sourced) a regional ±180°-spanning scene renders as a single contiguous image. +- 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 initial-triangulation seed; default unchanged; tests including the delaunator-unit-square equivalence check. 2. `createInitialConditions` utility (separate module, delaunator, tree-shakeable) + `sideEffects: false`. -3. Cut location (inverse-project the antimeridian) + straight-cut detection/error, on tile metadata. +3. Cut location (inverse-project the antimeridian) + convexity check (error on a curved/concave cut), on tile metadata. 4. Two-box bounding volume in traversal for crossing tiles. -5. `RasterLayer` `initialTriangulation` prop; `_renderSubLayers` emits 1 or 2 `RasterLayer`s. +5. `RasterLayer` `initialTriangulation` prop; `_renderSubLayers` emits 1 or 2 `RasterLayer`s, each seeded from its cut sub-domain via `createInitialConditions`. 6. Example wiring + visual validation in cog-basic. (Detailed task breakdown lives in the implementation plan, not here.) From b07a22940901fc9a871527022f68719bea13a39f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 12:13:25 -0400 Subject: [PATCH 03/13] 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) --- packages/raster-reproject/package.json | 3 ++ packages/raster-reproject/src/delatin.ts | 18 +++++++++++ packages/raster-reproject/src/index.ts | 3 +- .../src/initial-conditions.ts | 23 ++++++++++++++ .../tests/initial-conditions.test.ts | 31 +++++++++++++++++++ pnpm-lock.yaml | 16 ++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/raster-reproject/src/initial-conditions.ts create mode 100644 packages/raster-reproject/tests/initial-conditions.test.ts diff --git a/packages/raster-reproject/package.json b/packages/raster-reproject/package.json index a695dde6..7e050c33 100644 --- a/packages/raster-reproject/package.json +++ b/packages/raster-reproject/package.json @@ -49,5 +49,8 @@ }, "volta": { "extends": "../../package.json" + }, + "dependencies": { + "delaunator": "^5.1.0" } } diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index 2f86776d..fe56d00c 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -29,6 +29,24 @@ const SAMPLE_POINTS: [number, number, number][] = [ const DEFAULT_MAX_ERROR = 0.125; +/** + * A seed triangulation for {@link RasterReprojector}, in delaunator's data + * shape. All UV coordinates must lie in `[0, 1]`. The triangulation must be a + * valid (ideally Delaunay) mesh — its triangles are NOT legalized on seeding. + */ +export interface InitialTriangulation { + /** Flat UV vertex coordinates `[u0, v0, u1, v1, ...]`, each in `[0, 1]`. */ + uvs: number[]; + /** Triangle vertex indices, 3 per triangle (indices into `uvs`). */ + triangles: number[]; + /** + * Halfedge twins: `halfedges[e]` is the opposite halfedge of `e`, or `-1` + * if `e` is on the boundary. Same convention as delaunator and the + * reprojector's internal `_halfedges`. + */ + halfedges: number[]; +} + export interface ReprojectionFns { /** * Convert from UV coordinates to input CRS coordinates. diff --git a/packages/raster-reproject/src/index.ts b/packages/raster-reproject/src/index.ts index dc4ddef4..23455845 100644 --- a/packages/raster-reproject/src/index.ts +++ b/packages/raster-reproject/src/index.ts @@ -1,2 +1,3 @@ -export type { ReprojectionFns } from "./delatin.js"; +export type { InitialTriangulation, ReprojectionFns } from "./delatin.js"; export { RasterReprojector } from "./delatin.js"; +export { createInitialConditions } from "./initial-conditions.js"; diff --git a/packages/raster-reproject/src/initial-conditions.ts b/packages/raster-reproject/src/initial-conditions.ts new file mode 100644 index 00000000..a525b5f1 --- /dev/null +++ b/packages/raster-reproject/src/initial-conditions.ts @@ -0,0 +1,23 @@ +import Delaunator from "delaunator"; +import type { InitialTriangulation } from "./delatin.js"; + +/** + * Build a Delaunay {@link InitialTriangulation} from a set of UV points via + * delaunator. The points must lie in `[0, 1]` and define a convex domain + * (delaunator triangulates the convex hull of its input). + * + * This module is the only one that imports delaunator; consumers that don't + * call this function tree-shake it out. + * + * @param points UV points as `[u, v]` pairs. + */ +export function createInitialConditions( + points: [number, number][], +): InitialTriangulation { + const delaunay = Delaunator.from(points); + return { + uvs: Array.from(delaunay.coords), + triangles: Array.from(delaunay.triangles), + halfedges: Array.from(delaunay.halfedges), + }; +} diff --git a/packages/raster-reproject/tests/initial-conditions.test.ts b/packages/raster-reproject/tests/initial-conditions.test.ts new file mode 100644 index 00000000..872d4077 --- /dev/null +++ b/packages/raster-reproject/tests/initial-conditions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createInitialConditions } from "../src/initial-conditions.js"; + +describe("createInitialConditions", () => { + it("triangulates the unit square into two triangles", () => { + const seed = createInitialConditions([ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ]); + expect(seed.uvs).toEqual([0, 0, 1, 0, 0, 1, 1, 1]); + expect(seed.triangles).toHaveLength(6); // two triangles + expect(seed.halfedges).toHaveLength(6); + // exactly one interior shared edge (its twin is >= 0); the rest are boundary + expect(seed.halfedges.filter((h) => h >= 0)).toHaveLength(2); + }); + + it("triangulates a sub-rectangle, keeping all vertices inside it", () => { + const seed = createInitialConditions([ + [0, 0], + [0.5, 0], + [0, 1], + [0.5, 1], + ]); + expect(seed.triangles).toHaveLength(6); + for (let i = 0; i < seed.uvs.length; i += 2) { + expect(seed.uvs[i]).toBeLessThanOrEqual(0.5); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0095ea4..bbf43c72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1080,6 +1080,10 @@ importers: version: 4.1.5(@types/node@25.6.0)(happy-dom@20.0.11)(jsdom@29.1.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)) packages/raster-reproject: + dependencies: + delaunator: + specifier: ^5.1.0 + version: 5.1.0 devDependencies: '@developmentseed/affine': specifier: workspace:^ @@ -5465,6 +5469,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delegate@3.2.0: resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} @@ -8100,6 +8107,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -15232,6 +15242,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delegate@3.2.0: {} depd@1.1.2: {} @@ -18391,6 +18405,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 From 09175c10441dd6e01d13a36f33486241d06be0ac Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 12:32:42 -0400 Subject: [PATCH 04/13] refactor(raster-reproject): document delaunator seed pattern, don't ship a wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/raster-reproject/package.json | 4 +-- packages/raster-reproject/src/delatin.ts | 21 +++++++++++++ packages/raster-reproject/src/index.ts | 1 - .../src/initial-conditions.ts | 23 -------------- .../tests/initial-conditions.test.ts | 31 ------------------- pnpm-lock.yaml | 7 ++--- 6 files changed, 25 insertions(+), 62 deletions(-) delete mode 100644 packages/raster-reproject/src/initial-conditions.ts delete mode 100644 packages/raster-reproject/tests/initial-conditions.test.ts diff --git a/packages/raster-reproject/package.json b/packages/raster-reproject/package.json index 7e050c33..ce61e1e7 100644 --- a/packages/raster-reproject/package.json +++ b/packages/raster-reproject/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@developmentseed/affine": "workspace:^", "@types/node": "^25.6.0", + "delaunator": "^5.1.0", "jsdom": "^29.1.1", "proj4": "^2.20.8", "typescript": "^6.0.3", @@ -49,8 +50,5 @@ }, "volta": { "extends": "../../package.json" - }, - "dependencies": { - "delaunator": "^5.1.0" } } diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index fe56d00c..72d73226 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -33,6 +33,27 @@ const DEFAULT_MAX_ERROR = 0.125; * A seed triangulation for {@link RasterReprojector}, in delaunator's data * shape. All UV coordinates must lie in `[0, 1]`. The triangulation must be a * valid (ideally Delaunay) mesh — its triangles are NOT legalized on seeding. + * + * The fields deliberately match [delaunator](https://github.com/mapbox/delaunator)'s + * output, so building a seed from a set of UV points is a one-liner — pass the + * points (which must form a convex domain; delaunator triangulates the convex + * hull of its input): + * + * ```ts + * import Delaunator from "delaunator"; + * + * const points: [number, number][] = [[0, 0], [1, 0], [0, 1], [1, 1]]; + * const d = Delaunator.from(points); + * const seed: InitialTriangulation = { + * uvs: Array.from(d.coords), + * triangles: Array.from(d.triangles), + * halfedges: Array.from(d.halfedges), + * }; + * ``` + * + * delaunator is not a dependency of this package; consumers that want this + * convenience add it themselves. A hand-built triangulation works too, as long + * as it is a valid mesh with the halfedge convention below. */ export interface InitialTriangulation { /** Flat UV vertex coordinates `[u0, v0, u1, v1, ...]`, each in `[0, 1]`. */ diff --git a/packages/raster-reproject/src/index.ts b/packages/raster-reproject/src/index.ts index 23455845..ba75e61d 100644 --- a/packages/raster-reproject/src/index.ts +++ b/packages/raster-reproject/src/index.ts @@ -1,3 +1,2 @@ export type { InitialTriangulation, ReprojectionFns } from "./delatin.js"; export { RasterReprojector } from "./delatin.js"; -export { createInitialConditions } from "./initial-conditions.js"; diff --git a/packages/raster-reproject/src/initial-conditions.ts b/packages/raster-reproject/src/initial-conditions.ts deleted file mode 100644 index a525b5f1..00000000 --- a/packages/raster-reproject/src/initial-conditions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Delaunator from "delaunator"; -import type { InitialTriangulation } from "./delatin.js"; - -/** - * Build a Delaunay {@link InitialTriangulation} from a set of UV points via - * delaunator. The points must lie in `[0, 1]` and define a convex domain - * (delaunator triangulates the convex hull of its input). - * - * This module is the only one that imports delaunator; consumers that don't - * call this function tree-shake it out. - * - * @param points UV points as `[u, v]` pairs. - */ -export function createInitialConditions( - points: [number, number][], -): InitialTriangulation { - const delaunay = Delaunator.from(points); - return { - uvs: Array.from(delaunay.coords), - triangles: Array.from(delaunay.triangles), - halfedges: Array.from(delaunay.halfedges), - }; -} diff --git a/packages/raster-reproject/tests/initial-conditions.test.ts b/packages/raster-reproject/tests/initial-conditions.test.ts deleted file mode 100644 index 872d4077..00000000 --- a/packages/raster-reproject/tests/initial-conditions.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createInitialConditions } from "../src/initial-conditions.js"; - -describe("createInitialConditions", () => { - it("triangulates the unit square into two triangles", () => { - const seed = createInitialConditions([ - [0, 0], - [1, 0], - [0, 1], - [1, 1], - ]); - expect(seed.uvs).toEqual([0, 0, 1, 0, 0, 1, 1, 1]); - expect(seed.triangles).toHaveLength(6); // two triangles - expect(seed.halfedges).toHaveLength(6); - // exactly one interior shared edge (its twin is >= 0); the rest are boundary - expect(seed.halfedges.filter((h) => h >= 0)).toHaveLength(2); - }); - - it("triangulates a sub-rectangle, keeping all vertices inside it", () => { - const seed = createInitialConditions([ - [0, 0], - [0.5, 0], - [0, 1], - [0.5, 1], - ]); - expect(seed.triangles).toHaveLength(6); - for (let i = 0; i < seed.uvs.length; i += 2) { - expect(seed.uvs[i]).toBeLessThanOrEqual(0.5); - } - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf43c72..3e4cabce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1080,10 +1080,6 @@ importers: version: 4.1.5(@types/node@25.6.0)(happy-dom@20.0.11)(jsdom@29.1.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)) packages/raster-reproject: - dependencies: - delaunator: - specifier: ^5.1.0 - version: 5.1.0 devDependencies: '@developmentseed/affine': specifier: workspace:^ @@ -1091,6 +1087,9 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.6.0 + delaunator: + specifier: ^5.1.0 + version: 5.1.0 jsdom: specifier: ^29.1.1 version: 29.1.1 From 931528c8c4743b08249167f28d80c45d68026d2a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 12:33:44 -0400 Subject: [PATCH 05/13] 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) --- ...26-05-27-antimeridian-crossing-tile-design.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md b/dev-docs/specs/2026-05-27-antimeridian-crossing-tile-design.md index c125617d..8db71e2b 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 @@ -67,7 +67,7 @@ RasterTileLayer._renderSubLayers (per tile) ← the only split point ``` - **`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. -- **`createInitialConditions(points)`** — a **separate, tree-shakeable module** in `raster-reproject` that runs delaunator to produce a seed from arbitrary vertices. `delatin.ts` must **not** import it (so it tree-shakes when unused); mark the package `"sideEffects": false`. delaunator is ~8 KB, zero-dependency, same author/data-model as delatin (near-direct array transfer). The crossing-tile path uses it to seed each convex piece — a rectangle for a vertical cut, a convex quad/pentagon for a slanted cut (which can't be a hand-rolled rectangle) — so delaunator is part of the MVP crossing path. It still tree-shakes out for consumers that only render non-crossing tiles, since the default seed path doesn't import it. +- **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`). @@ -114,8 +114,7 @@ The initial-triangulation seed subsumes several pending needs into one primitive ## Test plan **Unit** -- `createInitialConditions`: delaunator seed for the unit square and a sub-rectangle has expected `{ uvs, triangles, halfedges }`. -- Reprojector with a sub-rectangle seed converges and adds no vertices outside the seed domain; with a delaunator unit-square seed produces output equivalent to the current default. +- 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). @@ -126,11 +125,10 @@ The initial-triangulation seed subsumes several pending needs into one primitive ## Implementation stages (high level) -1. `RasterReprojector` accepts an initial-triangulation seed; default unchanged; tests including the delaunator-unit-square equivalence check. -2. `createInitialConditions` utility (separate module, delaunator, tree-shakeable) + `sideEffects: false`. -3. Cut location (inverse-project the antimeridian) + convexity check (error on a curved/concave cut), on tile metadata. -4. Two-box bounding volume in traversal for crossing tiles. -5. `RasterLayer` `initialTriangulation` prop; `_renderSubLayers` emits 1 or 2 `RasterLayer`s, each seeded from its cut sub-domain via `createInitialConditions`. -6. Example wiring + visual validation in cog-basic. +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.) From 85a4ae60764884209f105b8a6a5df63d5b75a14f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 12:40:53 -0400 Subject: [PATCH 06/13] 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) --- packages/raster-reproject/src/delatin.ts | 52 ++++++++++---- .../tests/initial-triangulation.test.ts | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 packages/raster-reproject/tests/initial-triangulation.test.ts diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index 72d73226..7c501da3 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -68,6 +68,21 @@ export interface InitialTriangulation { halfedges: number[]; } +/** + * The default seed: the full image in UV space, as 4 corner vertices and 2 + * triangles sharing the p0–p3 diagonal. Reproduces the previous hardcoded + * constructor init exactly. Hardcoded (not delaunator-built) so the package has + * no runtime triangulation dependency. + */ +const UNIT_SQUARE_SEED: InitialTriangulation = { + // p0=(0,0) p1=(1,0) p2=(0,1) p3=(1,1) + uvs: [0, 0, 1, 0, 0, 1, 1, 1], + // t0 = (p3, p0, p2), t1 = (p0, p3, p1) + triangles: [3, 0, 2, 0, 3, 1], + // shared diagonal p0–p3: halfedge 0 <-> 3; all other edges are boundary + halfedges: [3, -1, -1, 0, -1, -1], +}; + export interface ReprojectionFns { /** * Convert from UV coordinates to input CRS coordinates. @@ -151,6 +166,7 @@ export class RasterReprojector { reprojectors: ReprojectionFns, width: number, height: number = width, + options: { initialTriangulation?: InitialTriangulation } = {}, ) { this.reprojectors = reprojectors; this.width = width; @@ -170,21 +186,33 @@ export class RasterReprojector { this._pending = []; // triangles pending addition to queue this._pendingLen = 0; - // The two initial triangles cover the entire input texture in UV space, so - // they range from [0, 0] to [1, 1] in u and v. - const u1 = 1; - const v1 = 1; - const p0 = this._addPoint(0, 0); - const p1 = this._addPoint(u1, 0); - const p2 = this._addPoint(0, v1); - const p3 = this._addPoint(u1, v1); - - // add initial two triangles - const t0 = this._addTriangle(p3, p0, p2, -1, -1, -1); - this._addTriangle(p0, p3, p1, t0, -1, -1); + this._seed(options.initialTriangulation ?? UNIT_SQUARE_SEED); this._flush(); } + /** + * Seed the mesh from an {@link InitialTriangulation}. Adds every vertex (which + * computes its exact output position), copies the triangle and halfedge arrays + * directly (the seed is already a valid triangulation), and queues every + * triangle for the first reprojection-error pass. + */ + private _seed(seed: InitialTriangulation): void { + for (let i = 0; i < seed.uvs.length; i += 2) { + this._addPoint(seed.uvs[i]!, seed.uvs[i + 1]!); + } + for (let i = 0; i < seed.triangles.length; i++) { + this.triangles[i] = seed.triangles[i]!; + this._halfedges[i] = seed.halfedges[i]!; + } + const numTriangles = seed.triangles.length / 3; + for (let t = 0; t < numTriangles; t++) { + this._candidatesUV[2 * t] = 0; + this._candidatesUV[2 * t + 1] = 0; + this._queueIndices[t] = -1; + this._pending[this._pendingLen++] = t; + } + } + /** * Refine the mesh until its maximum error gets below the given one * diff --git a/packages/raster-reproject/tests/initial-triangulation.test.ts b/packages/raster-reproject/tests/initial-triangulation.test.ts new file mode 100644 index 00000000..1ef951f7 --- /dev/null +++ b/packages/raster-reproject/tests/initial-triangulation.test.ts @@ -0,0 +1,69 @@ +import Delaunator from "delaunator"; +import { describe, expect, it } from "vitest"; +import type { InitialTriangulation, ReprojectionFns } from "../src/delatin.js"; +import { RasterReprojector } from "../src/delatin.js"; + +// The delaunator seed pattern documented on InitialTriangulation. Building the +// seed here (rather than shipping a wrapper) doubles as both the worked example +// and the winding-compatibility check: if delaunator's orientation were +// incompatible with delatin's `orient`/`inCircle`, a seeded reprojector would +// fail to converge below. +function seedFromPoints(points: [number, number][]): InitialTriangulation { + const d = Delaunator.from(points); + return { + uvs: Array.from(d.coords), + triangles: Array.from(d.triangles), + halfedges: Array.from(d.halfedges), + }; +} + +// Identity transforms; a reproject with a strong nonlinearity in v so the mesh +// must subdivide (linear interpolation of a quadratic has real error). +const fns: ReprojectionFns = { + forwardTransform: (x, y) => [x, y], + inverseTransform: (x, y) => [x, y], + forwardReproject: (x, y) => [x + 0.05 * y * y, y], + inverseReproject: (x, y) => [x - 0.05 * y * y, y], +}; + +describe("RasterReprojector initial triangulation", () => { + it("defaults to the full unit square (unchanged behavior)", () => { + const r = new RasterReprojector(fns, 64, 64); + // Before refinement, the seed is the 4 unit-square corners + 2 triangles. + expect(r.uvs.slice(0, 8)).toEqual([0, 0, 1, 0, 0, 1, 1, 1]); + expect(r.triangles.slice(0, 6)).toEqual([3, 0, 2, 0, 3, 1]); + }); + + it("converges when seeded from a delaunator unit square (winding compatible)", () => { + const seed = seedFromPoints([ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ]); + const r = new RasterReprojector(fns, 64, 64, { + initialTriangulation: seed, + }); + r.run(0.125); + expect(r.uvs.length).toBeGreaterThan(8); // refinement happened + expect(r.triangles.length % 3).toBe(0); // valid triangle list + expect(r.getMaxError()).toBeLessThanOrEqual(0.125); // converged + }); + + it("confines refinement to the seed sub-domain", () => { + const seed = seedFromPoints([ + [0, 0], + [0.5, 0], + [0, 1], + [0.5, 1], + ]); + const r = new RasterReprojector(fns, 64, 64, { + initialTriangulation: seed, + }); + r.run(0.125); + // refinement only splits existing triangles, so no vertex escapes u <= 0.5 + for (let i = 0; i < r.uvs.length; i += 2) { + expect(r.uvs[i]).toBeLessThanOrEqual(0.5 + 1e-9); + } + }); +}); From 9637c39277439834b6267f06ce0584d0ae74e71e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 13:01:59 -0400 Subject: [PATCH 07/13] feat(raster-reproject): add rectangleSeed helper for sub-rectangle seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/raster-reproject/src/delatin.ts | 40 +++++++++++++------ packages/raster-reproject/src/index.ts | 2 +- .../tests/rectangle-seed.test.ts | 39 ++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 packages/raster-reproject/tests/rectangle-seed.test.ts diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index 7c501da3..3d7bde55 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -69,19 +69,35 @@ export interface InitialTriangulation { } /** - * The default seed: the full image in UV space, as 4 corner vertices and 2 - * triangles sharing the p0–p3 diagonal. Reproduces the previous hardcoded - * constructor init exactly. Hardcoded (not delaunator-built) so the package has - * no runtime triangulation dependency. + * Build an axis-aligned rectangle seed (4 corner vertices, 2 triangles sharing + * the p0–p3 diagonal) covering the UV sub-rectangle `[uMin, vMin]`–`[uMax, vMax]`. + * + * Seeding {@link RasterReprojector} with a sub-rectangle confines the mesh to + * that region — e.g. to clamp an image's mesh to the valid Web Mercator + * latitude band (see #182 / #351), pass that band's UV bounds. + */ +export function rectangleSeed( + uMin: number, + vMin: number, + uMax: number, + vMax: number, +): InitialTriangulation { + return { + // p0=(uMin,vMin) p1=(uMax,vMin) p2=(uMin,vMax) p3=(uMax,vMax) + uvs: [uMin, vMin, uMax, vMin, uMin, vMax, uMax, vMax], + // t0 = (p3, p0, p2), t1 = (p0, p3, p1) + triangles: [3, 0, 2, 0, 3, 1], + // shared diagonal p0–p3: halfedge 0 <-> 3; all other edges are boundary + halfedges: [3, -1, -1, 0, -1, -1], + }; +} + +/** + * The default seed: the full image in UV space. Reproduces the previous + * hardcoded constructor init exactly, and is hardcoded (not delaunator-built) + * so the package has no runtime triangulation dependency. */ -const UNIT_SQUARE_SEED: InitialTriangulation = { - // p0=(0,0) p1=(1,0) p2=(0,1) p3=(1,1) - uvs: [0, 0, 1, 0, 0, 1, 1, 1], - // t0 = (p3, p0, p2), t1 = (p0, p3, p1) - triangles: [3, 0, 2, 0, 3, 1], - // shared diagonal p0–p3: halfedge 0 <-> 3; all other edges are boundary - halfedges: [3, -1, -1, 0, -1, -1], -}; +const UNIT_SQUARE_SEED: InitialTriangulation = rectangleSeed(0, 0, 1, 1); export interface ReprojectionFns { /** diff --git a/packages/raster-reproject/src/index.ts b/packages/raster-reproject/src/index.ts index ba75e61d..40203d25 100644 --- a/packages/raster-reproject/src/index.ts +++ b/packages/raster-reproject/src/index.ts @@ -1,2 +1,2 @@ export type { InitialTriangulation, ReprojectionFns } from "./delatin.js"; -export { RasterReprojector } from "./delatin.js"; +export { RasterReprojector, rectangleSeed } from "./delatin.js"; diff --git a/packages/raster-reproject/tests/rectangle-seed.test.ts b/packages/raster-reproject/tests/rectangle-seed.test.ts new file mode 100644 index 00000000..bf45b61c --- /dev/null +++ b/packages/raster-reproject/tests/rectangle-seed.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { ReprojectionFns } from "../src/delatin.js"; +import { RasterReprojector, rectangleSeed } from "../src/delatin.js"; + +const fns: ReprojectionFns = { + forwardTransform: (x, y) => [x, y], + inverseTransform: (x, y) => [x, y], + forwardReproject: (x, y) => [x + 0.05 * y * y, y], + inverseReproject: (x, y) => [x - 0.05 * y * y, y], +}; + +describe("rectangleSeed", () => { + it("builds the unit square (same as the default seed)", () => { + const s = rectangleSeed(0, 0, 1, 1); + expect(s.uvs).toEqual([0, 0, 1, 0, 0, 1, 1, 1]); + expect(s.triangles).toEqual([3, 0, 2, 0, 3, 1]); + expect(s.halfedges).toEqual([3, -1, -1, 0, -1, -1]); + }); + + it("builds a clamped horizontal band", () => { + const s = rectangleSeed(0, 0.25, 1, 0.75); + expect(s.uvs).toEqual([0, 0.25, 1, 0.25, 0, 0.75, 1, 0.75]); + // topology is identical to the unit square + expect(s.triangles).toEqual([3, 0, 2, 0, 3, 1]); + expect(s.halfedges).toEqual([3, -1, -1, 0, -1, -1]); + }); + + it("is a valid reprojector seed (converges, stays in the band)", () => { + const r = new RasterReprojector(fns, 64, 64, { + initialTriangulation: rectangleSeed(0, 0.2, 1, 0.8), + }); + r.run(0.125); + expect(r.getMaxError()).toBeLessThanOrEqual(0.125); // winding/halfedges valid + for (let i = 1; i < r.uvs.length; i += 2) { + expect(r.uvs[i]).toBeGreaterThanOrEqual(0.2 - 1e-9); + expect(r.uvs[i]).toBeLessThanOrEqual(0.8 + 1e-9); + } + }); +}); From 1be22975d98b0be55a75449a56ce52fbe909ee3f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 13:06:49 -0400 Subject: [PATCH 08/13] =?UTF-8?q?feat(deck.gl-raster):=20add=20webMercator?= =?UTF-8?q?ClampSeed=20(clamp=20mesh=20to=20=C2=B185.051=C2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tileset/web-mercator-clamp.ts | 77 +++++++++++++++++++ .../raster-tileset/web-mercator-clamp.test.ts | 65 ++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts create mode 100644 packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts diff --git a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts new file mode 100644 index 00000000..fb685211 --- /dev/null +++ b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts @@ -0,0 +1,77 @@ +import type { InitialTriangulation } from "@developmentseed/raster-reproject"; +import { rectangleSeed } from "@developmentseed/raster-reproject"; + +/** Maximum latitude representable in Web Mercator (EPSG:3857), in degrees. */ +const MAX_WEB_MERCATOR_LAT = 85.05112877980659; + +/** Tolerance for the north-up check and degenerate-band guard, in degrees. */ +const LAT_EPSILON = 1e-6; + +/** + * The WGS84 latitudes of a tile's four corners. + */ +export interface CornerLatitudes { + topLeft: number; + topRight: number; + bottomLeft: number; + bottomRight: number; +} + +/** + * Compute a {@link InitialTriangulation} that clamps a tile's reprojection mesh + * to the latitude band representable in Web Mercator (±85.051°), or `undefined` + * if no clamp is needed or possible. + * + * Beyond ±85.051°, `makeClampedForwardTo3857` collapses every polar vertex onto + * the same clamped Y, so the reprojector emits degenerate near-pole triangles + * that never converge (see #182 / #351). Seeding the reprojector with the + * clamped band avoids meshing those rows entirely. + * + * Only **north-up geographic** tiles are handled — where latitude is constant + * across each row, so the valid band is an axis-aligned rectangle. Rotated or + * projected tiles return `undefined` (the caller falls back to the full mesh). + * + * @param cornerLats WGS84 latitudes of the tile's four corners. + * @param maxLat Web Mercator latitude limit. Defaults to ±85.051°. + */ +export function webMercatorClampSeed( + cornerLats: CornerLatitudes, + maxLat: number = MAX_WEB_MERCATOR_LAT, +): InitialTriangulation | undefined { + const { topLeft, topRight, bottomLeft, bottomRight } = cornerLats; + + // North-up means latitude is constant across each row, so the clamp band is + // an axis-aligned rectangle. Otherwise fall back to the full mesh. + const northUp = + Math.abs(topLeft - topRight) < LAT_EPSILON && + Math.abs(bottomLeft - bottomRight) < LAT_EPSILON; + if (!northUp) { + return undefined; + } + + const north = topLeft; + const south = bottomLeft; + // Degenerate or south-up tile: leave it to the default full mesh. + if (north - south <= LAT_EPSILON) { + return undefined; + } + + // Nothing to clamp if the whole tile is already within bounds. + if (north <= maxLat && south >= -maxLat) { + return undefined; + } + + // v runs 0 (north) → 1 (south); lat(v) = north - v * (north - south). + const clamp01 = (t: number) => Math.max(0, Math.min(1, t)); + const vTop = clamp01((north - maxLat) / (north - south)); + const vBottom = clamp01((north - -maxLat) / (north - south)); + + // Fully-polar tile (entirely outside ±maxLat): empty band, nothing to render. + // Such tiles are normally excluded by the dataset-bounds clamp; guard anyway + // so we never emit a degenerate seed. + if (vBottom - vTop < LAT_EPSILON) { + return undefined; + } + + return rectangleSeed(0, vTop, 1, vBottom); +} diff --git a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts new file mode 100644 index 00000000..d5872eb1 --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { webMercatorClampSeed } from "../../src/raster-tileset/web-mercator-clamp.js"; + +const MAX_LAT = 85.05112877980659; + +describe("webMercatorClampSeed", () => { + it("returns undefined for a tile within Web Mercator bounds", () => { + expect( + webMercatorClampSeed({ + topLeft: 40, + topRight: 40, + bottomLeft: 30, + bottomRight: 30, + }), + ).toBeUndefined(); + }); + + it("clamps a global north-up tile to the valid band on both edges", () => { + const seed = webMercatorClampSeed({ + topLeft: 90, + topRight: 90, + bottomLeft: -90, + bottomRight: -90, + }); + expect(seed).toBeDefined(); + // rectangleSeed(0, vTop, 1, vBottom) → uvs = [0,vTop, 1,vTop, 0,vBottom, 1,vBottom] + expect(seed?.uvs[1]).toBeCloseTo((90 - MAX_LAT) / 180, 9); // vTop + expect(seed?.uvs[5]).toBeCloseTo((90 + MAX_LAT) / 180, 9); // vBottom + expect(seed?.uvs[0]).toBe(0); + expect(seed?.uvs[2]).toBe(1); + }); + + it("clamps only the north edge when only the north exceeds the bound", () => { + const seed = webMercatorClampSeed({ + topLeft: 90, + topRight: 90, + bottomLeft: 80, + bottomRight: 80, + }); + expect(seed?.uvs[1]).toBeCloseTo((90 - MAX_LAT) / 10, 9); // vTop in (0,1) + expect(seed?.uvs[5]).toBe(1); // south within bounds → vBottom clamped to 1 + }); + + it("returns undefined for a non-north-up (rotated) tile", () => { + expect( + webMercatorClampSeed({ + topLeft: 90, + topRight: 88, + bottomLeft: -90, + bottomRight: -88, + }), + ).toBeUndefined(); + }); + + it("returns undefined for a fully-polar tile (empty band)", () => { + expect( + webMercatorClampSeed({ + topLeft: 88, + topRight: 88, + bottomLeft: 86, + bottomRight: 86, + }), + ).toBeUndefined(); + }); +}); From 63f14d240f59c4ec772810eb0dcad84be2b9927b Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 13:09:46 -0400 Subject: [PATCH 09/13] feat(deck.gl-raster): clamp Web Mercator meshes to valid latitude band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/deck.gl-raster/src/raster-layer.ts | 18 +++++++++++++-- .../raster-tile-layer/raster-tile-layer.ts | 5 ++++ .../src/raster-tileset/raster-tileset-2d.ts | 23 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts index 3080e0e3..e8bee712 100644 --- a/packages/deck.gl-raster/src/raster-layer.ts +++ b/packages/deck.gl-raster/src/raster-layer.ts @@ -7,7 +7,10 @@ import type { } from "@deck.gl/core"; import { CompositeLayer } from "@deck.gl/core"; import { PolygonLayer } from "@deck.gl/layers"; -import type { ReprojectionFns } from "@developmentseed/raster-reproject"; +import type { + InitialTriangulation, + ReprojectionFns, +} from "@developmentseed/raster-reproject"; import { RasterReprojector } from "@developmentseed/raster-reproject"; import { splitFloat64Array } from "./fp64.js"; import type { RasterModule } from "./gpu-modules/types.js"; @@ -75,6 +78,14 @@ export interface RasterLayerProps extends CompositeLayerProps { */ reprojectionFns: ReprojectionFns; + /** + * Optional seed triangulation for the reprojector — e.g. to clamp the mesh to + * a UV sub-region (such as the valid Web Mercator latitude band). Defaults to + * the full image. Must be reference-stable across renders to avoid + * regenerating the mesh every frame. + */ + initialTriangulation?: InitialTriangulation; + /** * The image to display. Accepts any luma.gl `TextureSource` (e.g. a URL, * `HTMLImageElement`, `ImageData`, etc.). deck.gl manages the texture @@ -186,7 +197,8 @@ export class RasterLayer extends CompositeLayer { props.width !== oldProps.width || props.height !== oldProps.height || reprojectionFnsChanged || - props.maxError !== oldProps.maxError; + props.maxError !== oldProps.maxError || + props.initialTriangulation !== oldProps.initialTriangulation; if (needsMeshUpdate) { this._generateMesh(); @@ -198,6 +210,7 @@ export class RasterLayer extends CompositeLayer { width, height, reprojectionFns, + initialTriangulation, maxError = DEFAULT_MAX_ERROR, } = this.props; @@ -212,6 +225,7 @@ export class RasterLayer extends CompositeLayer { reprojectionFns, width + 1, height + 1, + { initialTriangulation }, ); reprojector.run(maxError); const { indices, positions64High, positions64Low, texCoords } = 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 1d17a135..d13def0b 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 @@ -443,6 +443,11 @@ export class RasterTileLayer< renderPipeline, maxError, reprojectionFns, + // Web Mercator: clamp the mesh to the valid latitude band for tiles + // past ±85.051°. Globe renders the full mesh (it shows the poles). + initialTriangulation: isGlobe + ? undefined + : tile._webMercatorReprojectorSeed, debug, debugOpacity, coordinateSystem, 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 b291f541..898da2f5 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 @@ -13,6 +13,7 @@ import type { } from "@deck.gl/geo-layers"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import { transformBounds } from "@developmentseed/proj"; +import type { InitialTriangulation } from "@developmentseed/raster-reproject"; import type { Matrix4 } from "@math.gl/core"; import { BoundingVolumeCache } from "./bounding-volume-cache.js"; import { @@ -30,6 +31,7 @@ import type { TileIndex, ZRange, } from "./types.js"; +import { webMercatorClampSeed } from "./web-mercator-clamp.js"; /** Type returned by {@link RasterTileset2D.getTileMetadata} */ export type RasterTileMetadata = { @@ -98,6 +100,14 @@ export type RasterTileMetadata = { * as {@link RasterTileMetadata._projectPosition}. */ _unprojectPosition: ProjectionFunction; + + /** + * Seed triangulation that clamps this tile's reprojection mesh to the valid + * Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed. + * Consumed only by the Web Mercator render path; the globe path renders the + * full mesh. See {@link webMercatorClampSeed}. + */ + _webMercatorReprojectorSeed?: InitialTriangulation; }; /** @@ -350,6 +360,18 @@ export class RasterTileset2D extends Tileset2D { const { forwardTransform, inverseTransform } = levelDescriptor.tileTransform(x, y); + // 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 _webMercatorReprojectorSeed = webMercatorClampSeed({ + topLeft: cornerLat(topLeft), + topRight: cornerLat(topRight), + bottomLeft: cornerLat(bottomLeft), + bottomRight: cornerLat(bottomRight), + }); + return { bbox: { west, @@ -370,6 +392,7 @@ export class RasterTileset2D extends Tileset2D { inverseTransform, _projectPosition: this.projectPosition, _unprojectPosition: this.unprojectPosition, + _webMercatorReprojectorSeed, }; } } From f0d1b3d4c567343c6d948675786afe2d261c5554 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 14:20:47 -0400 Subject: [PATCH 10/13] refactor(deck.gl-raster): name clamp seed as initialTriangulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/raster-tile-layer/raster-tile-layer.ts | 2 +- .../src/raster-tileset/raster-tileset-2d.ts | 10 +++++----- .../src/raster-tileset/web-mercator-clamp.ts | 2 +- .../raster-tileset/web-mercator-clamp.test.ts | 14 +++++++------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index d13def0b..8981f843 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 @@ -447,7 +447,7 @@ export class RasterTileLayer< // past ±85.051°. Globe renders the full mesh (it shows the poles). initialTriangulation: isGlobe ? undefined - : tile._webMercatorReprojectorSeed, + : tile._webMercatorInitialTriangulation, debug, debugOpacity, coordinateSystem, 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 898da2f5..7fcdacdb 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 @@ -31,7 +31,7 @@ import type { TileIndex, ZRange, } from "./types.js"; -import { webMercatorClampSeed } from "./web-mercator-clamp.js"; +import { webMercatorInitialTriangulation } from "./web-mercator-clamp.js"; /** Type returned by {@link RasterTileset2D.getTileMetadata} */ export type RasterTileMetadata = { @@ -105,9 +105,9 @@ export type RasterTileMetadata = { * Seed triangulation that clamps this tile's reprojection mesh to the valid * Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed. * Consumed only by the Web Mercator render path; the globe path renders the - * full mesh. See {@link webMercatorClampSeed}. + * full mesh. See {@link webMercatorInitialTriangulation}. */ - _webMercatorReprojectorSeed?: InitialTriangulation; + _webMercatorInitialTriangulation?: InitialTriangulation; }; /** @@ -365,7 +365,7 @@ export class RasterTileset2D extends Tileset2D { // ±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 _webMercatorReprojectorSeed = webMercatorClampSeed({ + const _webMercatorInitialTriangulation = webMercatorInitialTriangulation({ topLeft: cornerLat(topLeft), topRight: cornerLat(topRight), bottomLeft: cornerLat(bottomLeft), @@ -392,7 +392,7 @@ export class RasterTileset2D extends Tileset2D { inverseTransform, _projectPosition: this.projectPosition, _unprojectPosition: this.unprojectPosition, - _webMercatorReprojectorSeed, + _webMercatorInitialTriangulation, }; } } diff --git a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts index fb685211..2fe37e3b 100644 --- a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts +++ b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts @@ -34,7 +34,7 @@ export interface CornerLatitudes { * @param cornerLats WGS84 latitudes of the tile's four corners. * @param maxLat Web Mercator latitude limit. Defaults to ±85.051°. */ -export function webMercatorClampSeed( +export function webMercatorInitialTriangulation( cornerLats: CornerLatitudes, maxLat: number = MAX_WEB_MERCATOR_LAT, ): InitialTriangulation | undefined { diff --git a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts index d5872eb1..fadb2382 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { webMercatorClampSeed } from "../../src/raster-tileset/web-mercator-clamp.js"; +import { webMercatorInitialTriangulation } from "../../src/raster-tileset/web-mercator-clamp.js"; const MAX_LAT = 85.05112877980659; -describe("webMercatorClampSeed", () => { +describe("webMercatorInitialTriangulation", () => { it("returns undefined for a tile within Web Mercator bounds", () => { expect( - webMercatorClampSeed({ + webMercatorInitialTriangulation({ topLeft: 40, topRight: 40, bottomLeft: 30, @@ -16,7 +16,7 @@ describe("webMercatorClampSeed", () => { }); it("clamps a global north-up tile to the valid band on both edges", () => { - const seed = webMercatorClampSeed({ + const seed = webMercatorInitialTriangulation({ topLeft: 90, topRight: 90, bottomLeft: -90, @@ -31,7 +31,7 @@ describe("webMercatorClampSeed", () => { }); it("clamps only the north edge when only the north exceeds the bound", () => { - const seed = webMercatorClampSeed({ + const seed = webMercatorInitialTriangulation({ topLeft: 90, topRight: 90, bottomLeft: 80, @@ -43,7 +43,7 @@ describe("webMercatorClampSeed", () => { it("returns undefined for a non-north-up (rotated) tile", () => { expect( - webMercatorClampSeed({ + webMercatorInitialTriangulation({ topLeft: 90, topRight: 88, bottomLeft: -90, @@ -54,7 +54,7 @@ describe("webMercatorClampSeed", () => { it("returns undefined for a fully-polar tile (empty band)", () => { expect( - webMercatorClampSeed({ + webMercatorInitialTriangulation({ topLeft: 88, topRight: 88, bottomLeft: 86, From e3d64130e92b024e1b2325984fe61445e33e096f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 14:23:14 -0400 Subject: [PATCH 11/13] 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) --- .../src/raster-tileset/raster-tileset-2d.ts | 17 +++++++++-------- .../src/raster-tileset/web-mercator-clamp.ts | 2 +- .../raster-tileset/web-mercator-clamp.test.ts | 14 +++++++------- 3 files changed, 17 insertions(+), 16 deletions(-) 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 7fcdacdb..23ec47ea 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 @@ -31,7 +31,7 @@ import type { TileIndex, ZRange, } from "./types.js"; -import { webMercatorInitialTriangulation } from "./web-mercator-clamp.js"; +import { createInitialWebMercatorTriangulation } from "./web-mercator-clamp.js"; /** Type returned by {@link RasterTileset2D.getTileMetadata} */ export type RasterTileMetadata = { @@ -105,7 +105,7 @@ export type RasterTileMetadata = { * Seed triangulation that clamps this tile's reprojection mesh to the valid * Web Mercator latitude band (±85.051°), or `undefined` if no clamp is needed. * Consumed only by the Web Mercator render path; the globe path renders the - * full mesh. See {@link webMercatorInitialTriangulation}. + * full mesh. See {@link createInitialWebMercatorTriangulation}. */ _webMercatorInitialTriangulation?: InitialTriangulation; }; @@ -365,12 +365,13 @@ export class RasterTileset2D extends Tileset2D { // ±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 = webMercatorInitialTriangulation({ - topLeft: cornerLat(topLeft), - topRight: cornerLat(topRight), - bottomLeft: cornerLat(bottomLeft), - bottomRight: cornerLat(bottomRight), - }); + const _webMercatorInitialTriangulation = + createInitialWebMercatorTriangulation({ + topLeft: cornerLat(topLeft), + topRight: cornerLat(topRight), + bottomLeft: cornerLat(bottomLeft), + bottomRight: cornerLat(bottomRight), + }); return { bbox: { diff --git a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts index 2fe37e3b..69340e49 100644 --- a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts +++ b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts @@ -34,7 +34,7 @@ export interface CornerLatitudes { * @param cornerLats WGS84 latitudes of the tile's four corners. * @param maxLat Web Mercator latitude limit. Defaults to ±85.051°. */ -export function webMercatorInitialTriangulation( +export function createInitialWebMercatorTriangulation( cornerLats: CornerLatitudes, maxLat: number = MAX_WEB_MERCATOR_LAT, ): InitialTriangulation | undefined { diff --git a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts index fadb2382..7d87a2fd 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { webMercatorInitialTriangulation } from "../../src/raster-tileset/web-mercator-clamp.js"; +import { createInitialWebMercatorTriangulation } from "../../src/raster-tileset/web-mercator-clamp.js"; const MAX_LAT = 85.05112877980659; -describe("webMercatorInitialTriangulation", () => { +describe("createInitialWebMercatorTriangulation", () => { it("returns undefined for a tile within Web Mercator bounds", () => { expect( - webMercatorInitialTriangulation({ + createInitialWebMercatorTriangulation({ topLeft: 40, topRight: 40, bottomLeft: 30, @@ -16,7 +16,7 @@ describe("webMercatorInitialTriangulation", () => { }); it("clamps a global north-up tile to the valid band on both edges", () => { - const seed = webMercatorInitialTriangulation({ + const seed = createInitialWebMercatorTriangulation({ topLeft: 90, topRight: 90, bottomLeft: -90, @@ -31,7 +31,7 @@ describe("webMercatorInitialTriangulation", () => { }); it("clamps only the north edge when only the north exceeds the bound", () => { - const seed = webMercatorInitialTriangulation({ + const seed = createInitialWebMercatorTriangulation({ topLeft: 90, topRight: 90, bottomLeft: 80, @@ -43,7 +43,7 @@ describe("webMercatorInitialTriangulation", () => { it("returns undefined for a non-north-up (rotated) tile", () => { expect( - webMercatorInitialTriangulation({ + createInitialWebMercatorTriangulation({ topLeft: 90, topRight: 88, bottomLeft: -90, @@ -54,7 +54,7 @@ describe("webMercatorInitialTriangulation", () => { it("returns undefined for a fully-polar tile (empty band)", () => { expect( - webMercatorInitialTriangulation({ + createInitialWebMercatorTriangulation({ topLeft: 88, topRight: 88, bottomLeft: 86, From a657eb83bedaded8e274180e0af3c9a898213e18 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 14:33:44 -0400 Subject: [PATCH 12/13] 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) --- .../{rectangle-seed.test.ts => triangulate-rectangle.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/raster-reproject/tests/{rectangle-seed.test.ts => triangulate-rectangle.test.ts} (100%) diff --git a/packages/raster-reproject/tests/rectangle-seed.test.ts b/packages/raster-reproject/tests/triangulate-rectangle.test.ts similarity index 100% rename from packages/raster-reproject/tests/rectangle-seed.test.ts rename to packages/raster-reproject/tests/triangulate-rectangle.test.ts From 9620130ff582e62cc7da7f822c878580d600702b Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 14:35:44 -0400 Subject: [PATCH 13/13] 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) --- .../src/raster-tileset/web-mercator-clamp.ts | 4 ++-- .../raster-tileset/web-mercator-clamp.test.ts | 2 +- packages/raster-reproject/src/delatin.ts | 16 +++++++++++----- packages/raster-reproject/src/index.ts | 2 +- .../tests/triangulate-rectangle.test.ts | 10 +++++----- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts index 69340e49..6a21068f 100644 --- a/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts +++ b/packages/deck.gl-raster/src/raster-tileset/web-mercator-clamp.ts @@ -1,5 +1,5 @@ import type { InitialTriangulation } from "@developmentseed/raster-reproject"; -import { rectangleSeed } from "@developmentseed/raster-reproject"; +import { triangulateRectangle } from "@developmentseed/raster-reproject"; /** Maximum latitude representable in Web Mercator (EPSG:3857), in degrees. */ const MAX_WEB_MERCATOR_LAT = 85.05112877980659; @@ -73,5 +73,5 @@ export function createInitialWebMercatorTriangulation( return undefined; } - return rectangleSeed(0, vTop, 1, vBottom); + return triangulateRectangle(0, vTop, 1, vBottom); } diff --git a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts index 7d87a2fd..20aadddd 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/web-mercator-clamp.test.ts @@ -23,7 +23,7 @@ describe("createInitialWebMercatorTriangulation", () => { bottomRight: -90, }); expect(seed).toBeDefined(); - // rectangleSeed(0, vTop, 1, vBottom) → uvs = [0,vTop, 1,vTop, 0,vBottom, 1,vBottom] + // triangulateRectangle(0, vTop, 1, vBottom) → uvs = [0,vTop, 1,vTop, 0,vBottom, 1,vBottom] expect(seed?.uvs[1]).toBeCloseTo((90 - MAX_LAT) / 180, 9); // vTop expect(seed?.uvs[5]).toBeCloseTo((90 + MAX_LAT) / 180, 9); // vBottom expect(seed?.uvs[0]).toBe(0); diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index 3d7bde55..6183e20e 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -69,14 +69,15 @@ export interface InitialTriangulation { } /** - * Build an axis-aligned rectangle seed (4 corner vertices, 2 triangles sharing - * the p0–p3 diagonal) covering the UV sub-rectangle `[uMin, vMin]`–`[uMax, vMax]`. + * Triangulate an axis-aligned UV rectangle `[uMin, vMin]`–`[uMax, vMax]` into an + * {@link InitialTriangulation} (4 corner vertices, 2 triangles sharing the + * p0–p3 diagonal). * * Seeding {@link RasterReprojector} with a sub-rectangle confines the mesh to * that region — e.g. to clamp an image's mesh to the valid Web Mercator * latitude band (see #182 / #351), pass that band's UV bounds. */ -export function rectangleSeed( +export function triangulateRectangle( uMin: number, vMin: number, uMax: number, @@ -97,7 +98,12 @@ export function rectangleSeed( * hardcoded constructor init exactly, and is hardcoded (not delaunator-built) * so the package has no runtime triangulation dependency. */ -const UNIT_SQUARE_SEED: InitialTriangulation = rectangleSeed(0, 0, 1, 1); +const UNIT_SQUARE_TRIANGULATION: InitialTriangulation = triangulateRectangle( + 0, + 0, + 1, + 1, +); export interface ReprojectionFns { /** @@ -202,7 +208,7 @@ export class RasterReprojector { this._pending = []; // triangles pending addition to queue this._pendingLen = 0; - this._seed(options.initialTriangulation ?? UNIT_SQUARE_SEED); + this._seed(options.initialTriangulation ?? UNIT_SQUARE_TRIANGULATION); this._flush(); } diff --git a/packages/raster-reproject/src/index.ts b/packages/raster-reproject/src/index.ts index 40203d25..78df1855 100644 --- a/packages/raster-reproject/src/index.ts +++ b/packages/raster-reproject/src/index.ts @@ -1,2 +1,2 @@ export type { InitialTriangulation, ReprojectionFns } from "./delatin.js"; -export { RasterReprojector, rectangleSeed } from "./delatin.js"; +export { RasterReprojector, triangulateRectangle } from "./delatin.js"; diff --git a/packages/raster-reproject/tests/triangulate-rectangle.test.ts b/packages/raster-reproject/tests/triangulate-rectangle.test.ts index bf45b61c..10c59686 100644 --- a/packages/raster-reproject/tests/triangulate-rectangle.test.ts +++ b/packages/raster-reproject/tests/triangulate-rectangle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ReprojectionFns } from "../src/delatin.js"; -import { RasterReprojector, rectangleSeed } from "../src/delatin.js"; +import { RasterReprojector, triangulateRectangle } from "../src/delatin.js"; const fns: ReprojectionFns = { forwardTransform: (x, y) => [x, y], @@ -9,16 +9,16 @@ const fns: ReprojectionFns = { inverseReproject: (x, y) => [x - 0.05 * y * y, y], }; -describe("rectangleSeed", () => { +describe("triangulateRectangle", () => { it("builds the unit square (same as the default seed)", () => { - const s = rectangleSeed(0, 0, 1, 1); + const s = triangulateRectangle(0, 0, 1, 1); expect(s.uvs).toEqual([0, 0, 1, 0, 0, 1, 1, 1]); expect(s.triangles).toEqual([3, 0, 2, 0, 3, 1]); expect(s.halfedges).toEqual([3, -1, -1, 0, -1, -1]); }); it("builds a clamped horizontal band", () => { - const s = rectangleSeed(0, 0.25, 1, 0.75); + const s = triangulateRectangle(0, 0.25, 1, 0.75); expect(s.uvs).toEqual([0, 0.25, 1, 0.25, 0, 0.75, 1, 0.75]); // topology is identical to the unit square expect(s.triangles).toEqual([3, 0, 2, 0, 3, 1]); @@ -27,7 +27,7 @@ describe("rectangleSeed", () => { it("is a valid reprojector seed (converges, stays in the band)", () => { const r = new RasterReprojector(fns, 64, 64, { - initialTriangulation: rectangleSeed(0, 0.2, 1, 0.8), + initialTriangulation: triangulateRectangle(0, 0.2, 1, 0.8), }); r.run(0.125); expect(r.getMaxError()).toBeLessThanOrEqual(0.125); // winding/halfedges valid