Skip to content

feat(core): layer coordinateSystem supports arbitrary CRS - #10874

Open
Pessimistress wants to merge 2 commits into
codex/crs-descriptor-integrationfrom
codex/source-crs-resolution
Open

Pessimistress wants to merge 2 commits into
codex/crs-descriptor-integrationfrom
codex/source-crs-resolution

Conversation

@Pessimistress

@Pessimistress Pessimistress commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

For #10739

Background

Major conceptual clean up of projection pipeline. Now explicitly separates:

  • Layer's data coordinate system
  • Layer's packed positions attribute coordinate system
  • View's target CRS coordinate system
  • Shaderlib's common space

For example, MVTLayer in v9.4 creates sublayers with coordinateSystem: 'cartesian' in MapView and coordinateSystem: 'lnglat' in GlobeView. After this PR, it creates sublayers with coordinateSystem: 'OSMTile512' in all viewports.

Antarctica, coordinateSystem: 'EPSG:3031' (Stereographic) + GlobeView
custom-projection-antarctica-globe

France regions, coordinateSystem: 'EPSG:2154' (Lambert-93) + MapView
custom-projection-france-map

Switzerland cantons, coordinateSystem: 'EPSG:2056' (LV95) + CustomProjectionView toCrs: 'EPSG:8857' (Equal Earth)
custom-projection-switzerland-equal-earth

Change List

  • Accept arbitrary CRS strings in Layer.coordinateSystem
  • Resolve source CRS before initializing or updating layers
  • Track per-layer preprojection and packed-coordinate CRS
  • Keep shader-supported coordinates untransformed during attribute packing
  • Load MVT geometry consistently in OSMTile512
  • Derive subdivision steps from source CRS units and viewport resolution
  • Tessellate polygons in the viewport’s target CRS
  • Unit tests, render tests, docs, and upgrade guide

Note

High Risk
This is a v10-breaking change to core projection, public constants, viewport CRS APIs, and tiled-layer coordinate handling across aggregation and geo layers.

Overview
Layers can declare arbitrary CRS identifiers via coordinateSystem (EPSG codes, PROJ strings, aliases like 'OSMTile512'). LayerManager resolves descriptors asynchronously and blocks init/update until ready; layers track source CRS, packed attribute CRS, and optional CPU preproject/unpreproject pairs from viewport.getPreprojection().

The projection pipeline is split explicitly: layer data CRS → packed attributes → view target CRS → shader common space. COORDINATE_SYSTEM and _PROJECTION_MODE are removed from public exports; layers use string CRS names. The project shader uniform coordinateSystem is renamed coordinateFrame (geodetic/projected/geocentric packed frames).

Viewport resolution now means subdivision intervals around the earth (step = circumference / resolution), default 0 everywhere including GlobeView (migrate old 10° with resolution: 36). preproject/postUnproject viewport hooks are removed in favor of getPreprojection(sourceCrs) and getSubdivisionStep(sourceCrs).

MVTLayer always loads tile-local geometry with coordinateSystem: 'OSMTile512' and converts per viewport; binary mode disables when subdivision is on. Aggregation, heatmap, clip/mask extensions, and related layers switch from viewport.preproject to project.isCustomProjection and layer preprojection state. Docs and the v10 upgrade guide cover CRS config, transform order (modelMatrix then coordinateOrigin), and breaking API changes.

Reviewed by Cursor Bugbot for commit b0c50dc. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread modules/core/src/lib/layer.ts
unpreproject: position => {
const wgs84 = targetConverter ? targetConverter.to(position) : position;
const source = geographic ? wgs84 : sourceConverter.from(wgs84);
return [source[0], source[1], source[2] ?? 0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Picking conversion can throw

Medium Severity

getPreprojection().unpreproject forwards math.gl inverse conversion without catching domain failures. HexagonLayer picking now calls that helper on packed centroids, so a bin outside the target CRS domain can throw during pick instead of returning a non-finite position as unprojectPosition does.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit df1769d. Configure here.

dataChanged: 'tile geometry format changed',
propsOrDataChanged: true
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subdivision clears VectorTile binary flag

Medium Severity

MVTLayer now sets binary to false whenever getSubdivisionStep() is positive. VectorTileLayer always loads binary tiles and forces binary: true at init, including non-MVT CARTO payloads that are already WGS84. After the first update with subdivision enabled, picking skips binaryToGeojson and highlighted feature objects are wrong.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit df1769d. Configure here.

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5

[High impact] Coordinate system handling refactored across rendering pipeline.

This PR needs fixes before merging: clipping can hide valid points, and the earlier rendering and example-build failures remain.

Findings

  1. P1 Clipping hides valid points ▶
  2. P1 Globe tile fills disappear ▶
  3. P1 Masks cover the wrong place ▶
  4. P1 Repository examples no longer build ▶
  5. P1 Moved globe fills disappear ▶
  6. P2 Unchanged geometry keeps rebuilding ▶
  7. P2 Omitted altitude becomes NaN ▶

Summary

Adds arbitrary source CRS strings and separates source coordinates, packed attributes, and viewport projection.

  • Layers can declare their source coordinates with a CRS string.
  • Views set how finely layers subdivide geometry.
  • MVT tiles use the same tile-local CRS in every viewport.
  • Coordinate-aware layers use source and packed CRS state.

The previous-review ancestry checks did not pass, so this review used the full PR diff. Tests and visual checks were not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Layer coordinateSystem] --> B[Resolve source CRS]
  B --> C{Shader accepts source?}
  C -->|Yes| D[Keep source attributes]
  C -->|No| E[Apply layer transforms and CPU conversion]
  E --> F[Pack converted attributes]
  D --> G[Shader projection]
  F --> G
  G --> H[Viewport]
Loading

Reviews (3) · Last reviewed commit: "render tests" · Reviewed by Greptile

// GlobeView doesn't work well with binary data
const binary = this.context.viewport.resolution !== undefined ? false : this.props.binary;
// Prepared binary geometry bypasses normalization/subdivision.
const binary = this.getSubdivisionStep() > 0 ? false : this.props.binary;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Globe tile fills disappear

With subdivision disabled, MVTLayer now keeps binary tiles and their supplied triangle indices in GlobeView. Those triangles were built in southward-Y tile coordinates, but converting positions to longitude/latitude reverses their winding. Supplied indices skip the new outward-facing correction, so GlobeView hides their back faces and polygon fills disappear. Keep the non-binary globe path until supplied triangles are also corrected.

Comment on lines 440 to +443
usePositionTransforms(): Pick<AttributeOptions, 'transformSource' | 'transform'> {
return {
transformSource: 'projection',
transform:
this.context.viewport.preproject && this.props.coordinateSystem !== 'cartesian'
? transformPosition
: null
transform: this.internalState?.preproject ? transformPosition : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Masks cover the wrong place

A mask polygon declared in EPSG:3857 now stores longitude/latitude positions in MapView. Its getBounds() returns those packed positions, but joinLayerBounds() passes them through layer.projectPosition() again as meters in EPSG:3857. The mask texture then covers the wrong place.

Update bounds consumers to use the packed CRS and skip transforms already applied during packing. The terrain bounds helper uses the same path.

this._sourceCrs = sourceCrs;
if (this.internalState) {
this.internalState.sourceCrs = sourceCrs;
this._updatePreprojection(this.context.viewport);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unchanged geometry keeps rebuilding

_setSourceCrs() refreshes conversion against the shared viewport on every layer match, even when the layer was last drawn in another view. _update() immediately switches back to internalState.viewport. In multi-view apps with different target CRSs, this marks unchanged positions and geometry for rebuilding on every update. Use the retained layer viewport here, or defer the refresh to _update().

Suggested change
this._updatePreprojection(this.context.viewport);
this._updatePreprojection(this.internalState.viewport || this.context.viewport);

transformMat4(position, position, this.props.modelMatrix);
}
return this.context.viewport.preproject!(position);
for (let axis = 0; axis < 3; axis++) position[axis] += this.props.coordinateOrigin[axis];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Omitted altitude becomes NaN

JavaScript callers commonly pass two-element coordinateOrigin values, and the existing shader path accepts them. This new loop adds undefined to altitude during CPU conversion, producing NaN positions that can make geometry disappear. Default the missing origin altitude to zero before adding it.

Suggested change
for (let axis = 0; axis < 3; axis++) position[axis] += this.props.coordinateOrigin[axis];
for (let axis = 0; axis < 3; axis++) position[axis] += this.props.coordinateOrigin[axis] ?? 0;

@coveralls

coveralls commented Oct 15, 2026 •

Copy link
Copy Markdown

Coverage Status

Coverage is 84.047% — codex/source-crs-resolution into codex/crs-descriptor-integration. No base build found for codex/crs-descriptor-integration.

@kylebarron

Copy link
Copy Markdown
Collaborator
  • Accept arbitrary CRS strings in Layer.coordinateSystem

The typedef of CRS definition should at least include objects to support PROJJSON, a more modern description of CRS than the relatively fickle and outdated proj strings

@Pessimistress
Pessimistress force-pushed the codex/source-crs-resolution branch from df1769d to bbbcc9c Compare October 15, 2026 21:35
Comment thread modules/core/src/index.ts
Comment on lines +371 to +373
preproject: this.state.packedCrs
? position => viewport.projectPosition(position, {sourceCrs: this.state.packedCrs!})
: undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Moved globe fills disappear

Globe triangulation ignores modelMatrix and coordinateOrigin when positions stay in WGS84. This callback projects the raw polygon, but the shader draws the transformed polygon. For example, a ring around latitude 80 with coordinateOrigin: [0, -160, 0] gets north-facing triangles, then draws around latitude -80, where those triangles face inward and are culled. Include the layer transforms in the temporary globe coordinates, and rebuild the triangles when those transforms change.

@Pessimistress
Pessimistress force-pushed the codex/source-crs-resolution branch from bbbcc9c to b0c50dc Compare October 15, 2026 23:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b0c50dc. Configure here.

return;
}

if (this._coordinateSystemLoading) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale viewport after async CRS load

Medium Severity

activateViewport returns while a coordinateSystem descriptor is still loading and never stores the incoming viewport. When the CRS resolves, _update rebuilds attributes and sublayers from the previous internalState.viewport, so a camera or view change during that wait is ignored for one update cycle.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0c50dc. Configure here.

Comment on lines +141 to +147
const corner0 = transform.call(this, [clipBounds[0], clipBounds[1], 0]);
const corner1 = transform.call(this, [clipBounds[2], clipBounds[3], 0]);
clipProps.bounds = [
Math.min(corner0[0], corner1[0]),
Math.min(corner0[1], corner1[1]),
Math.max(corner0[0], corner1[0]),
Math.max(corner0[1], corner1[1])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Clipping hides valid points

With CPU conversion active, ClipExtension builds its bounds from only two converted corners. For example, a ScatterplotLayer in MapView with coordinateSystem: 'EPSG:3857', a 45-degree modelMatrix rotation, and square clipBounds rotates those corners onto nearly the same X coordinate. The resulting box excludes most points inside the original square.

Test anchors against the original source-space rectangle rather than a box built from two converted corners.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants