Skip to content

fix: render COGs in bounded projections (Mollweide, Sinusoidal, Equal Earth) - #636

Open
tylere wants to merge 1 commit into
developmentseed:mainfrom
tylere:tylere/fix-bounded-crs-rendering
Open

fix: render COGs in bounded projections (Mollweide, Sinusoidal, Equal Earth)#636
tylere wants to merge 1 commit into
developmentseed:mainfrom
tylere:tylere/fix-bounded-crs-rendering

Conversation

@tylere

@tylere tylere commented Aug 20, 2026

Copy link
Copy Markdown

Summary

Fixes rendering of COGs that use a CRS with a bounded domain — an ellipse or other closed shape — such as World Mollweide (ESRI:54009), Sinusoidal, and Equal Earth. Before this PR, loading any such COG produced a blank map.

Abbreviations used below:

  • OOD — Out Of Domain: a point that lies outside the projection's valid region (e.g. a rectangle corner that falls outside the Mollweide ellipse). proj4 returns NaN or a clamped/incorrect value for these points.
  • OBB — Oriented Bounding Box: a tight-fitting box that can be rotated to align with a shape. Used by deck.gl for frustum culling — testing whether a tile's OBB intersects the camera frustum to decide if the tile is visible.
  • AABB — Axis-Aligned Bounding Box: a bounding box whose sides are parallel to the coordinate axes ([minX, minY, maxX, maxY]). Used here as the commonSpaceBounds for a quick pre-filter before the full frustum cull.

Four root causes were identified and fixed:

1. AffineTilesetLevel.projectedTileCorners — corner extrapolation past array boundary

The last tile in each row/column has nominal corners at (col+1)*tileWidth pixels, which exceeds arrayWidth when the tile matrix doesn't evenly divide the array. For bounded projections, those extrapolated corners fall outside the CRS domain; proj4 clamps them to ±85.05° in Mercator, collapsing all such corners onto a single horizontal line and producing a zero-height bounding volume — causing every tile to be culled immediately.

Fix: clip right = min((col+1)*tw, arrayWidth) and bottom = min((row+1)*th, arrayHeight) before applying the affine.

2. RasterTileNode._getGenericBoundingVolume — NaN positions in OBB input

OOD corners produce NaN common-space positions. Passing NaN to makeOrientedBoundingBoxFromPoints produced a degenerate OBB that always returned -1 from computeVisibility, incorrectly culling every tile. Additionally, when the only valid reference points are collinear (e.g. the equatorial band of a Mollweide tile), the OBB is degenerate for a different reason.

Fix: filter NaN positions before OBB/AABB computation; return an off-screen degenerate bounding volume for fully-OOD tiles; build the OBB from the four AABB corners (with a 1-unit minimum extent on each axis) so the OBB is always non-degenerate.

3. RasterLayer._generateMesh — adaptive reprojector choked by OOD vertices

Tiles that straddle the CRS domain boundary have some corners with valid output positions and some OOD. The adaptive RasterReprojector spent its entire iteration budget refining OOD-vertex triangles, leaving the valid area of the tile severely under-resolved (error 100s of pixels).

Fix: detect "border tiles" by checking whether any corner's round-trip error via forwardReproject → inverseReproject exceeds 1% of the tile CRS width. For border tiles, build a dense 64×64 uniform grid mesh and filter out any triangle whose vertex fails the OOD check — producing a clean domain-boundary cutoff. Also filter OOD triangles from the adaptive mesh path in reprojectorToMesh.

4. RasterTileset2D.projectPosition — explicit NaN for OOD inputs

Made the NaN pass-through explicit so the GPU rasterizer reliably discards triangles touching OOD vertices, regardless of future changes to the clamping path.

Test plan

  • pnpm -r test — 135 pass (4 pre-existing failures in web-mercator-clamp.test.ts related to triangulateRectangle not exported from dist; unrelated to this PR)
  • pnpm biome check — clean across all changed files
  • pnpm -r typecheck — same pre-existing failures as on main (InitialTriangulation not in dist); no new errors from this PR
  • +2 new edge-clipping tests in affine-tileset-level.test.ts
  • Manual: load https://data.source.coop/vizzuality/hfp-100/hfp_2017_100m_v1-2_cog.tif (World Mollweide HFP COG) in the cog-basic example — tiles should render over the globe

Related

🤖 Generated with Claude Code

… Earth)

Four root causes prevented tiles from rendering when a COG uses a CRS with
a bounded domain (an ellipse or other closed shape), such as World Mollweide
(ESRI:54009):

1. **AffineTilesetLevel.projectedTileCorners** extrapolated past the array
   boundary for the last tile row/column. For bounded projections the
   extrapolated corners fall outside the CRS domain; proj4 clamps them to
   ±85.05° in Mercator, collapsing all such corners to a single horizontal
   line and producing a zero-height bounding volume — causing every tile to
   be culled immediately.
   Fix: clip tile corners to `[0, arrayWidth] × [0, arrayHeight]`.

2. **RasterTileNode._getGenericBoundingVolume** passed NaN common-space
   positions (from OOD corners) straight to `makeOrientedBoundingBoxFromPoints`,
   producing a degenerate OBB that always returned -1 from `computeVisibility`.
   Fix: filter NaN positions before OBB/AABB computation; return an
   off-screen degenerate volume for fully-OOD tiles; build the OBB from
   AABB corners (with a 1-unit minimum extent on each axis) to avoid a
   collinear-points degenerate OBB for equatorial Mollweide tiles.

3. **RasterLayer._generateMesh** fed OOD tile corners to the adaptive
   `RasterReprojector`; it exhausted its iteration budget refining
   OOD-vertex triangles, leaving the valid region severely under-resolved.
   Fix: detect "border tiles" (any corner fails the round-trip
   forwardReproject→inverseReproject check), and for those tiles build a
   dense 64×64 uniform grid mesh instead, filtering out OOD-vertex
   triangles. Also filter OOD triangles from the adaptive mesh path via
   `reprojectorToMesh`.

4. **RasterTileset2D.projectPosition** was not explicit about returning
   NaN for OOD inputs. Made the NaN-pass-through explicit so future changes
   to the clamping path don't silently break the GPU triangle discard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tylere

tylere commented Aug 20, 2026

Copy link
Copy Markdown
Author

These changes are based on patches used with deck.gl-raster 0.8.0-beta.2
See: source-cooperative/cog-viewer#30

@kylebarron

Copy link
Copy Markdown
Member

I don't know enough about these types of projections but we definitely need a careful understanding of how they work and careful tests before merging

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants