feat(plot3d): set_texture — wrap an image around a surface, with a WebGPU path - #48
Merged
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
`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'`.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
BrushWidgetcommit that was also on this branch is already onmainvia #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.
Left → right: the same sphere as a colormapped Z surface, with
set_texture(sky), and withshade=True.Axes.plot_surfacegainedtexture=,bounds=andgpu=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 < 1deliberately 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
shadefills the clip black and draws the texture atglobalAlpha = brightrather 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
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
mplot3dconvention (dazim = -(dx/w)·180,delev = -(dy/h)·180in 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.zdecreased with depth while every depth-tested pipeline compares withlessagainst a 1.0 clear, so a GPU-renderedscatter3dcloud 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√3at a corner. At the scale factor of0.35that putclip.zat 1.089 for the default camera, outside the[0, 1]WebGPU keeps — and WebGPU has no depth clamp withoutdepth-clip-control. The bound is0.5/√3 = 0.2887; the constant is now0.28, withdepth24plusprecision to spare.A 26³ lattice at the default camera. Left
0.35, centre0.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 discardedflip_v. Rebuilding an auto UV mapping passedflip_v=Falseunconditionally, so a surface textured withflip_v=Trueturned upside-down on its first data update — exactly what a streaming globe hits.Left: straight after
set_texture(sky, flip_v=True). Centre: afterset_data(), before the fix. Right: afterset_data(), fixed. Left and right are pixel-identical (max channel delta 0); left vs centre differs by 250.pointer_upresolved its widget by index._brushCommitresolves by ID because a model echo can replacep.statemid-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.
_brushPaintAtdrops 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, unlikeBrushWidget, which raises outside[0, 1].Files that were never committed.
test_interactive_examples.pyalready listedplot_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.mdanchors. Every anchor fromdraw3ddown 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.mdnow says to re-check them in the same commit that moves them.Tests
+486lines across four new files: the encode path, UV mapping and validation,set_datarebuild 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'sset_titledoes nothing.assets/pr-3d-surface-textures, which carries no code history. Delete it once this is merged.