Visitar URL original
feat(layers): generate projection-aware BitmapLayer UVs by ibgreen · Pull Request #11031 · visgl/deck.gl · GitHub
Skip to content

feat(layers): generate projection-aware BitmapLayer UVs - #11031

Open
ibgreen wants to merge 11 commits into
codex/bitmap-vectorsfrom
codex/bitmap-projection
Open

ibgreen wants to merge 11 commits into
codex/bitmap-vectorsfrom
codex/bitmap-projection

Conversation

@ibgreen

@ibgreen ibgreen commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Requires #11030

Background

Bitmap meshes require callers to supply UVs. Generate them from explicit image and position CRSs, with optional source-space subdivision through math.gl.

Change List

  • Add imageMapping and meshMaxEdgeLength to BitmapLayer
  • Generate UVs using public math.gl projection helpers
  • Preserve manual topology unless explicit subdivision is requested
  • Reject GPU readback, invalid domains, and conflicting UV inputs
  • Unit tests: conversion, lifecycle, and validation
  • Render tests: independently verify wide-latitude Mercator UVs on both backends
  • Documentation: describe CRS mapping, units, and seam limitations

Latest checks: lint, layers type-check, 59 Node tests, ten WebGL/WebGPU mesh tests. Existing examples and golden images unchanged.


Note

Low Risk
The changes add opt-in props to BitmapLayer for projection-aware UV generation with no impact on existing bitmap rendering paths.

Overview
Adds imageMapping and meshMaxEdgeLength props to BitmapLayer to automatically generate texture coordinates (UVs) when the image and mesh use different coordinate reference systems (CRS).

When imageMapping is supplied, CPU-readable Arrow mesh positions are projected from positionCRS to imageCRS using @math.gl/geospatial's projection engine, generating normalized UVs and replacing the need for manual texCoords or _imageCoordinateSystem. Specifying meshMaxEdgeLength refines triangles with long edges in position space using @math.gl/geometry to avoid image distortion on coarse meshes.

Dynamic import is used within BitmapLayerMesh to load the mapping and subdivision logic only when required. Corresponding documentation, lifecycle checks, and WebGL/WebGPU test coverage have also been added.

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

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-04T20:16:55.428546Z
We couldn’t complete this review. Comment @codex review to try again.
521e734 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@greptile-apps

greptile-apps Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new actionable issue was established.

Summary

Adds projection-aware UV generation through imageMapping, with optional source-space subdivision through meshMaxEdgeLength.

  • BitmapLayer creates image UVs from mesh and image projections.
  • Mapped triangles split when they exceed a chosen edge length.

The previous and current commits have different merge bases with the target, so this review covered the full PR.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Bounds or CPU mesh] --> B[Optional source-space subdivision]
  B --> C[Project positions into image CRS]
  C --> D[Generate UVs]
  D --> E[Convert Arrow columns]
  E --> F[Replace mesh buffers]
Loading

Reviews (7) · Last reviewed commit: "Merge branch 'codex/review-bitmap' into ..." · Reviewed by Greptile

@coveralls

coveralls commented Nov 4, 2026 •

Copy link
Copy Markdown

Coverage Status

Coverage is 84.475% — codex/bitmap-projection into codex/bitmap-vectors. No base build found for codex/bitmap-vectors.

@chrisgervang chrisgervang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed fefe2dc; recommend approval. No code P1s, but please follow documentation guidelines.

subdivideTriangleMesh and projectionEngine ship in math.gl 5.0.0-beta.4, which yarn.lock already resolves. apache-arrow stays in the dynamically loaded mapping chunk. Generated Mercator UVs matched the analytic formula to 3e-8 up to 60 degrees latitude.

Labels: P1 settle before this is merged, P2 can follow, Non-blocking optional.

import fs from './bitmap-layer-fragment';

const defaultProps: DefaultProps<BitmapLayerProps> = {
imageMapping: {type: 'object', value: null, compare: false},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: An inline imageMapping literal, as in the docs example, regenerates the mesh on every update. compare: false and the !== check in updateMeshState both compare by reference.

  • In a node probe, three updates with equal literals ran BitmapLayerMesh.update three times, and isLoaded went false each time.

Could imageMapping use a deep compare such as compare: -1, with updateMeshState using the same check?

/** Explicit CRS of the image rectangle. */
imageCRS: ReadonlyCRSDefinition;
/** Explicit CRS of mesh positions before deck.gl transforms. */
positionCRS: ReadonlyCRSDefinition;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: positionCRS restates the layer's coordinateSystem, which already accepts PROJ strings, and the docs ask callers to keep the two consistent. imageMapping also overlaps the experimental _imageCoordinateSystem for lnglat images in Mercator.

Could positionCRS default to the layer's source CRS when there is no modelMatrix? Is imageMapping meant to replace _imageCoordinateSystem over time?

Details
  • Core names these sourceCrs, packedCrs and targetCrs. The new props use positionCRS and imageCRS.

[bounds[2], bounds[1]]
]
: bounds;
({positions, indices} = createMesh(corners, undefined));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: Without positions, the generated footprint is a single quad. The bounds path subdivides with getSubdivisionStep() when the view sets resolution. So in a GlobeView the same image loses that curvature when it moves to imageMapping. I found this by reading the code and did not render it.

Should the footprint use the layer's subdivision step when meshMaxEdgeLength is omitted?

/** Generates UVs instead of accepting explicit texCoords. */
imageMapping?: BitmapImageMapping | null;
/** Positive maximum source-space edge length; omitted preserves input segmentation. */
meshMaxEdgeLength?: number | null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: meshMaxEdgeLength works only with imageMapping. Set alone, it switches the layer into mesh mode and raises an error. Could it be imageMapping.maxEdgeLength?

},
{
transform: position => position,
tolerance: 1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: With the identity transform, math.gl's error probes always pass. refinement: 'source-edge' is in 5.0.0-beta.4 and states the intent without the probes. Could this call use it?


### Generated texture coordinates

Set `imageMapping` to generate texture coordinates from CPU-readable Arrow positions:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: Could this open with when to use it?

Suggested change
Set `imageMapping` to generate texture coordinates from CPU-readable Arrow positions:
Use `imageMapping` when the image and the mesh use different projections, such as a Web Mercator
image draped on a longitude/latitude mesh. The layer computes `texCoords` for each vertex:

Comment on lines +318 to +344
`imageMapping.bounds` is an axis-aligned rectangle in `imageCRS`. Both CRS values
are explicit math.gl CRS definitions. Positions are interpreted in `positionCRS`
before the layer's model matrix or rendering projection. The viewport does not
select the image projection. Set the layer's `coordinateSystem` consistently with
`positionCRS`; the mapping affects UVs, not the layer's position interpretation.
Without positions, the layer generates a footprint
from its ordinary `bounds` prop. Explicit `texCoords` and `imageMapping` are mutually
exclusive; `_imageCoordinateSystem` does not replace generated UVs.

`meshMaxEdgeLength` must be finite and positive and requires `imageMapping`.
It subdivides source-space triangles using math.gl's bounded refinement helper.
Omitting it preserves the supplied segmentation and winding. Distinct seam vertices
are not welded. The limit is a source-space edge length, not a screen-space error
bound; for geographic positions use degrees, and avoid mixing elevation units in
refinement inputs. Tiny limits may exceed math.gl's refinement or allocation bounds
and report an error.

The layer transforms each resulting vertex into the image CRS, then calculates
`u = (x - left) / (right - left)` and `v = (top - y) / (top - bottom)`.
Out-of-range UVs retain the configured texture sampler's behavior. Split projection
seams and clip invalid projection domains before supplying a mesh. Nonfinite
positions or projection results and degenerate image bounds are rejected.

Generation requires CPU-readable Arrow columns. GPUVector inputs currently expose
no supported CPU-value interface and must supply complete positions and UVs instead;
the layer never performs implicit GPU readback. Manual GPU meshes remain supported
without `imageMapping`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: These paragraphs mix the rules users act on with internals: math.gl's refinement helper, the UV formula, seam welding and GPU readback, which the #11030 section also states. Could they read as below? imageMapping and meshMaxEdgeLength would also need #### entries, like the ones asked for on #11030.

Suggested change
`imageMapping.bounds` is an axis-aligned rectangle in `imageCRS`. Both CRS values
are explicit math.gl CRS definitions. Positions are interpreted in `positionCRS`
before the layer's model matrix or rendering projection. The viewport does not
select the image projection. Set the layer's `coordinateSystem` consistently with
`positionCRS`; the mapping affects UVs, not the layer's position interpretation.
Without positions, the layer generates a footprint
from its ordinary `bounds` prop. Explicit `texCoords` and `imageMapping` are mutually
exclusive; `_imageCoordinateSystem` does not replace generated UVs.
`meshMaxEdgeLength` must be finite and positive and requires `imageMapping`.
It subdivides source-space triangles using math.gl's bounded refinement helper.
Omitting it preserves the supplied segmentation and winding. Distinct seam vertices
are not welded. The limit is a source-space edge length, not a screen-space error
bound; for geographic positions use degrees, and avoid mixing elevation units in
refinement inputs. Tiny limits may exceed math.gl's refinement or allocation bounds
and report an error.
The layer transforms each resulting vertex into the image CRS, then calculates
`u = (x - left) / (right - left)` and `v = (top - y) / (top - bottom)`.
Out-of-range UVs retain the configured texture sampler's behavior. Split projection
seams and clip invalid projection domains before supplying a mesh. Nonfinite
positions or projection results and degenerate image bounds are rejected.
Generation requires CPU-readable Arrow columns. GPUVector inputs currently expose
no supported CPU-value interface and must supply complete positions and UVs instead;
the layer never performs implicit GPU readback. Manual GPU meshes remain supported
without `imageMapping`.
`imageMapping.bounds` is the image rectangle `[left, bottom, right, top]` in `imageCRS`. `positionCRS`
is the CRS of `positions`; keep the layer's [`coordinateSystem`](../core/layer.md#coordinatesystem)
consistent with it. Both take math.gl CRS definitions such as PROJ strings (see
[Coordinate Systems](../../developer-guide/coordinate-systems.md#positions)). Without `positions`,
the layer maps the quad from [`bounds`](#bounds).
`meshMaxEdgeLength` splits triangles until no edge is longer than this length, in `positions` units
(degrees for longitude/latitude). Use it when a coarse mesh visibly bends the image. Edge length
includes z, so XYZ positions should use one unit on all axes. Without it, the supplied triangles are
kept as they are.
Parts of the mesh outside `imageMapping.bounds` sample the image as set by [`textureParameters`](#textureparameters).
#### Limitations
- `imageMapping` replaces `texCoords` and `_imageCoordinateSystem`. Setting `texCoords` too is an error.
- Positions must be CPU-readable Arrow columns. For GPUVector positions, supply `texCoords` instead.
- Split meshes at projection seams and clip them to the image CRS's valid area first. Vertices that don't project are an error.
- Very small `meshMaxEdgeLength` values can exceed refinement limits and raise an error.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The typed meshes section above this needs to be worked into the API reference as well.

@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.


import {subdivideTriangleMesh} from '@math.gl/geometry';
import {projectionEngine} from '@math.gl/geospatial/projection';
import type {ReadonlyCRSDefinition} from '@math.gl/geospatial';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing geospatial package dependency

Medium Severity

createMappedBitmapMesh imports projectionEngine from @math.gl/geospatial, but @deck.gl/layers does not declare that package. The mapping module is loaded whenever imageMapping is set, so consumers with strict dependency isolation can fail at import time even though @deck.gl/core already depends on geospatial.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f388434. 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.

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

There are 2 total unresolved issues (including 1 from previous review).

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 c1b8fa5. Configure here.

props.texCoords !== oldProps.texCoords ||
!deepEqual(props.imageMapping, oldProps.imageMapping, -1) ||
props.meshMaxEdgeLength !== oldProps.meshMaxEdgeLength ||
(Boolean(props.imageMapping) && !props.positions && props.bounds !== oldProps.bounds) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Inline bounds rebuilds mapped mesh

Medium Severity

updateMeshState still uses reference equality for bounds when imageMapping is set without positions. A new bounds array with the same values, common for inline literals on pan or re-render, rebuilds the projected mesh, flips isLoaded, and reuploads GPU buffers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c1b8fa5. Configure here.

@chrisgervang chrisgervang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed b299c2e; recommend approval. imageMapping compares deeply, bounds footprints use the view's subdivision step, refinement uses source-edge, and @math.gl/geospatial is declared. Still open: a positionCRS default and imageMapping.maxEdgeLength.

Labels: P1 settle before this is merged, P2 can follow, Non-blocking optional.

(`[left, bottom, right, top]` in the image CRS), `imageCRS`, and `positionCRS`.
Both CRS fields accept math.gl CRS definitions such as PROJ strings. Keep `positionCRS`
consistent with the layer's `coordinateSystem`. Without `positions`, the layer uses its
`bounds` footprint and the view's subdivision step.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: The rewrite dropped the imageMapping example, so the page has none for the mesh props. CRS strings and imageMapping.bounds units are hard to guess from prose. Could the earlier example come back here?

Suggested change
`bounds` footprint and the view's subdivision step.
`bounds` footprint and the view's subdivision step.
```ts
new BitmapLayer({
image,
positions, // Arrow XY or XYZ column in positionCRS
imageMapping: {
bounds: [left, bottom, right, top], // in imageCRS units
positionCRS: '+proj=longlat +datum=WGS84',
imageCRS: '+proj=merc +a=6378137 +b=6378137 +units=m'
}
});
```


* Replaces `texCoords` and `_imageCoordinateSystem`. Setting `texCoords` too raises an error.
* Positions must be CPU-readable Arrow columns. GPU-only meshes require explicit `texCoords`.
* Split projection seams and clip to the image CRS's valid area before supplying the mesh.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: A positive _projectionTolerance with imageMapping now raises an error, but only the #11030 mesh Remarks say so. Could it be listed here too?

Suggested change
* Split projection seams and clip to the image CRS's valid area before supplying the mesh.
* Split projection seams and clip to the image CRS's valid area before supplying the mesh.
* Cannot be combined with a positive `_projectionTolerance`.

@greg-om greg-om mentioned this pull request Nov 11, 2026
33 of 41 tasks

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