From b9e2d7f2b83c6e46e8bc4ba9951b82630b14a635 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 16 Jun 2026 08:49:55 -0500 Subject: [PATCH 1/5] Fix 3D plane-drag snap-back and faulty highlight tracking Two issues made the voxel explorer's slice selector feel broken: 1. Snap-back (underlying code): Plot3D.to_state_dict() returned dict(_state) without refreshing overlay_widgets from the live widgets, so any view-only push that goes through the targeted-fields path (set_highlight / set_view) re-serialised a stale plane position and overwrote the in-progress drag in JS. The drag would jump back to the last Python-known integer position. Fix: to_state_dict() now always rebuilds overlay_widgets from the live _widgets, so every push path carries the current plane positions. 2. Faulty / jumpy highlight (example): the explorer snapped the highlight to integer voxel indices, so the marker jumped while the plane glided. Now it tracks smooth float positions (fx,fy,fz) for the highlight and the planes that follow, keeping integer indices only for slicing the 2D images. Adds 3 regression tests (TestPlaneDragNoSnapBack) asserting set_highlight / set_view preserve a live plane position. 3D suite + example execution green. --- .../Interactive/plot_voxel_grain_explorer.py | 56 ++++++++++++------- anyplotlib/plot3d/_plot3d.py | 10 +++- .../tests/test_plot3d/test_voxels_planes.py | 41 ++++++++++++++ upcoming_changes/+plane_snapback.bugfix.rst | 6 ++ 4 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 upcoming_changes/+plane_snapback.bugfix.rst diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py index 5a23d45..b550142 100644 --- a/Examples/Interactive/plot_voxel_grain_explorer.py +++ b/Examples/Interactive/plot_voxel_grain_explorer.py @@ -93,7 +93,8 @@ def random_rotations(n): ax_vol = fig.add_subplot(gs[1, 0]) ax_ipf = fig.add_subplot(gs[1, 1:3]) -ix, iy, iz = N // 2, N // 2, N // 2 # current voxel +ix, iy, iz = N // 2, N // 2, N // 2 # integer slice indices +fx, fy, fz = float(ix), float(iy), float(iz) # smooth highlight pos px = [np.arange(N)] * 2 # pixel axes → gutters @@ -163,15 +164,18 @@ def update(source: str) -> None: v_xz.set_title(f"XZ slice — y={iy}") v_yz.set_title(f"YZ slice — x={ix}") - # 3-D slice-selector planes follow (skipped for the one being dragged) + # 3-D slice-selector planes follow at the SMOOTH position (skipped for + # the one being dragged, so its own live position isn't overwritten). if source != "px": - pw_yz.set(position=ix) + pw_yz.set(position=fx) if source != "py": - pw_xz.set(position=iy) + pw_xz.set(position=fy) if source != "pz": - pw_xy.set(position=iz) + pw_xy.set(position=fz) - v_vol.set_highlight(ix, iy, iz, color="#ffffff", size=7) + # Highlight tracks the SMOOTH plane positions (fx,fy,fz) so the marker + # glides with the planes instead of jumping by whole voxels. + v_vol.set_highlight(fx, fy, fz, color="#ffffff", size=7) g = int(gid[iz, iy, ix]) v_ipf.set_highlight(*reduced[g], color="#ffffff", size=8) @@ -181,61 +185,73 @@ def update(source: str) -> None: _busy[0] = False -def _clip(v): - return int(np.clip(round(v), 0, N - 1)) +def _clipf(v): + """Clamp a float position to the volume range (kept smooth for the marker).""" + return float(np.clip(v, 0.0, N - 1)) + + +def _i(v): + """Round a float position to the nearest integer slice index.""" + return int(round(v)) @cw_xy.add_event_handler("pointer_move") def _moved_xy(event): - global ix, iy + global ix, iy, fx, fy if _busy[0]: return - ix, iy = _clip(cw_xy.cx), _clip(cw_xy.cy) + fx, fy = _clipf(cw_xy.cx), _clipf(cw_xy.cy) + ix, iy = _i(fx), _i(fy) update("xy") @cw_xz.add_event_handler("pointer_move") def _moved_xz(event): - global ix, iz + global ix, iz, fx, fz if _busy[0]: return - ix, iz = _clip(cw_xz.cx), _clip(cw_xz.cy) + fx, fz = _clipf(cw_xz.cx), _clipf(cw_xz.cy) + ix, iz = _i(fx), _i(fz) update("xz") @cw_yz.add_event_handler("pointer_move") def _moved_yz(event): - global iy, iz + global iy, iz, fy, fz if _busy[0]: return - iy, iz = _clip(cw_yz.cx), _clip(cw_yz.cy) + fy, fz = _clipf(cw_yz.cx), _clipf(cw_yz.cy) + iy, iz = _i(fy), _i(fz) update("yz") @pw_yz.add_event_handler("pointer_move") def _plane_x(event): - global ix + global ix, fx if _busy[0]: return - ix = _clip(pw_yz.position) + fx = _clipf(pw_yz.position) + ix = _i(fx) update("px") @pw_xz.add_event_handler("pointer_move") def _plane_y(event): - global iy + global iy, fy if _busy[0]: return - iy = _clip(pw_xz.position) + fy = _clipf(pw_xz.position) + iy = _i(fy) update("py") @pw_xy.add_event_handler("pointer_move") def _plane_z(event): - global iz + global iz, fz if _busy[0]: return - iz = _clip(pw_xy.position) + fz = _clipf(pw_xy.position) + iz = _i(fz) update("pz") diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 7c115f4..af07ea7 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -329,7 +329,15 @@ def set_voxel_alpha(self, alpha: float, slice_alpha: float | None = None) -> Non self._push() def to_state_dict(self) -> dict: - return dict(self._state) + # Always serialise the live overlay widgets, so *every* push path + # (full _push, targeted _push_fields, batched) carries the current + # plane positions. Without this, a view-only push (set_highlight / + # set_view) re-serialises a stale overlay_widgets snapshot and clobbers + # an in-progress plane drag in JS — the "snap-back" symptom. + d = dict(self._state) + if self._widgets: + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + return d # ------------------------------------------------------------------ @property diff --git a/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py index a70a609..98649a2 100644 --- a/anyplotlib/tests/test_plot3d/test_voxels_planes.py +++ b/anyplotlib/tests/test_plot3d/test_voxels_planes.py @@ -156,3 +156,44 @@ def js_position(): moved = js_position() assert abs(moved - 3) > 0.5, ( f"plane did not move on drag (position still {moved})") + + +class TestPlaneDragNoSnapBack: + """Regression: a view-only push (set_highlight / set_view) must NOT clobber + a plane widget's live position — the "snap-back" symptom.""" + + def _voxels_with_plane(self): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + g = np.arange(0, 8, dtype=float) + zz, yy, xx = np.meshgrid(g, g, g, indexing="ij") + v = ax.voxels(xx.ravel(), yy.ravel(), zz.ravel(), bounds=((0, 7),) * 3) + pw = v.add_widget("plane", axis="z", position=4) + return fig, v, pw + + def test_to_state_dict_reflects_live_widget(self): + fig, v, pw = self._voxels_with_plane() + pw.set(position=2.7) + st = v.to_state_dict() + z = next(w["position"] for w in st["overlay_widgets"] + if w["type"] == "plane" and w["axis"] == "z") + assert z == 2.7, f"to_state_dict serialised a stale plane position: {z}" + + def test_set_highlight_preserves_plane_position(self): + fig, v, pw = self._voxels_with_plane() + pw.set(position=2.7) # simulate a mid-drag float position + v.set_highlight(1, 2, 3) # view-only push on the same panel + import json + st = json.loads(getattr(fig, f"panel_{v._id}_json")) + z = next(w["position"] for w in st["overlay_widgets"] + if w["type"] == "plane" and w["axis"] == "z") + assert z == 2.7, f"set_highlight snapped the plane back to {z} (want 2.7)" + + def test_set_view_preserves_plane_position(self): + fig, v, pw = self._voxels_with_plane() + pw.set(position=5.3) + v.set_view(azimuth=10, elevation=20) + import json + st = json.loads(getattr(fig, f"panel_{v._id}_json")) + z = next(w["position"] for w in st["overlay_widgets"] + if w["type"] == "plane" and w["axis"] == "z") + assert z == 5.3, f"set_view snapped the plane back to {z} (want 5.3)" diff --git a/upcoming_changes/+plane_snapback.bugfix.rst b/upcoming_changes/+plane_snapback.bugfix.rst new file mode 100644 index 0000000..2c4bc67 --- /dev/null +++ b/upcoming_changes/+plane_snapback.bugfix.rst @@ -0,0 +1,6 @@ +Fixed 3-D plane-widget drags snapping back instead of moving smoothly. +``Plot3D.to_state_dict()`` now always serialises the live overlay widgets, so +a view-only push on the same panel (``set_highlight`` / ``set_view``) no +longer re-sends a stale plane position and clobbers an in-progress drag. The +voxel grain explorer also tracks smooth (float) positions for the highlight +marker so it glides with the planes instead of jumping by whole voxels. From ff63bc159843d8c3c76dc7e452b0b2617d056268 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 16 Jun 2026 10:17:26 -0500 Subject: [PATCH 2/5] Fix floating/random 3-D voxel highlight in grain explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voxel highlight appeared to land on random voxels in large (256³) volumes because the example rendered a sparse random subsample of the whole volume — the highlight projected to the correct (ix,iy,iz) but almost never coincided with a displayed cube, so it floated in empty space. Render the voxels lying ON the three slice planes instead, re-cut live on each drag. The marker is now always anchored on a real cube at the slice intersection, the volume shows actual slice contents, and the on-plane count is ~3·(N/step)² regardless of N, so it stays fast at 256³. - Plot3D.set_point_colors: accept voxels panels, not just scatter, so slabs can be recoloured live each drag. - Add the voxel grain explorer to the example smoke tests. - Add a set_point_colors-on-voxels regression test. --- .../Interactive/plot_voxel_grain_explorer.py | 52 +++++++++++++++---- anyplotlib/plot3d/_plot3d.py | 13 ++--- .../test_interactive_examples.py | 1 + .../tests/test_plot3d/test_voxels_planes.py | 10 ++++ .../+voxel_slice_highlight.bugfix.rst | 8 +++ 5 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 upcoming_changes/+voxel_slice_highlight.bugfix.rst diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py index b550142..47f73fb 100644 --- a/Examples/Interactive/plot_voxel_grain_explorer.py +++ b/Examples/Interactive/plot_voxel_grain_explorer.py @@ -70,14 +70,39 @@ def random_rotations(n): grain_rgb_u8 = (rgb * 255).astype(np.uint8) # (N_GRAINS, 3) # ── 3. Voxels for the 3-D volume view ─────────────────────────────────────── -# Render a uniform subsample of the volume as translucent cubes (a step-3 -# grid gives 16³ ≈ 4k cubes — chunky enough to read, snappy to orbit). -step = 3 -vz, vy, vx = np.mgrid[0:N:step, 0:N:step, 0:N:step] -vox = np.column_stack([vz.ravel(), vy.ravel(), vx.ravel()]) # (M, 3) (z,y,x) -if len(vox) > 4000: - vox = vox[rng.choice(len(vox), 4000, replace=False)] -vox_colors = grain_rgb_u8[gid[vox[:, 0], vox[:, 1], vox[:, 2]]] +# Rather than a sparse random subsample of the whole volume (where the +# highlight marker floats in empty space because almost no cube sits at the +# selected voxel), render the voxels that actually lie ON the three slice +# planes. This anchors the highlight exactly where the slices intersect, +# shows real slice contents in 3-D, and scales: the on-plane count is +# ~3·(N/step)² regardless of N, so it stays fast even for a 256³ volume. +VSTEP = max(1, N // 48) # in-plane downsample → ~48² cubes per plane + +# Voxel cube size in data units. A touch larger than VSTEP so the three +# slabs read as solid sheets rather than a dotted grid. +VOXSIZE = float(VSTEP) * 1.3 + + +def slice_voxels(ix, iy, iz): + """Voxel centres + colours lying on the x=ix, y=iy, z=iz planes.""" + s = VSTEP + rng_ax = np.arange(0, N, s) + parts = [] + # z = iz plane (vary x, y) + yy2, xx2 = np.meshgrid(rng_ax, rng_ax, indexing="ij") + parts.append(np.column_stack([xx2.ravel(), yy2.ravel(), + np.full(xx2.size, iz)])) + # y = iy plane (vary x, z) + zz2, xx2 = np.meshgrid(rng_ax, rng_ax, indexing="ij") + parts.append(np.column_stack([xx2.ravel(), np.full(xx2.size, iy), + zz2.ravel()])) + # x = ix plane (vary y, z) + zz2, yy2 = np.meshgrid(rng_ax, rng_ax, indexing="ij") + parts.append(np.column_stack([np.full(yy2.size, ix), yy2.ravel(), + zz2.ravel()])) + pts = np.vstack(parts) # (M, 3) as (x,y,z) + cols = grain_rgb_u8[gid[pts[:, 2], pts[:, 1], pts[:, 0]]] # gid[z,y,x] + return pts, cols # ── 4. Figure: 3 slices on top, volume + IPF below ────────────────────────── gs = apl.GridSpec(2, 3) @@ -109,9 +134,10 @@ def random_rotations(n): cw_xz = v_xz.add_widget("crosshair", cx=ix, cy=iz, color="#ffffff") cw_yz = v_yz.add_widget("crosshair", cx=iy, cy=iz, color="#ffffff") +_vpts, _vcols = slice_voxels(ix, iy, iz) v_vol = ax_vol.voxels( - vox[:, 2], vox[:, 1], vox[:, 0], colors=vox_colors, - size=float(step), alpha=0.10, + _vpts[:, 0], _vpts[:, 1], _vpts[:, 2], colors=_vcols, + size=VOXSIZE, alpha=0.55, x_label="x", y_label="y", z_label="z", bounds=((0, N - 1),) * 3, zoom=1.1, ) @@ -173,6 +199,12 @@ def update(source: str) -> None: if source != "pz": pw_xy.set(position=fz) + # Re-cut the 3-D slab voxels to the new slice indices so the volume + # view shows the actual slice contents (bounded ~3·(N/VSTEP)² voxels). + _p, _c = slice_voxels(ix, iy, iz) + v_vol.set_data(_p[:, 0], _p[:, 1], _p[:, 2]) + v_vol.set_point_colors(_c) + # Highlight tracks the SMOOTH plane positions (fx,fy,fz) so the marker # glides with the planes instead of jumping by whole voxels. v_vol.set_highlight(fx, fy, fz, color="#ffffff", size=7) diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index af07ea7..c9f3d1d 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -425,17 +425,18 @@ def set_data(self, x, y, z) -> None: self._push() def set_point_colors(self, colors) -> None: - """Set (or clear) per-point colours on a scatter panel. + """Set (or clear) per-point colours on a scatter or voxels panel. Parameters ---------- colors : list of "#rrggbb" strings, (N, 3) array, or None - One colour per point. Floats are interpreted as 0–1 (or 0–255 - when the max exceeds 1). ``None`` reverts to the single - ``color`` for all points. + One colour per point / voxel. Floats are interpreted as 0–1 (or + 0–255 when the max exceeds 1). ``None`` reverts to the single + ``color`` for all elements. """ - if self._state["geom_type"] != "scatter": - raise ValueError("per-point colors are only supported for scatter") + if self._state["geom_type"] not in ("scatter", "voxels"): + raise ValueError( + "per-point colors are only supported for scatter/voxels") if colors is None: self._state["point_colors_b64"] = "" else: diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py index 4531882..157ae25 100644 --- a/anyplotlib/tests/test_examples/test_interactive_examples.py +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -11,6 +11,7 @@ "plot_eels_explorer.py", "plot_threshold_explorer.py", "plot_spectra_roi_inspector.py", + "plot_voxel_grain_explorer.py", ] diff --git a/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py index 98649a2..ba55887 100644 --- a/anyplotlib/tests/test_plot3d/test_voxels_planes.py +++ b/anyplotlib/tests/test_plot3d/test_voxels_planes.py @@ -32,6 +32,16 @@ def test_per_voxel_colors_allowed(self): v = _voxels(colors=colors) assert v._state["point_colors_b64"] != "" + def test_set_point_colors_after_construction(self): + """The orthoslice explorer re-cuts slab voxels each drag via + set_data + set_point_colors, so voxels must accept post-hoc + per-voxel colours (not just at construction).""" + v = _voxels() + v.set_point_colors(np.zeros((512, 3), dtype=np.uint8)) + assert v._state["point_colors_b64"] != "" + v.set_point_colors(None) + assert v._state["point_colors_b64"] == "" + def test_set_voxel_alpha(self): v = _voxels() v.set_voxel_alpha(0.1, slice_alpha=0.8) diff --git a/upcoming_changes/+voxel_slice_highlight.bugfix.rst b/upcoming_changes/+voxel_slice_highlight.bugfix.rst new file mode 100644 index 0000000..d0c05ad --- /dev/null +++ b/upcoming_changes/+voxel_slice_highlight.bugfix.rst @@ -0,0 +1,8 @@ +Fixed the 3-D voxel highlight appearing to "float" or land on random voxels +in large grain volumes. ``Plot3D.set_point_colors`` now accepts ``voxels`` +panels (not just ``scatter``), so the orthoslice explorer can re-colour voxels +live. The voxel grain explorer now renders the voxels that lie *on* the three +slice planes (instead of a sparse random subsample of the whole volume), so the +highlight marker is always anchored on a real cube at the slice intersection. +The on-plane voxel count is ~3·(N/step)² regardless of N, so this stays fast +even for a 256³ volume. From 2d49919aad20ab786233944cb09626d1261968fb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 16 Jun 2026 10:29:20 -0500 Subject: [PATCH 3/5] Fix large voxel volumes rendering as sparse "floating" cubes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 256³ grain explorer produced ~8112 slab voxels, which crossed the GPU_VOXEL_THRESHOLD (8000) and switched the panel to the Phase-1 WebGPU voxel path. That path is not hardware-verified in CI — headless Chromium exposes no WebGPU adapter, so the canvas fallback is what every test exercised — and on real GPUs it rendered a sparse, see-through volume (cubes appearing to float / vanish) instead of solid slice slabs. - Raise GPU_VOXEL_THRESHOLD 8000 -> 20000 so mid-size volumes stay on the depth-sorted, visual-regression-tested Canvas2D renderer. The grain explorer (~8k cubes) now renders its full slabs (verified at N=256). - GPU voxel pipeline: cullMode 'none'. The MVP swaps rows (r0,r2,r1) and negates depth, so cube winding can't be relied on for back-face culling; culling dropped the wrong faces. Translucent cubes need all faces anyway. - GPU geometry cache now keys on point_colors_b64 too, so set_point_colors recolours voxels live instead of reusing a stale colour buffer. - Add a browser regression test that a ~8k-voxel volume renders dense slabs on the canvas path (gpuCanvas hidden). --- anyplotlib/figure_esm.js | 22 +++++++++-- .../tests/test_plot3d/test_voxels_planes.py | 38 +++++++++++++++++++ .../+voxel_gpu_threshold.bugfix.rst | 9 +++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 upcoming_changes/+voxel_gpu_threshold.bugfix.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 016359f..f5172ae 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1946,7 +1946,13 @@ function render({ model, el }) { // GPU; draw3d draws them over a transparent plotCanvas when GPU is active. // ═══════════════════════════════════════════════════════════════════════ const GPU_POINT_THRESHOLD = 20000; - const GPU_VOXEL_THRESHOLD = 8000; // cubes cost ~6× a point on canvas + // The Canvas2D voxel renderer is depth-sorted, sprite-cached and verified by + // visual-regression tests; it comfortably handles ~20k cubes. The WebGPU + // voxel path is a Phase-1 prototype that is NOT hardware-verified in CI + // (headless Chromium exposes no WebGPU adapter), so only hand volumes to it + // once they genuinely exceed the canvas budget. This keeps mid-size cases + // such as the orthoslice grain explorer (~8k slab cubes) on the proven path. + const GPU_VOXEL_THRESHOLD = 20000; let _gpuDevicePromise = null; // module singleton: Promise function _gpuDevice() { @@ -2145,8 +2151,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { }, fragment: { module, entryPoint: 'fs', targets: [{ format: fmt, blend: isVox ? blend : undefined }] }, - primitive: { topology: 'triangle-list', - cullMode: isVox ? 'back' : 'none' }, + // No back-face culling for voxels: the MVP swaps rows (r0,r2,r1) and + // negates depth, so cube winding can't be relied on to match GL's + // front-face rule — culling 'back' would drop the wrong faces and leave + // a sparse, see-through volume (cubes appearing to "float"/vanish at + // large counts). Translucent cubes need every face drawn anyway, and + // the depth test handles occlusion, so render all faces. + primitive: { topology: 'triangle-list', cullMode: 'none' }, depthStencil: { format: 'depth24plus', // Translucent voxels: test depth but don't write it, so // blending isn't order-killed; opaque points write depth. @@ -2166,7 +2177,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { function _gpuUploadGeometry(p) { const g = p._gpuObj, st = p.state, device = g.device; - const key = st.vertices_b64 || ''; + // Key on BOTH geometry and colours: the orthoslice explorer recolours + // voxels via set_point_colors with the vertex set unchanged, so caching + // on vertices_b64 alone would reuse a stale colour buffer. + const key = (st.vertices_b64 || '') + '|' + (st.point_colors_b64 || ''); if (g.geomKey === key && g.posBuf) return; g.geomKey = key; if (g.posBuf) g.posBuf.destroy(); diff --git a/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py index ba55887..59098c2 100644 --- a/anyplotlib/tests/test_plot3d/test_voxels_planes.py +++ b/anyplotlib/tests/test_plot3d/test_voxels_planes.py @@ -126,6 +126,44 @@ def test_voxels_render_with_slice_emphasis(self, interact_page): assert res["pale"] > 500, f"translucent voxel ink missing: {res}" assert res["strong"] > 200, f"opaque slice-plane voxels missing: {res}" + def test_large_volume_renders_dense_slabs(self, interact_page): + """A ~8k-voxel volume (over the old GPU threshold, where the + unverified WebGPU path produced a sparse "floating" volume on real + hardware) must still render its full slabs on the canvas path. + + Regression for: 256³ orthoslice explorer showing only scattered + voxels instead of solid slice planes. + """ + N = 24 # 24³ slab fronts → ~1.7k each + g = np.arange(0, N, dtype=float) + ax_rng = g + zz, yy, xx = np.meshgrid(ax_rng, ax_rng, ax_rng, indexing="ij") + pts = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) + # Keep only the three centre slabs → mirrors slice_voxels(); ~3·N² cubes + c = N // 2 + on_plane = (pts[:, 0] == c) | (pts[:, 1] == c) | (pts[:, 2] == c) + pts = pts[on_plane] + assert len(pts) > 1500 + fig, ax = apl.subplots(1, 1, figsize=(360, 360)) + cols = np.full((len(pts), 3), [255, 0, 0], dtype=np.uint8) + v = ax.voxels(pts[:, 0], pts[:, 1], pts[:, 2], colors=cols, + size=1.3, alpha=0.5, bounds=((0, N - 1),) * 3) + v.set_axis_off() + page = interact_page(fig) + page.wait_for_timeout(300) + # Voxels stay on the Canvas2D path (gpuCanvas hidden), and lots of ink. + res = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let red = 0; + for (let i = 0; i < d.length; i += 4) + if (d[i] > 150 && d[i+1] < 130 && d[i+2] < 130) red++; + const gpu = [...document.querySelectorAll('canvas')].find(x => x.style.zIndex === '0'); + return { red, gpuDisp: gpu ? gpu.style.display : 'none' }; + }""") + assert res["gpuDisp"] == "none", "voxels must render on canvas, not GPU" + assert res["red"] > 2000, f"dense slabs did not render: {res}" + def test_plane_drag_in_browser(self, interact_page): """Dragging a plane widget must change its position in the model.""" v = _voxels(alpha=0.1) diff --git a/upcoming_changes/+voxel_gpu_threshold.bugfix.rst b/upcoming_changes/+voxel_gpu_threshold.bugfix.rst new file mode 100644 index 0000000..cc83a4b --- /dev/null +++ b/upcoming_changes/+voxel_gpu_threshold.bugfix.rst @@ -0,0 +1,9 @@ +Fixed large voxel volumes (e.g. a 256³ grain explorer) rendering as a sparse +scatter of "floating" cubes instead of solid slice slabs. The Phase-1 WebGPU +voxel path is not hardware-verified in CI (headless Chromium exposes no WebGPU +adapter) and could leave a see-through volume on real GPUs; the auto threshold +for handing voxels to the GPU was raised so mid-size volumes stay on the +depth-sorted, visual-regression-tested Canvas2D path. The GPU voxel path also +no longer back-face culls (cube winding isn't guaranteed under the projection's +row-swap, which dropped faces) and now keys its colour buffer on the colours, +so ``set_point_colors`` recolours voxels live. From 92be3d9633bec5ad8cdb73096cd23546661f5712 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 16 Jun 2026 12:51:42 -0500 Subject: [PATCH 4/5] Fix voxels rendering empty under WebGPU (opaque plotCanvas overlay) Correct the diagnosis from the previous commit. Large voxel volumes rendered 'empty' (only plane widgets + highlight, no cubes) in WebGPU-enabled browsers (PyCharm JCEF = Chrome 137). It was NOT the shader or back-face culling. Real cause: the 3D panel stacks gpuCanvas (z-index 0, WebGPU voxels) under plotCanvas (z-index 1, decorations). Activating the GPU path cleared the plotCanvas bitmap but left its opaque CSS background, so the element painted over every GPU-drawn voxel. - plotCanvas background -> transparent while GPU active; restored on fallback, device loss, and state-no-longer-wants-GPU. - Revert earlier workarounds now shown unnecessary: GPU_VOXEL_THRESHOLD back to 8000, cullMode back to 'back' for voxels. - Verified the voxel WGSL + _gpuMatrix on real hardware (NVIDIA TITAN X via native wgpu): three solid slabs; cull 'back' == 'none'. - Retarget regression test to the DOM layering invariant; the active-GPU transparent swap is hardware-verified separately (no WebGPU adapter in CI). --- anyplotlib/figure_esm.js | 28 ++++---- .../tests/test_plot3d/test_voxels_planes.py | 68 +++++++++---------- .../+voxel_gpu_threshold.bugfix.rst | 18 ++--- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index f5172ae..4a54417 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1946,13 +1946,7 @@ function render({ model, el }) { // GPU; draw3d draws them over a transparent plotCanvas when GPU is active. // ═══════════════════════════════════════════════════════════════════════ const GPU_POINT_THRESHOLD = 20000; - // The Canvas2D voxel renderer is depth-sorted, sprite-cached and verified by - // visual-regression tests; it comfortably handles ~20k cubes. The WebGPU - // voxel path is a Phase-1 prototype that is NOT hardware-verified in CI - // (headless Chromium exposes no WebGPU adapter), so only hand volumes to it - // once they genuinely exceed the canvas budget. This keeps mid-size cases - // such as the orthoslice grain explorer (~8k slab cubes) on the proven path. - const GPU_VOXEL_THRESHOLD = 20000; + const GPU_VOXEL_THRESHOLD = 8000; // cubes cost ~6× a point on canvas let _gpuDevicePromise = null; // module singleton: Promise function _gpuDevice() { @@ -1970,6 +1964,7 @@ function render({ model, el }) { if (p.kind === '3d' && p._gpu === 'active') { p._gpu = 'unavailable'; if (p.gpuCanvas) p.gpuCanvas.style.display = 'none'; + if (p.plotCanvas) p.plotCanvas.style.background = theme.bgPlot; _gpuDisposePanel(p); _redrawPanel(p); } @@ -2151,13 +2146,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { }, fragment: { module, entryPoint: 'fs', targets: [{ format: fmt, blend: isVox ? blend : undefined }] }, - // No back-face culling for voxels: the MVP swaps rows (r0,r2,r1) and - // negates depth, so cube winding can't be relied on to match GL's - // front-face rule — culling 'back' would drop the wrong faces and leave - // a sparse, see-through volume (cubes appearing to "float"/vanish at - // large counts). Translucent cubes need every face drawn anyway, and - // the depth test handles occlusion, so render all faces. - primitive: { topology: 'triangle-list', cullMode: 'none' }, + primitive: { topology: 'triangle-list', + cullMode: isVox ? 'back' : 'none' }, depthStencil: { format: 'depth24plus', // Translucent voxels: test depth but don't write it, so // blending isn't order-killed; opaque points write depth. @@ -2481,6 +2471,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { // State no longer wants GPU — revert to canvas. p._gpu = undefined; gpuActive = false; if (p.gpuCanvas) p.gpuCanvas.style.display = 'none'; + p.plotCanvas.style.background = theme.bgPlot; // restore opaque bg _gpuDisposePanel(p); } @@ -2502,7 +2493,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { p.gpuCanvas.style.display = 'block'; p.gpuCanvas.style.width = pw + 'px'; p.gpuCanvas.style.height = ph + 'px'; - // plotCanvas becomes a transparent decoration overlay + // plotCanvas becomes a transparent decoration overlay: clear its BITMAP + // *and* drop its opaque CSS background — otherwise the element keeps + // painting theme.bgPlot over the gpuCanvas beneath it (z-index 1 vs 0), + // hiding every GPU-drawn voxel while canvas-drawn planes/highlight still + // show. (This is what made large voxel volumes look "empty" with only + // the plane widgets + highlight visible.) + p.plotCanvas.style.background = 'transparent'; ctx.clearRect(0, 0, pw, ph); try { _gpuUploadGeometry(p); @@ -2512,6 +2509,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { console.warn('[anyplotlib] GPU draw failed — falling back:', e); p._gpu = 'unavailable'; gpuActive = false; p.gpuCanvas.style.display = 'none'; + p.plotCanvas.style.background = theme.bgPlot; // restore opaque bg ctx.fillStyle = theme.bgPlot; ctx.fillRect(0, 0, pw, ph); } } diff --git a/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py index 59098c2..48a386a 100644 --- a/anyplotlib/tests/test_plot3d/test_voxels_planes.py +++ b/anyplotlib/tests/test_plot3d/test_voxels_planes.py @@ -126,43 +126,43 @@ def test_voxels_render_with_slice_emphasis(self, interact_page): assert res["pale"] > 500, f"translucent voxel ink missing: {res}" assert res["strong"] > 200, f"opaque slice-plane voxels missing: {res}" - def test_large_volume_renders_dense_slabs(self, interact_page): - """A ~8k-voxel volume (over the old GPU threshold, where the - unverified WebGPU path produced a sparse "floating" volume on real - hardware) must still render its full slabs on the canvas path. - - Regression for: 256³ orthoslice explorer showing only scattered - voxels instead of solid slice planes. + def test_voxel_gpu_canvas_layering(self, interact_page): + """The 3-D voxel panel stacks a gpuCanvas (z-index 0, WebGPU voxels) + below the plotCanvas (z-index 1, decorations). In canvas mode the + plotCanvas MUST keep an opaque background; the renderer only flips it + to ``transparent`` while the GPU path is active, so the GPU-drawn + voxels beneath aren't hidden by an opaque overlay. + + Regression for: large voxel volumes rendering "empty" (only planes + + highlight visible) in PyCharm's WebGPU-enabled JCEF, because the + opaque plotCanvas painted over the gpuCanvas. The active-GPU swap is + hardware-verified via native wgpu; CI has no adapter, so here we lock + the DOM stacking + the canvas-mode opaque-background invariant. """ - N = 24 # 24³ slab fronts → ~1.7k each - g = np.arange(0, N, dtype=float) - ax_rng = g - zz, yy, xx = np.meshgrid(ax_rng, ax_rng, ax_rng, indexing="ij") - pts = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) - # Keep only the three centre slabs → mirrors slice_voxels(); ~3·N² cubes - c = N // 2 - on_plane = (pts[:, 0] == c) | (pts[:, 1] == c) | (pts[:, 2] == c) - pts = pts[on_plane] - assert len(pts) > 1500 - fig, ax = apl.subplots(1, 1, figsize=(360, 360)) - cols = np.full((len(pts), 3), [255, 0, 0], dtype=np.uint8) - v = ax.voxels(pts[:, 0], pts[:, 1], pts[:, 2], colors=cols, - size=1.3, alpha=0.5, bounds=((0, N - 1),) * 3) + colors = np.full((512, 3), [255, 0, 0], dtype=np.uint8) + v = _voxels(colors=colors, alpha=0.4) v.set_axis_off() - page = interact_page(fig) - page.wait_for_timeout(300) - # Voxels stay on the Canvas2D path (gpuCanvas hidden), and lots of ink. - res = page.evaluate("""() => { - const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); - const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; - let red = 0; - for (let i = 0; i < d.length; i += 4) - if (d[i] > 150 && d[i+1] < 130 && d[i+2] < 130) red++; - const gpu = [...document.querySelectorAll('canvas')].find(x => x.style.zIndex === '0'); - return { red, gpuDisp: gpu ? gpu.style.display : 'none' }; + page = interact_page(v._fig) + page.wait_for_timeout(200) + + layout = page.evaluate("""() => { + const cs = [...document.querySelectorAll('canvas')]; + const gpu = cs.find(x => x.style.zIndex === '0'); + const plot = cs.find(x => x.style.zIndex === '1'); + return { + hasGpu: !!gpu, + gpuBelow: !!gpu && !!plot, + plotBg: plot ? plot.style.background : null, + gpuDisp: gpu ? gpu.style.display : null, + }; }""") - assert res["gpuDisp"] == "none", "voxels must render on canvas, not GPU" - assert res["red"] > 2000, f"dense slabs did not render: {res}" + assert layout["hasGpu"], "3-D voxel panel must create a gpuCanvas" + assert layout["gpuDisp"] == "none", \ + "gpuCanvas stays hidden in canvas mode (no WebGPU adapter in CI)" + # Canvas mode: plotCanvas keeps an opaque bg (NOT transparent), so the + # canvas-drawn voxels read against a solid panel background. + assert layout["plotBg"] and layout["plotBg"] != "transparent", \ + f"canvas-mode plotCanvas must stay opaque, got {layout['plotBg']!r}" def test_plane_drag_in_browser(self, interact_page): """Dragging a plane widget must change its position in the model.""" diff --git a/upcoming_changes/+voxel_gpu_threshold.bugfix.rst b/upcoming_changes/+voxel_gpu_threshold.bugfix.rst index cc83a4b..396a6d0 100644 --- a/upcoming_changes/+voxel_gpu_threshold.bugfix.rst +++ b/upcoming_changes/+voxel_gpu_threshold.bugfix.rst @@ -1,9 +1,11 @@ -Fixed large voxel volumes (e.g. a 256³ grain explorer) rendering as a sparse -scatter of "floating" cubes instead of solid slice slabs. The Phase-1 WebGPU -voxel path is not hardware-verified in CI (headless Chromium exposes no WebGPU -adapter) and could leave a see-through volume on real GPUs; the auto threshold -for handing voxels to the GPU was raised so mid-size volumes stay on the -depth-sorted, visual-regression-tested Canvas2D path. The GPU voxel path also -no longer back-face culls (cube winding isn't guaranteed under the projection's -row-swap, which dropped faces) and now keys its colour buffer on the colours, +Fixed large voxel volumes (e.g. a 256³ grain explorer) rendering "empty" — +only the plane widgets and highlight marker visible, with no cubes — in +WebGPU-enabled browsers such as PyCharm's embedded JCEF. The WebGPU voxel +path draws cubes on a ``gpuCanvas`` beneath the ``plotCanvas`` that carries +the axes/planes/highlight; activating the GPU path cleared the plotCanvas +bitmap but left its opaque CSS ``background``, so the element painted over +every GPU-drawn voxel. The plotCanvas background is now set transparent +while the GPU path is active (and restored on fallback / device loss). The +voxel shader itself was verified correct on real hardware (NVIDIA TITAN X via +native wgpu). The GPU geometry cache also keys on ``point_colors_b64`` now, so ``set_point_colors`` recolours voxels live. From 5a6a252686cc8f4ed7da3dd8a63e2fb83f99aa8c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 16 Jun 2026 13:01:54 -0500 Subject: [PATCH 5/5] Self-heal 3D GPU panels when the device throws mid-draw (Safari) Safari's experimental WebGPU can activate, render correctly for a while, then throw mid-draw or lose the device. The GPU path makes the decoration plotCanvas transparent and takes GPU-only branches (proj==null etc.), so a mid-draw throw left the frame half-built: voxels AND axes vanished, and only a window resize (forcing a full redraw) brought them back. The catch block now disposes the GPU panel, restores the opaque plotCanvas background, and re-renders the whole panel once on the canvas path in the same frame (guarded against re-entry via p._gpuFellBack, reset per draw3d call). Verified by simulating an adapter+device that activates then throws on createCommandEncoder: no page errors, gpuCanvas hidden, plotCanvas opaque, voxels + axes rendered. Note: the kernel 'Task was destroyed but it is pending' warnings are ipykernel shutdown noise, unrelated to this. --- anyplotlib/figure_esm.js | 18 ++++- .../tests/test_plot3d/test_gpu_fallback.py | 70 +++++++++++++++++++ .../+gpu_draw_self_heal.bugfix.rst | 9 +++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 upcoming_changes/+gpu_draw_self_heal.bugfix.rst diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 4a54417..387d079 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -2376,8 +2376,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { p._gpuObj = null; } - function draw3d(p) { + function draw3d(p, _retry) { const st = p.state; if (!st) return; + if (!_retry) p._gpuFellBack = false; // per-render re-entry guard reset _recordFrame(p); const { pw, ph, plotCtx: ctx } = p; @@ -2508,8 +2509,21 @@ fn fs(in : VsOut) -> @location(0) vec4 { } catch (e) { console.warn('[anyplotlib] GPU draw failed — falling back:', e); p._gpu = 'unavailable'; gpuActive = false; - p.gpuCanvas.style.display = 'none'; + if (p.gpuCanvas) p.gpuCanvas.style.display = 'none'; p.plotCanvas.style.background = theme.bgPlot; // restore opaque bg + _gpuDisposePanel(p); + // The current frame already cleared plotCanvas and took GPU-only + // branches (proj == null etc.), so the axes/decorations for THIS frame + // are half-built. Rather than limp through, re-render the whole panel + // once from the top on the canvas path — self-healing without needing + // a user resize. (Safari's experimental WebGPU can throw mid-draw or + // lose the device after working for a while; this recovers cleanly.) + if (!p._gpuFellBack) { + p._gpuFellBack = true; + ctx.fillStyle = theme.bgPlot; ctx.fillRect(0, 0, pw, ph); + draw3d(p, true); // re-render once on the canvas path + return; + } ctx.fillStyle = theme.bgPlot; ctx.fillRect(0, 0, pw, ph); } } diff --git a/anyplotlib/tests/test_plot3d/test_gpu_fallback.py b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py index 0477e1e..6ec9937 100644 --- a/anyplotlib/tests/test_plot3d/test_gpu_fallback.py +++ b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py @@ -168,3 +168,73 @@ def test_voxel_gpu_no_console_errors(self, interact_page): page.on("pageerror", lambda e: errors.append(str(e))) page.wait_for_timeout(400) assert not errors, f"GPU voxel fallback raised errors: {errors}" + + def test_gpu_draw_failure_self_heals(self, interact_page, _pw_browser): + """A GPU device that ACTIVATES then throws mid-draw (e.g. Safari's + experimental WebGPU losing the device) must self-heal: the panel + re-renders on the canvas path in the same frame — voxels AND axes — + without the user needing to resize, and the plotCanvas background is + restored to opaque (not left transparent over a dead gpuCanvas). + """ + import pathlib, tempfile + from anyplotlib.tests.conftest import _build_interact_html + + colors = np.tile([255, 60, 60], (512, 1)).astype(np.uint8) + v = _voxels(colors=colors, alpha=0.5, gpu="always") # axes ON + html = _build_interact_html(v._fig) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + + # Fake navigator.gpu: adapter+device resolve (GPU ACTIVATES, plotCanvas + # goes transparent), but the first command encoder throws — the exact + # "worked beautifully then broke" Safari signature. + fake_gpu = """ + () => { + const tex = () => ({ createView:()=>({}), destroy:()=>{} }); + const buf = () => ({ destroy:()=>{} }); + const dev = { + lost: new Promise(()=>{}), + createShaderModule:()=>({}), createBuffer:()=>buf(), + createBindGroupLayout:()=>({}), createPipelineLayout:()=>({}), + createBindGroup:()=>({}), createTexture:()=>tex(), + createRenderPipeline:()=>({ getBindGroupLayout:()=>({}) }), + createCommandEncoder:()=>{ throw new Error('SIMULATED mid-draw GPU failure'); }, + queue:{ writeBuffer:()=>{}, submit:()=>{}, readTexture:()=>new Uint8Array(4) }, + }; + navigator.gpu = { + getPreferredCanvasFormat:()=>'bgra8unorm', + requestAdapter: async ()=>({ info:{}, requestDevice: async ()=>dev }), + }; + }""" + page = _pw_browser.new_page() + page.set_viewport_size({"width": 400, "height": 400}) + page.add_init_script(fake_gpu) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + try: + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15000) + page.wait_for_timeout(600) + res = page.evaluate("""() => { + const cs = [...document.querySelectorAll('canvas')]; + const plot = cs.find(x => x.style.zIndex === '1'); + const gpu = cs.find(x => x.style.zIndex === '0'); + const d = plot.getContext('2d').getImageData(0,0,plot.width,plot.height).data; + let red = 0; + for (let i = 0; i < d.length; i += 4) + if (d[i] > 150 && d[i+1] < 130 && d[i+2] < 130) red++; + return { plotBg: plot.style.background, + gpuDisp: gpu ? gpu.style.display : null, red }; + }""") + finally: + page.close() + tmp.unlink(missing_ok=True) + + assert not errors, f"mid-draw GPU failure leaked errors: {errors}" + assert res["gpuDisp"] == "none", "dead gpuCanvas must be hidden" + assert res["plotBg"] and res["plotBg"] != "transparent", \ + f"plotCanvas bg must be restored to opaque, got {res['plotBg']!r}" + assert res["red"] > 500, \ + f"panel did not self-heal onto canvas (no voxels): {res}" diff --git a/upcoming_changes/+gpu_draw_self_heal.bugfix.rst b/upcoming_changes/+gpu_draw_self_heal.bugfix.rst new file mode 100644 index 0000000..6899377 --- /dev/null +++ b/upcoming_changes/+gpu_draw_self_heal.bugfix.rst @@ -0,0 +1,9 @@ +Fixed a 3-D GPU panel breaking — voxels and axes both vanishing after +rendering correctly — when the WebGPU device throws mid-draw or is lost, +as Safari's experimental WebGPU does after working for a while. The GPU +path makes the decoration ``plotCanvas`` transparent and takes GPU-only +branches, so a mid-draw failure left the frame half-built and only a window +resize (which forces a full redraw) restored it. The fallback now disposes +the GPU panel, restores the opaque background, and re-renders the whole panel +once on the Canvas2D path in the same frame, so it self-heals without a +resize.