diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py index 5a23d45a..47f73fb9 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) @@ -93,7 +118,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 @@ -108,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, ) @@ -163,15 +190,24 @@ 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) + # 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) g = int(gid[iz, iy, ix]) v_ipf.set_highlight(*reduced[g], color="#ffffff", size=8) @@ -181,61 +217,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/figure_esm.js b/anyplotlib/figure_esm.js index 016359f9..387d0790 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1964,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); } @@ -2166,7 +2167,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(); @@ -2372,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; @@ -2467,6 +2472,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); } @@ -2488,7 +2494,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); @@ -2497,7 +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/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 7c115f46..c9f3d1dd 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 @@ -417,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 4531882b..157ae253 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_gpu_fallback.py b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py index 0477e1e6..6ec99370 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/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py index a70a6093..48a386a3 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) @@ -116,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_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. + """ + 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(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 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.""" v = _voxels(alpha=0.1) @@ -156,3 +204,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/+gpu_draw_self_heal.bugfix.rst b/upcoming_changes/+gpu_draw_self_heal.bugfix.rst new file mode 100644 index 00000000..6899377d --- /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. diff --git a/upcoming_changes/+plane_snapback.bugfix.rst b/upcoming_changes/+plane_snapback.bugfix.rst new file mode 100644 index 00000000..2c4bc676 --- /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. diff --git a/upcoming_changes/+voxel_gpu_threshold.bugfix.rst b/upcoming_changes/+voxel_gpu_threshold.bugfix.rst new file mode 100644 index 00000000..396a6d09 --- /dev/null +++ b/upcoming_changes/+voxel_gpu_threshold.bugfix.rst @@ -0,0 +1,11 @@ +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. diff --git a/upcoming_changes/+voxel_slice_highlight.bugfix.rst b/upcoming_changes/+voxel_slice_highlight.bugfix.rst new file mode 100644 index 00000000..d0c05add --- /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.