Skip to content

feat(plot3d): set_texture — wrap an image around a surface, with a WebGPU path - #48

Merged
CSSFrancis merged 5 commits into
mainfrom
feat/3d-colormap-surface
Jul 31, 2026
Merged

feat(plot3d): set_texture — wrap an image around a surface, with a WebGPU path#48
CSSFrancis merged 5 commits into
mainfrom
feat/3d-colormap-surface

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Wraps an image around a 3-D surface — globes, planets, star charts — with a WebGPU path for large meshes, plus the two 3-D rendering bugs the work uncovered.

The BrushWidget commit that was also on this branch is already on main via #47, so what is left here is the texture work, the celestial-globe gallery example, and a round of review fixes.


Plot3D.set_texture()

Each triangle is genuinely texture-mapped, so the picture follows the geometry as you orbit. The default mapping is parametric — image left edge to the grid's first column, top row to its first row — so an equirectangular image lands on a sphere built with longitude along the columns and latitude down the rows.

colormapped vs textured vs shaded

Left → right: the same sphere as a colormapped Z surface, with set_texture(sky), and with shade=True. Axes.plot_surface gained texture=, bounds= and gpu= to match.

Rendering goes through WebGPU above ~2k triangles (~9k triangles: 54 ms/frame on Canvas2D vs 0.4 ms on the GPU; 160k: 1.75 s vs 2.4 ms), falling back silently when WebGPU is unavailable. texture_alpha < 1 deliberately stays on Canvas2D — the overlapping-triangle composite that makes a translucent skin look right has no cheap depth-buffer equivalent.

Two things on the Canvas2D path are easy to get wrong and are documented at the call site: triangles are grown along their edges (a capped miter, not a centroid push) so neighbours overlap instead of leaving a mesh of background hairlines; and shade fills the clip black and draws the texture at globalAlpha = bright rather than darkening with a second translucent pass, which would double-darken every overlap.

set_axis_off() now also hides a 3-D panel's axis lines, labels and ticks — a textured globe reads badly with three dashed lines through it.


Bug: 3-D orbit drags went the wrong way

orbit direction

Centre: the starting view. Dragging right used to give the left frame — the sky sweeps away from the cursor, as if you had grabbed the far side of the sphere. It now gives the right frame.

Azimuth and elevation position the camera, so adding the drag delta swept the surface the opposite way. Both signs are inverted, which lands exactly on matplotlib's mplot3d convention (dazim = -(dx/w)·180, delev = -(dy/h)·180 in y-up display coords).

This is filed as an api_change, a new towncrier type that leads the release notes. It is a fix, but what a reader upgrading needs to know is that every 3-D drag now goes the other way, and that is easy to miss under "Bug Fixes".


Bug: WebGPU depth was inverted, then out of range

Two separate defects in the same matrix row.

Sign. clip.z decreased with depth while every depth-tested pipeline compares with less against a 1.0 clear, so a GPU-rendered scatter3d cloud drew its far points on top of its near ones and a textured surface rendered inside-out. It went unnoticed because voxels — the only other GPU consumer at the time — disable depth writes entirely.

Range. norm() puts geometry in a box reaching [-1, 1] on its longest axis, so |r1·n| reaches √3 at a corner. At the scale factor of 0.35 that put clip.z at 1.089 for the default camera, outside the [0, 1] WebGPU keeps — and WebGPU has no depth clamp without depth-clip-control. The bound is 0.5/√3 = 0.2887; the constant is now 0.28, with depth24plus precision to spare.

gpu depth range

A 26³ lattice at the default camera. Left 0.35, centre 0.28, right the difference between them. 65 of 17,576 points — the two depth-extreme corners — were being dropped. They project near the centre of the hexagonal silhouette, which is why this is so easy to miss without the difference panel.


Review fixes

Found reviewing the branch before opening this PR.

set_data() silently discarded flip_v. Rebuilding an auto UV mapping passed flip_v=False unconditionally, so a surface textured with flip_v=True turned upside-down on its first data update — exactly what a streaming globe hits.

flip_v

Left: straight after set_texture(sky, flip_v=True). Centre: after set_data(), before the fix. Right: after set_data(), fixed. Left and right are pixel-identical (max channel delta 0); left vs centre differs by 250.

pointer_up resolved its widget by index. _brushCommit resolves by ID because a model echo can replace p.state mid-drag; the emit on the next line did not, and would ship the wrong widget's dict in precisely that case.

A brush claimed Shift-drags that started outside the image. _brushPaintAt drops out-of-image points, so a gesture begun in the axis margin took the drag and then painted nothing — a dead zone that also swallowed the pan.

set_texture(alpha=) was unvalidated, unlike BrushWidget, which raises outside [0, 1].

Files that were never committed. test_interactive_examples.py already listed plot_star_globe_explorer.py, so a clean checkout failed that smoke test — the example only ever existed in a working tree. Same for 1,100 lines of tests and four changelog fragments. Scratch files (esm.diff, docs/_dummy/, a throwaway notebook) are gitignored rather than committed.

FIGURE_ESM.md anchors. Every anchor from draw3d down had drifted 390–590 lines when the texture commit inserted above them, and the 2-D function table was over 1,000 out. Both tables re-verified against all 9,285 lines; AGENTS.md now says to re-check them in the same commit that moves them.


Tests

+486 lines across four new files: the encode path, UV mapping and validation, set_data rebuild semantics, geometry-channel transport, Canvas2D render (including a seam check), the WebGPU activation matrix, depth ordering, orbit direction, and the star-globe example's linking maths.

Full suite green locally — 2,058 passed, 58 skipped, including the WebGPU tests against a real adapter.

Notes for the reviewer

  • Plot3D.set_title() stores the title but the renderer has no 3-D title draw site, so it is a silent no-op. Pre-existing and left alone, but it means the gallery example's set_title does nothing.
  • Screenshots above are hosted on the orphan branch assets/pr-3d-surface-textures, which carries no code history. Delete it once this is merged.

Track the files that were left untracked, and fix the defects the branch
review turned up.

Untracked work now committed. test_interactive_examples.py already listed
plot_star_globe_explorer.py in SCRIPTS, so a clean checkout failed that
smoke test — the example was only ever in a working tree. Same for 1,100
lines of tests covering textures, GPU depth, orbit direction and the star
globe linking, and for four changelog fragments. esm.diff, docs/_dummy/
and Examples/testing.ipynb are scratch, so they are gitignored instead.

set_data() no longer discards flip_v. Rebuilding an auto UV mapping passed
flip_v=False unconditionally, so a surface textured with flip_v=True turned
upside-down on its first data update — the exact case a streaming globe
hits. The flip is now remembered alongside _texture_uv_auto.

WebGPU depth scale 0.35 -> 0.28. norm() puts geometry in a box reaching
[-1,1] on its longest axis, so |r1*n| reaches sqrt(3) at a corner and
clip.z peaked at 1.089 — outside the [0,1] WebGPU keeps. The near corner
of a cube-shaped scatter cloud was silently clipped at the default camera.
0.5/sqrt(3) is the bound; 0.28 sits under it with depth24plus precision to
spare. The comment claiming the old range was safe is gone.

pointer_up resolves its widget by ID, not by the drag's original index.
_brushCommit already did this because a model echo can replace p.state
mid-drag; the emit on the next line did not, and would ship the wrong
widget's dict in precisely that case.

A brush no longer claims a Shift-drag that starts outside the image.
_brushPaintAt drops out-of-image points, so a gesture begun in the axis
margin took the drag and then painted nothing — a dead zone that also
swallowed the pan.

set_texture(alpha=) is validated to [0, 1], matching BrushWidget.

Orbit reversal is filed as an api_change, a new towncrier type that leads
the release notes. It is a fix, but what a reader upgrading needs to know
is that every 3-D drag now goes the other way, and that is easy to miss
under "Bug Fixes".

FIGURE_ESM.md line anchors re-verified against all 9,285 lines. Every
anchor from draw3d down had drifted 390-590 lines when the texture commit
inserted above them, and the 2-D function table was over 1,000 out. AGENTS.md
now says to re-check them in the same commit that moves them.
@codecov-commenter

codecov-commenter commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.73913% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.58%. Comparing base (15d091e) to head (2205f0b).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
anyplotlib/_utils.py 94.59% 2 Missing ⚠️
anyplotlib/plot3d/_plot3d.py 98.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #48      +/-   ##
==========================================
+ Coverage   90.43%   90.58%   +0.15%     
==========================================
  Files          39       39              
  Lines        4254     4345      +91     
==========================================
+ Hits         3847     3936      +89     
- Misses        407      409       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`test_shade_lights_the_sphere_from_the_upper_left` blew up on macOS CI with
"zero-size array to reduction operation maximum": the `drawn` mask was empty.

The mask was `arr[..., :3].sum(2) < 600` — "darker than the lightest
background".  The shading tests wrap the sphere in a flat (200, 200, 200)
texture, and 200 x 3 is exactly 600, so a strict `<` classifies every
interior pixel as background.  It only ever "passed" by accident: on
Windows and Ubuntu a handful of antialiased 199-greys land at 597 and the
test limped along on ~1400 of the disc's ~28000 pixels (which is also why
its unshaded halves-match assertion was meaningless there — it compared
199.0 against 199.0).  macOS rounds those stragglers to 200 as well, so
nothing at all came back and the reduction had nothing to work on.

Replace the magic sum with `_drawn_mask`, which asks the actual question:
is this pixel one of the two flat colours the figure paints behind the
sphere?  Those colours are read out of the render itself — the projected
sphere's radius is ~0.32 x the panel size, so anything covering a
meaningful share of the image beyond 0.45 is background by construction,
and the 5 % share cut drops sparse decorations (axis lines, tick labels)
that sit out there when axes are on.

Shared by `_disc` and both shading tests, so there is one definition of
"the sphere" in the file.  Measured against every render scenario the file
has, the new mask agrees with the old one to within 1.5 % of the disc
(seam-artifact fraction is identical at 0.0015) except in the broken case,
where it recovers all 28169 pixels instead of 1442.
Five WebGPU surface tests failed on macOS CI reading `_gpu == 'pending'` —
the renderer's in-flight state while `_gpuDevice()` resolves.  macOS is the
only runner where these tests do not skip: the `_pw_gpu_browser` capability
probe finds no usable adapter on GitHub's ubuntu and windows images, so
"Ubuntu and Windows CI pass" said nothing about this path.

The harness slept a fixed 1200 ms.  A textured surface needs TWO sequential
async hops before it reaches its final rendering, and each schedules its own
redraw: the `<img>` decode (because `_gpuWanted` refuses the GPU for a
surface until there is something to sample), then WebGPU device + pipeline
init.  Timed here on a fast box with a real adapter, the decode lands at
~315 ms and activation only at ~950 ms — a fixed 1200 ms had ~20 % headroom
on the machine the test was written on.  A macOS runner exceeding that is a
race lost, not a renderer defect; the same runner passes the scatter test in
test_gpu_depth, which starts its device request at mount and so skips the
decode hop entirely.

Wait for the condition instead: every 3-D panel has decoded its declared
texture, is not `'pending'`, and (if `'active'`) has completed its
activation redraw.  On timeout, raise with draw3d's own `__apl_gpu3d`
diagnostic, which distinguishes "no adapter" from "texture still decoding"
from "panel had zero size" — and thread that same diagnostic into every GPU
assertion message, so a CI-only failure explains itself instead of just
printing `assert False`.

`wait_3d_settled` / `gpu3d_diag` live in tests/conftest.py next to the
WebGPU probe.  test_gpu_depth gets the same treatment: it passes today, but
by headroom rather than by construction.

Also corrects the `render_png` docstring — newer Playwright headless shells
do expose `navigator.gpu`; what keeps that fixture on Canvas2D is
`requestAdapter()` returning null, which latches `_gpu = 'unavailable'`.
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.

2 participants