Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 78 additions & 30 deletions Examples/Interactive/plot_voxel_grain_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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,
)
Expand Down Expand Up @@ -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)
Expand All @@ -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")


Expand Down
34 changes: 30 additions & 4 deletions anyplotlib/figure_esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -2166,7 +2167,10 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {

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();
Expand Down Expand Up @@ -2372,8 +2376,9 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
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;

Expand Down Expand Up @@ -2467,6 +2472,7 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
// 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);
}

Expand All @@ -2488,7 +2494,13 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
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);
Expand All @@ -2497,7 +2509,21 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
} 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);
}
}
Expand Down
23 changes: 16 additions & 7 deletions anyplotlib/plot3d/_plot3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"plot_eels_explorer.py",
"plot_threshold_explorer.py",
"plot_spectra_roi_inspector.py",
"plot_voxel_grain_explorer.py",
]


Expand Down
70 changes: 70 additions & 0 deletions anyplotlib/tests/test_plot3d/test_gpu_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Loading
Loading