diff --git a/.gitignore b/.gitignore index 6d2bcb08..785d9799 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ docs/_static/anywidget_config.js # Git worktrees .worktrees/ + +# Generated by Sphinx-Gallery (anywidget iframe HTML) — never commit +docs/_static/viewer_widgets/ diff --git a/Examples/Interactive/plot_ipf_explorer.py b/Examples/Interactive/plot_ipf_explorer.py new file mode 100644 index 00000000..b3aed178 --- /dev/null +++ b/Examples/Interactive/plot_ipf_explorer.py @@ -0,0 +1,125 @@ +""" +Inverse Pole Figure (IPF) Explorer +================================== + +An EBSD-style orientation explorer for a synthetic polycrystal: + +* **Left panel** — IPF-Z orientation map, colored with the standard cubic + IPF key (red = ⟨001⟩, green = ⟨011⟩, blue = ⟨111⟩). Rendered as a + true-color RGB image. +* **Right panel** — the *reduced 3-D inverse pole figure*: every grain's + sample-Z direction, expressed in crystal coordinates and folded into the + cubic fundamental sector, plotted as an IPF-colored point cloud on a + shaded, wireframed unit sphere. + +Drag the crosshair on the map: the grain's orientation is marked with a +highlighted dot on the sphere, and the sphere **rotates so that direction +faces you**. Drag on the sphere to orbit freely; the next crosshair move +re-aims the camera. +""" + +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(42) + +# ── 1. Synthetic polycrystal: nearest-seed grain map ──────────────────────── +H = W = 192 +N_GRAINS = 60 + +seeds = rng.uniform(0, [H, W], size=(N_GRAINS, 2)) +yy, xx = np.mgrid[0:H, 0:W] +d2 = (yy[..., None] - seeds[:, 0]) ** 2 + (xx[..., None] - seeds[:, 1]) ** 2 +grain_id = np.argmin(d2, axis=-1) # (H, W) labels + + +# ── 2. Random orientation per grain (uniform rotations via quaternions) ───── +def random_rotations(n): + """Uniform random rotation matrices, shape (n, 3, 3) (Shoemake method).""" + u1, u2, u3 = rng.random((3, n)) + q = np.stack([ + np.sqrt(1 - u1) * np.sin(2 * np.pi * u2), + np.sqrt(1 - u1) * np.cos(2 * np.pi * u2), + np.sqrt(u1) * np.sin(2 * np.pi * u3), + np.sqrt(u1) * np.cos(2 * np.pi * u3), + ], axis=1) # (n, 4) unit quats + x, y, z, w = q.T + return np.stack([ + np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], -1), + np.stack([2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], -1), + np.stack([2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], -1), + ], axis=1) + + +rotations = random_rotations(N_GRAINS) + +# Sample-Z expressed in each grain's crystal frame: d = Rᵀ · ẑ +dirs = rotations[:, 2, :] # row 2 of R == Rᵀ·ẑ + +# ── 3. Reduce to the cubic fundamental sector and IPF-color ──────────────── +# For cubic symmetry, sorting |components| ascending lands every direction +# in the standard 001–011–111 stereographic triangle. +reduced = np.sort(np.abs(dirs), axis=1) # (a ≤ b ≤ c) +a, b, c = reduced.T + +# Classic IPF key: distance to each triangle corner → R, G, B +rgb = np.stack([c - b, b - a, a], axis=1) +rgb /= rgb.max(axis=1, keepdims=True) + 1e-12 # vivid normalisation +grain_rgb_u8 = (rgb * 255).astype(np.uint8) # (N_GRAINS, 3) + +ipf_map = grain_rgb_u8[grain_id] # (H, W, 3) true-color + + +# ── 4. Figure: RGB map + reduced 3-D IPF point cloud ─────────────────────── +fig, (ax_map, ax_ipf) = apl.subplots( + 1, 2, figsize=(880, 420), + help="Drag the crosshair: the sphere rotates to face that grain's\n" + "crystal direction. Drag the sphere to orbit freely.") + +vmap = ax_map.imshow(ipf_map) # (H, W, 3) → RGB +vmap.set_title("IPF-Z orientation map") +cross = vmap.add_widget("crosshair", cx=W // 2, cy=H // 2, color="#ffffff") + +# reduced directions live on the unit sphere → fix bounds to keep the +# origin centred and the geometry origin-true +vipf = ax_ipf.scatter3d( + reduced[:, 0], reduced[:, 1], reduced[:, 2], + colors=grain_rgb_u8, point_size=6, + x_label="[100]", y_label="[010]", z_label="[001]", + bounds=((-1, 1),) * 3, zoom=1.4, +) +vipf.set_title("Reduced 3D IPF (cubic fundamental sector)") +# Shaded unit sphere with lat/long wireframe behind the direction vectors +vipf.set_sphere(1.0) + + +# ── 5. Crosshair → highlight + rotate-to-face ─────────────────────────────── +def face_camera(v): + """(azimuth°, elevation°) that aim the camera straight down *v*. + + With the turntable camera, the view faces unit vector ``v`` when + ``el = asin(vz)`` and ``az = atan2(vx, -vy)``. + """ + vx, vy, vz = v + el = np.degrees(np.arcsin(np.clip(vz, -1.0, 1.0))) + az = np.degrees(np.arctan2(vx, -vy)) + return az, el + + +def show_orientation(gid: int) -> None: + v = reduced[gid] + vipf.set_highlight(*v, color="#ffffff", size=8) + az, el = face_camera(v) + vipf.set_view(azimuth=az, elevation=el) + + +@cross.add_event_handler("pointer_move") +def on_move(event): + ix = int(np.clip(round(cross.cx), 0, W - 1)) + iy = int(np.clip(round(cross.cy), 0, H - 1)) + show_orientation(int(grain_id[iy, ix])) + + +show_orientation(int(grain_id[H // 2, W // 2])) + +fig # Interactive diff --git a/Examples/Interactive/plot_segment_by_contrast_advanced.py b/Examples/Interactive/plot_segment_by_contrast_advanced.py new file mode 100644 index 00000000..e54e57a3 --- /dev/null +++ b/Examples/Interactive/plot_segment_by_contrast_advanced.py @@ -0,0 +1,355 @@ +""" +Advanced Interactive Contrast Segmentation (3 × 3 Grid) +========================================================= + +A 3 × 3 grid of synthetic images, each independently segmented by +flood-fill. Pass 8 or 9 images as ``images_flat``; the grid always +has 3 columns and enough rows to fit them all (last cell left blank +when 8 images are supplied). + +**Interaction** + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Action + - Effect + * - **Left-click** + - Add a *positive* seed (green dot) on the clicked panel. + * - **Shift + Left-click** + - Add a *negative* seed (red dot) — subtracts that connected region + from the mask. + * - **Ctrl + Left-click** + - Add a polygon vertex to the *clip polygon* of the active panel. + The mask is restricted to pixels inside the polygon once at least + 3 vertices exist. + * - **Drag polygon vertex** + - Reposition any clip-polygon vertex; mask updates on mouse-up. + * - **Hover + Delete / Backspace** + - Remove the clip vertex or seed nearest to the cursor (≤ 15 px). + * - **+** / **=** + - Increase tolerance (grow regions). + * - **-** + - Decrease tolerance (shrink regions). + * - **c** + - Clear all seeds (keeps clip polygon). + * - **p** + - Clear the clip polygon. + +After interaction, the resulting boolean mask arrays are in ``masks_flat`` +(same order as ``images_flat``). + +.. note:: + Click on a panel first to give it keyboard focus, then use the key + bindings. +""" + +import math +import numpy as np +import anyplotlib as apl + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +N = 192 # image size (pixels per side) for the synthetic demo images +NCOLS = 3 # fixed column count + +rng = np.random.default_rng(42) +xx, yy = np.meshgrid(np.arange(N), np.arange(N)) + + +def _gauss(cx, cy, sigma, amplitude): + return amplitude * np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * sigma ** 2)) + + +def _make_image(seed): + """Synthesise a unique multi-blob test image.""" + r = np.random.default_rng(seed) + blobs = [ + (r.integers(30, N - 30), r.integers(30, N - 30), + r.integers(15, 35), r.uniform(0.5, 1.0)) + for _ in range(5) + ] + img = sum(_gauss(cx, cy, sig, amp) for cx, cy, sig, amp in blobs) + img += 0.06 * r.standard_normal((N, N)) + return (img - img.min()) / (img.max() - img.min()) + + +# ── Images — swap this list for your own (8 or 9 arrays of shape (H, W)) ───── + +images_flat = [_make_image(seed) for seed in range(1, 9)] # 8 images +# images_flat = [_make_image(seed) for seed in range(1, 10)] # uncomment for 9 + +# ── Grid geometry derived from the image list ───────────────────────────────── + +n_images = len(images_flat) +if n_images not in (8, 9): + raise ValueError(f"images_flat must contain 8 or 9 images, got {n_images}") + +NROWS = math.ceil(n_images / NCOLS) # 3 for both 8 and 9 + +# ── BFS flood-fill ──────────────────────────────────────────────────────────── + +def _bfs_region(img, row, col, tol): + H, W = img.shape + seed_val = float(img[row, col]) + visited = np.zeros((H, W), dtype=bool) + visited[row, col] = True + stack = [(row, col)] + while stack: + r, c = stack.pop() + for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): + nr, nc = r + dr, c + dc + if 0 <= nr < H and 0 <= nc < W and not visited[nr, nc]: + if abs(float(img[nr, nc]) - seed_val) <= tol: + visited[nr, nc] = True + stack.append((nr, nc)) + return visited + + +def _compute_mask(img, pos_seeds, neg_seeds, tol, clip_poly): + """Flood-fill union, optionally restricted to a drawn polygon.""" + H, W = img.shape + if not pos_seeds: + return np.zeros((H, W), dtype=bool) + combined = np.zeros((H, W), dtype=bool) + for r, c in pos_seeds: + combined |= _bfs_region(img, r, c, tol) + for r, c in neg_seeds: + combined &= ~_bfs_region(img, r, c, tol) + + if clip_poly and len(clip_poly) >= 3: + # Pure-numpy even-odd ray-casting point-in-polygon + # Polygon vertices are [x, y] = [col, row] in image-pixel space + poly = np.asarray(clip_poly, dtype=float) # (K, 2) as [x, y] + rows = np.arange(H, dtype=float) + cols = np.arange(W, dtype=float) + gc, gr = np.meshgrid(cols, rows) # gc[r,c]=col, gr[r,c]=row + xs = gc.ravel() # x = col index + ys = gr.ravel() # y = row index + inside = np.zeros(H * W, dtype=bool) + n_v = len(poly) + xp, yp = poly[:, 0], poly[:, 1] + for i in range(n_v): + x1, y1 = xp[i], yp[i] + x2, y2 = xp[(i + 1) % n_v], yp[(i + 1) % n_v] + cond = ((y1 > ys) != (y2 > ys)) & ( + xs < (x2 - x1) * (ys - y1) / (y2 - y1 + 1e-12) + x1 + ) + inside ^= cond + combined &= inside.reshape(H, W) + + return combined + + +# ── Per-panel state (flat) ──────────────────────────────────────────────────── + +TOL_STEP = 0.01 +TOL_MIN = 0.005 +TOL_MAX = 0.40 +SEED_RADIUS = 4 +_HIDDEN = [[-9999.0, -9999.0]] +_OFFSCREEN_TRI = [[-9990.0, -9990.0], [-9989.0, -9990.0], [-9989.0, -9989.0]] + +_CMAPS = ["gray", "viridis", "plasma", "inferno", "magma", + "cividis", "hot", "cool", "bone"] + +panel_state = [ + {"pos_seeds": [], "neg_seeds": [], "tolerance": 0.08, "clip_poly": []} + for _ in range(n_images) +] +masks_flat = [np.zeros((N, N), dtype=bool) for _ in range(n_images)] +active_idx = [0] + +# ── Figure ──────────────────────────────────────────────────────────────────── + +fig, axes = apl.subplots( + NROWS, NCOLS, + figsize=(900, 900), + help=( + "Left-click → positive seed (grow)\n" + "Shift + Left-click → negative seed (shrink)\n" + "Ctrl + Left-click → add clip-polygon vertex\n" + "Drag polygon vertex → reposition (mask updates on release)\n" + "Delete / Backspace → remove nearest vertex or seed\n" + "+ / - → tolerance up / down\n" + "c → clear seeds\n" + "p → clear clip polygon" + ), +) + +# Flatten axes to a 1-D list (row-major, matches images_flat) +axes_flat = [axes[r][c] for r in range(NROWS) for c in range(NCOLS)] + +# Build plot objects only for panels that have an image +plots_flat = [] +clip_wids = [] # one PolygonWidget per panel + +for idx in range(n_images): + p = axes_flat[idx].imshow(images_flat[idx]) + p.set_colormap(_CMAPS[idx % len(_CMAPS)]) + + # Seed marker groups + p.add_circles(_HIDDEN, name="pos", + facecolors="#69f0ae", edgecolors="#ffffff", + radius=SEED_RADIUS) + p.add_circles(_HIDDEN, name="neg", + facecolors="#ff5252", edgecolors="#ffffff", + radius=SEED_RADIUS) + + # Preview dots for partial polygon (< 3 vertices — before widget takes over) + p.add_circles(_HIDDEN, name="clip_pts", + facecolors="#ffeb3b", edgecolors="#ffffff", + radius=3) + + # Draggable polygon widget — starts offscreen until ≥ 3 vertices are placed. + # The widget provides per-vertex handles that can be dragged in the browser. + wid = p.add_widget("polygon", color="#ffeb3b", vertices=_OFFSCREEN_TRI) + clip_wids.append(wid) + + plots_flat.append(p) + + +# ── Refresh helper ──────────────────────────────────────────────────────────── + +def _refresh(idx): + """Recompute mask and push all markers + overlay for panel ``idx``.""" + try: + st = panel_state[idx] + p = plots_flat[idx] + img = images_flat[idx] + + masks_flat[idx] = _compute_mask( + img, st["pos_seeds"], st["neg_seeds"], + st["tolerance"], st["clip_poly"], + ) + + # Seed marker dots + pos_off = [(c, r) for r, c in st["pos_seeds"]] or _HIDDEN + neg_off = [(c, r) for r, c in st["neg_seeds"]] or _HIDDEN + p.markers["circles"]["pos"].set(offsets=pos_off) + p.markers["circles"]["neg"].set(offsets=neg_off) + + # Clip polygon widget — show real vertices once we have ≥ 3, else offscreen + clip = st["clip_poly"] + if len(clip) >= 3: + clip_wids[idx].set(vertices=clip) + # Hide the preview dots (widget handles are enough) + p.markers["circles"]["clip_pts"].set(offsets=_HIDDEN) + else: + clip_wids[idx].set(vertices=_OFFSCREEN_TRI) + # Show partial-polygon vertex dots during the building phase + clip_off = [[v[0], v[1]] for v in clip] or _HIDDEN + p.markers["circles"]["clip_pts"].set(offsets=clip_off) + + # Mask overlay + p.set_overlay_mask(masks_flat[idx], color="#00e5ff", alpha=0.38) + + except Exception as exc: + import traceback + print(f"[panel {idx}] _refresh error: {exc}") + traceback.print_exc() + + +# ── Click & key handlers (one closure per panel) ────────────────────────────── + +def _make_handlers(idx): + p = plots_flat[idx] + wid = clip_wids[idx] + img = images_flat[idx] + H, W = img.shape + + # ── Polygon widget: sync vertices → panel_state after any drag ──────────── + @wid.add_event_handler("pointer_up") + def _poly_dragged(event): + active_idx[0] = idx + vs = wid.vertices # widget data is synced from JS before callbacks + if vs is None: + return + # Filter out any accidental off-screen dummy vertices + real = [[float(v[0]), float(v[1])] for v in vs + if abs(float(v[0])) < 9000 and abs(float(v[1])) < 9000] + panel_state[idx]["clip_poly"] = real + _refresh(idx) + + # ── Click: add seed or polygon vertex ───────────────────────────────────── + @p.add_event_handler("pointer_down") + def _on_click(event): + if event.xdata is None or event.ydata is None: + return + active_idx[0] = idx + st = panel_state[idx] + r_px = max(0, min(H - 1, int(round(float(event.ydata))))) + c_px = max(0, min(W - 1, int(round(float(event.xdata))))) + if "ctrl" in event.modifiers: + st["clip_poly"].append([float(c_px), float(r_px)]) + elif "shift" in event.modifiers: + st["neg_seeds"].append((r_px, c_px)) + else: + st["pos_seeds"].append((r_px, c_px)) + _refresh(idx) + + # ── Keys: tolerance, clear, delete-nearest ───────────────────────────────── + @p.add_event_handler("key_down") + def _on_key(event): + active_idx[0] = idx + st = panel_state[idx] + if event.key in ("+", "="): + st["tolerance"] = min(TOL_MAX, round(st["tolerance"] + TOL_STEP, 4)) + _refresh(idx) + elif event.key == "-": + st["tolerance"] = max(TOL_MIN, round(st["tolerance"] - TOL_STEP, 4)) + _refresh(idx) + elif event.key == "c": + st["pos_seeds"].clear() + st["neg_seeds"].clear() + _refresh(idx) + elif event.key == "p": + st["clip_poly"].clear() + _refresh(idx) + elif event.key in ("Delete", "Backspace"): + _delete_nearest(event) + + def _delete_nearest(event): + st = panel_state[idx] + if event.xdata is None or event.ydata is None: + return + cx = float(event.xdata) + cy = float(event.ydata) + HIT2 = 15 ** 2 # hit radius squared (px) + + # Check clip-polygon vertices first (they're on top visually) + best_dist = float("inf") + best_poly_i = -1 + for i, (vx, vy) in enumerate(st["clip_poly"]): + d = (vx - cx) ** 2 + (vy - cy) ** 2 + if d < best_dist: + best_dist = d + best_poly_i = i + + if best_poly_i >= 0 and best_dist <= HIT2: + st["clip_poly"].pop(best_poly_i) + _refresh(idx) + return + + # Otherwise check seeds + best_dist = float("inf") + best_list = None + best_i = -1 + for lst in (st["pos_seeds"], st["neg_seeds"]): + for i, (r, c) in enumerate(lst): + d = (c - cx) ** 2 + (r - cy) ** 2 + if d < best_dist: + best_dist = d + best_list = lst + best_i = i + + if best_list is not None and best_dist <= HIT2: + best_list.pop(best_i) + _refresh(idx) + + +_handlers = [_make_handlers(idx) for idx in range(n_images)] + +fig + diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py new file mode 100644 index 00000000..5a23d45a --- /dev/null +++ b/Examples/Interactive/plot_voxel_grain_explorer.py @@ -0,0 +1,244 @@ +""" +3-D Voxel Grain Explorer +======================== + +An orthoslice viewer for a synthetic 3-D polycrystal (voxel grain map), +in the style of EBSD/tomography volume browsers: + +* **Top row** — the three orthogonal slices (XY, XZ, YZ) through the + current voxel, rendered as true-colour IPF-RGB images. Each carries a + draggable crosshair; the three crosshairs are **linked**: dragging one + moves the slice planes of the other two views. +* **Bottom left** — the grain volume rendered as **translucent shaded + voxels** with three draggable **plane widgets** (the slice selectors in + 3-D). Voxels lying on a selected plane render more opaque, so the + current slices glow inside the volume. Drag a plane along its normal to + re-slice — the 2-D views follow. +* **Bottom right** — the *reduced 3-D inverse pole figure*: the selected + voxel's grain orientation is highlighted on the wireframed unit sphere, + which **rotates to face that crystal direction**. + +Everything is bidirectionally linked: drag a crosshair OR a 3-D plane and +the other views re-cut, the voxel highlight moves, and the IPF re-aims. +Drag empty space on either 3-D panel to orbit it freely. +""" + +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(11) + +# ── 1. Synthetic 3-D polycrystal: nearest-seed voxel grain map ────────────── +N = 48 # volume is N³ voxels, indexed V[z, y, x] +N_GRAINS = 40 + +seeds = rng.uniform(0, N, size=(N_GRAINS, 3)) # (z, y, x) +zz, yy, xx = np.mgrid[0:N, 0:N, 0:N] +gid = np.zeros((N, N, N), dtype=np.int32) +best = np.full((N, N, N), np.inf) +for g, (sz, sy, sx) in enumerate(seeds): + d = (zz - sz) ** 2 + (yy - sy) ** 2 + (xx - sx) ** 2 + closer = d < best + gid[closer] = g + best[closer] = d[closer] + + +# ── 2. Orientations, cubic fundamental-sector reduction, IPF colours ──────── +def random_rotations(n): + """Uniform random rotation matrices, shape (n, 3, 3) (Shoemake method).""" + u1, u2, u3 = rng.random((3, n)) + q = np.stack([ + np.sqrt(1 - u1) * np.sin(2 * np.pi * u2), + np.sqrt(1 - u1) * np.cos(2 * np.pi * u2), + np.sqrt(u1) * np.sin(2 * np.pi * u3), + np.sqrt(u1) * np.cos(2 * np.pi * u3), + ], axis=1) + x, y, z, w = q.T + return np.stack([ + np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], -1), + np.stack([2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], -1), + np.stack([2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], -1), + ], axis=1) + + +rotations = random_rotations(N_GRAINS) +dirs = rotations[:, 2, :] # Rᵀ·ẑ per grain +reduced = np.sort(np.abs(dirs), axis=1) # cubic 001–011–111 +a, b, c = reduced.T +rgb = np.stack([c - b, b - a, a], axis=1) +rgb /= rgb.max(axis=1, keepdims=True) + 1e-12 +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]]] + +# ── 4. Figure: 3 slices on top, volume + IPF below ────────────────────────── +gs = apl.GridSpec(2, 3) +fig = apl.Figure(figsize=(960, 640), + help="Drag a crosshair: the other two slices re-cut, the\n" + "3-D voxel highlight moves, and the IPF sphere rotates\n" + "to the selected grain's crystal direction.\n" + "Drag the 3-D panels to orbit them freely.") + +ax_xy = fig.add_subplot(gs[0, 0]) +ax_xz = fig.add_subplot(gs[0, 1]) +ax_yz = fig.add_subplot(gs[0, 2]) +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 + +px = [np.arange(N)] * 2 # pixel axes → gutters + +v_xy = ax_xy.imshow(grain_rgb_u8[gid[iz]], axes=px, units="vox") +v_xz = ax_xz.imshow(grain_rgb_u8[gid[:, iy, :]], axes=px, units="vox") +v_yz = ax_yz.imshow(grain_rgb_u8[gid[:, :, ix]], axes=px, units="vox") +v_xy.set_xlabel("x"); v_xy.set_ylabel("y") +v_xz.set_xlabel("x"); v_xz.set_ylabel("z") +v_yz.set_xlabel("y"); v_yz.set_ylabel("z") + +cw_xy = v_xy.add_widget("crosshair", cx=ix, cy=iy, color="#ffffff") +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") + +v_vol = ax_vol.voxels( + vox[:, 2], vox[:, 1], vox[:, 0], colors=vox_colors, + size=float(step), alpha=0.10, + x_label="x", y_label="y", z_label="z", + bounds=((0, N - 1),) * 3, zoom=1.1, +) +v_vol.set_title("Grain volume — drag a plane to re-slice") + +# Three draggable slice-selector planes; on-plane voxels render opaque +pw_yz = v_vol.add_widget("plane", axis="x", position=ix, color="#ff5252", alpha=0.18) +pw_xz = v_vol.add_widget("plane", axis="y", position=iy, color="#69f0ae", alpha=0.18) +pw_xy = v_vol.add_widget("plane", axis="z", position=iz, color="#40c4ff", alpha=0.18) + +v_ipf = ax_ipf.scatter3d( + reduced[:, 0], reduced[:, 1], reduced[:, 2], + colors=grain_rgb_u8, point_size=6, + x_label="[100]", y_label="[010]", z_label="[001]", + bounds=((-1, 1),) * 3, zoom=1.4, +) +v_ipf.set_title("Reduced 3D IPF") +v_ipf.set_sphere(1.0) + + +# ── 5. Linked updates ──────────────────────────────────────────────────────── +def face_camera(v): + """Turntable (az°, el°) aiming the camera straight down unit vector v.""" + el = np.degrees(np.arcsin(np.clip(v[2], -1.0, 1.0))) + az = np.degrees(np.arctan2(v[0], -v[1])) + return az, el + + +_busy = [False] # programmatic widget.set() fires callbacks — guard re-entry + + +def update(source: str) -> None: + """Re-cut the other slices, move crosshairs/highlights, re-aim the IPF.""" + _busy[0] = True + try: + # Coalesce every panel mutation below into one push per panel — without + # this, a single crosshair drag fires ~8 full-state pushes across the + # comm boundary, which is the main source of Pyodide lag. + with fig.batch(): + if source != "xy": + v_xy.set_data(grain_rgb_u8[gid[iz]]) + cw_xy.set(cx=ix, cy=iy) + if source != "xz": + v_xz.set_data(grain_rgb_u8[gid[:, iy, :]]) + cw_xz.set(cx=ix, cy=iz) + if source != "yz": + v_yz.set_data(grain_rgb_u8[gid[:, :, ix]]) + cw_yz.set(cx=iy, cy=iz) + v_xy.set_title(f"XY slice — z={iz}") + 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) + if source != "px": + pw_yz.set(position=ix) + if source != "py": + pw_xz.set(position=iy) + if source != "pz": + pw_xy.set(position=iz) + + v_vol.set_highlight(ix, iy, iz, color="#ffffff", size=7) + + g = int(gid[iz, iy, ix]) + v_ipf.set_highlight(*reduced[g], color="#ffffff", size=8) + az, el = face_camera(reduced[g]) + v_ipf.set_view(azimuth=az, elevation=el) + finally: + _busy[0] = False + + +def _clip(v): + return int(np.clip(round(v), 0, N - 1)) + + +@cw_xy.add_event_handler("pointer_move") +def _moved_xy(event): + global ix, iy + if _busy[0]: + return + ix, iy = _clip(cw_xy.cx), _clip(cw_xy.cy) + update("xy") + + +@cw_xz.add_event_handler("pointer_move") +def _moved_xz(event): + global ix, iz + if _busy[0]: + return + ix, iz = _clip(cw_xz.cx), _clip(cw_xz.cy) + update("xz") + + +@cw_yz.add_event_handler("pointer_move") +def _moved_yz(event): + global iy, iz + if _busy[0]: + return + iy, iz = _clip(cw_yz.cx), _clip(cw_yz.cy) + update("yz") + + +@pw_yz.add_event_handler("pointer_move") +def _plane_x(event): + global ix + if _busy[0]: + return + ix = _clip(pw_yz.position) + update("px") + + +@pw_xz.add_event_handler("pointer_move") +def _plane_y(event): + global iy + if _busy[0]: + return + iy = _clip(pw_xz.position) + update("py") + + +@pw_xy.add_event_handler("pointer_move") +def _plane_z(event): + global iz + if _busy[0]: + return + iz = _clip(pw_xy.position) + update("pz") + + +update("none") + +fig # Interactive diff --git a/Examples/PlotTypes/plot_label_formatting.py b/Examples/PlotTypes/plot_label_formatting.py new file mode 100644 index 00000000..e6bbd320 --- /dev/null +++ b/Examples/PlotTypes/plot_label_formatting.py @@ -0,0 +1,42 @@ +""" +Label Sizes and Scientific (TeX) Formatting +=========================================== + +Axis labels, titles, and the colorbar label accept an optional ``fontsize`` +(in CSS pixels) and support a small TeX subset inside ``$...$`` for +scientific notation — superscripts, subscripts, Greek letters, and common +symbols — rendered directly on the canvas with no MathJax dependency: + +* ``$10^{-3}$``, ``$x^2$`` — exponents +* ``$E_F$``, ``$k_{B}T$`` — subscripts +* ``$\\alpha$ … $\\Omega$``, ``\\mu``, ``\\Delta`` — Greek letters +* ``\\times``, ``\\pm``, ``\\AA``, ``\\degree``, ``\\propto``, ``\\partial`` — symbols +* ``$\\mathrm{...}$`` — upright text inside math +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(7) + +fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(880, 380)) + +# ── 2-D panel: diffraction-style image with TeX axis labels ──────────────── +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +q = np.linspace(-2.5, 2.5, 128) +img = ax_img.imshow(data, axes=[q, q], units="") +img.set_title(r"$|F(q)|^2$", fontsize=12) +img.set_xlabel(r"$q_x$ ($\AA^{-1}$)", fontsize=13) +img.set_ylabel(r"$q_y$ ($\AA^{-1}$)", fontsize=13) +img.set_colorbar_visible(True) +img.set_colorbar_label(r"Counts $\times 10^{3}$") + +# ── 1-D panel: spectrum with sized, TeX-formatted labels ─────────────────── +energy = np.linspace(0, 3, 512) +spectrum = np.exp(-((energy - 1.2) / 0.15) ** 2) + 0.05 * rng.random(512) +spec = ax_spec.plot(spectrum, axes=[energy], color="#ff7043") +spec.set_title(r"Plasmon peak near $E_p$", fontsize=12) +spec.set_xlabel(r"$\Delta E$ (eV)", fontsize=12) +spec.set_ylabel(r"Intensity ($10^{-3}$ counts)", fontsize=12) +spec.set_tick_label_size(11) + +fig diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..115f7b00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Carter Francis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/RELEASE_PLAN.md b/RELEASE_PLAN.md new file mode 100644 index 00000000..efa49618 --- /dev/null +++ b/RELEASE_PLAN.md @@ -0,0 +1,104 @@ +# anyplotlib 0.1.0 — Release Plan + +Status as of 2026-06-12: `pyproject.toml` already says `0.1.0`, `CHANGELOG.rst` +already contains a `v0.1.0 (2026-04-12)` section, but **no git tag exists and +nothing is on PyPI** (the name `anyplotlib` is still available). The release +automation (`prepare_release.yml` → tag → `release.yml` OIDC publish) is built +and ready; what remains is mostly housekeeping. + +## Phase 1 — Clean the working tree (blockers) + +- [ ] **Decide on the uncommitted `anywidget_bridge.js` work** (+611 lines: a + HyperSpy/Enthought-traits shim for Pyodide). It is experimental and + unrelated to core plotting — either finish it on a feature branch or + stash it. Don't let it ride into the release commit unreviewed. +- [ ] **Commit or drop `Examples/Interactive/plot_segment_by_contrast_advanced.py`** + (untracked). If kept, it runs in docs CI — verify it executes. +- [ ] **Commit `uv.lock`** (currently untracked). CI uses `uv sync`; a + committed lockfile makes CI and contributor environments reproducible. +- [ ] Commit the audit fixes from this session: `LICENSE`, packaging excludes, + classifier/keywords, colormap-fallback fix, `Plot3D` geometry refactor, + `vw` → `apl` alias standardization, README/AGENTS.md/FIGURE_ESM.md + updates. + +## Phase 2 — Reconcile the changelog and version + +The Prepare Release workflow can only bump *up* from 0.1.0, so for this first +release do the changelog manually: + +- [ ] Fold the three pending `upcoming_changes/` fragments (6, 9, 11) into the + existing `v0.1.0` section of `CHANGELOG.rst` (or run + `uvx towncrier build --version 0.1.0` after deleting the stale section), + update the date, and delete the consumed fragments. +- [ ] Verify `docs/conf.py` `release` string matches `0.1.0`. +- [ ] Verify `docs/_root/switcher.json` has (or will get) a `v0.1.0` entry. + +## Phase 3 — One-time PyPI setup + +- [ ] On pypi.org, add a **pending trusted publisher**: + Owner `CSSFrancis`, repo `anyplotlib`, workflow `release.yml`, + environment `pypi` (matches the `environment:` block in release.yml). +- [ ] Create the `pypi` environment in the GitHub repo settings (release.yml + references it; publishing fails without it). + +## Phase 4 — Pre-tag verification + +- [ ] CI green on `main` (tests.yml matrix: 3.10–3.13 × linux/mac/win, plus + lowest-direct resolution job). +- [ ] `uv build`, then sanity-check the artifacts: + `uvx twine check dist/*` and install the wheel in a fresh venv, + `python -c "import anyplotlib"`. (After this session's packaging fix the + wheel no longer ships `anyplotlib/tests/` and PNG baselines — confirm + it is ~250 KB, not ~890 KB.) +- [ ] Build docs locally (`make html`) and click through the interactive + gallery — the Pyodide bridge loads the wheel built from the release + commit. +- [ ] Smoke-test in a real JupyterLab session: `subplots`, `imshow` + widget + drag, `plot` + vline widget, `bar`, `plot_surface`, inset. + +## Phase 5 — Ship + +```bash +git fetch origin +git tag v0.1.0 origin/main +git push origin v0.1.0 +``` + +This triggers `release.yml` (build → PyPI publish → GitHub Release with +changelog notes) and the docs deploy. Afterwards: + +- [ ] Verify `pip install anyplotlib` works from a clean environment. +- [ ] Verify the GitHub Release notes rendered correctly. +- [ ] Check the versioned docs URL and the root redirect. + +## Post-0.1.0 backlog (quality items from the audit, none blocking) + +1. **Duplicate CI**: `ci.yml` and `tests.yml` both run pytest on every + push/PR (ubuntu + 3.12 overlaps). Move the Codecov upload into the + tests.yml ubuntu/3.12 job and delete `ci.yml`. +2. **Colormap fidelity**: with colorcet installed (a hard dependency), + `"viridis"` silently renders as colorcet `bmy` and `"inferno"` as `kb` + (black→blue) — visually very different from the matplotlib maps users + expect. Consider embedding real 256-entry LUTs for the half-dozen most + common matplotlib names (a few KB) instead of aliasing. +3. **Add a linter/formatter**: no ruff/flake8 config exists. Add `ruff` + (lint + format) to the dev group and CI; the codebase is clean enough + that adoption should be cheap. +4. **Coverage in `addopts`**: `--cov` on every local `pytest` run slows quick + iterations and overwrites `coverage.xml`. Consider moving coverage flags + into the CI invocation only. +5. **Typing**: annotations are partial (`_fig: object`, untyped dicts). + If type-checking is a goal, add `py.typed` + mypy/pyright gradually. +6. **`Axes.imshow` silently drops RGB channels** (`data[:, :, 0]`). Either + render RGB properly or raise with a clear message; silent channel + dropping will surprise matplotlib users. +7. **`figure_esm.js` size** (~4,400 lines, one closure): consider an + esbuild-based bundling step so the JS can live in modules while anywidget + still receives a single `_esm` string. Until then, keep + `FIGURE_ESM.md` regenerated (instructions are in its header). +8. **`Event` dataclass breadth**: plot-type-specific fields (`bar_index`, + `ray`, `line_id`) live on the universal event. Fine at this scale; if + event types grow, consider per-kind payload dataclasses. +9. **Large-scale 3-D rendering (WebGPU)**: scoped in `WEBGPU_PLAN.md` — + phased, demand-gated, canvas fallback contract. Phase 0 (canvas cheats + + `voxels_from_volume` resampling API) is worth shipping independently. diff --git a/WEBGPU_PLAN.md b/WEBGPU_PLAN.md new file mode 100644 index 00000000..44360d0b --- /dev/null +++ b/WEBGPU_PLAN.md @@ -0,0 +1,234 @@ +# WebGPU-on-demand rendering — scoping document + +Status: **Phases 1–2 prototyped & hardware-verified** (2026-06-13). +Instanced points (Phase 1) and voxels (Phase 2) render on the GPU with +canvas fallback; projection + shaders validated on an NVIDIA Pascal GPU via +offscreen-texture readback. Remaining: binary-trait transport for >200k +payloads, the flagged CI smoke job, and Phase 3 (OIT translucency). +Owner: @CSSFrancis +Prerequisite reading: `anyplotlib/FIGURE_ESM.md` (3D drawing, voxels, plane widgets) + +## 1. Goal + +Render **large point clouds and voxel volumes** interactively — targets: + +| Workload | Today (Canvas2D) | Target (WebGPU) | +|---|---|---| +| `scatter3d` points | ~50k usable | **1M @ ≥30fps** | +| `voxels` cubes | ~10k (≤30k after Phase 0) | **500k @ ≥30fps** | +| Plane-drag re-slice | O(N) re-blit | **uniform update, 60fps at any N** | + +…without causing problems: every figure must keep working everywhere it works +today (Jupyter, Pyodide docs, Electron embed, headless CI), with no new JS +dependencies and no behaviour change for users below the GPU threshold. + +## 2. Non-goals + +- **Not** replacing Canvas2D — it remains the universal baseline, the + fallback, the small-N path, and the fully-CI-tested path, forever. +- **No WebGL2** — we go straight to WebGPU; maintaining three paths is worse + than two. (Decided 2026-06: choosing in 2026, not 2023.) +- **No three.js / no bundler** — raw WebGPU API, WGSL shaders as inline + strings in the single-file ESM. +- **No 2D pipeline changes** — images/lines/bars stay Canvas2D. +- **No WebGPU compute in early phases** (see Phase 4). + +## 3. Coverage & the fallback contract + +As of mid-2026: Chromium Win/Mac/Android and Electron ✓ (since 2023/24), +Safari ≥26 ✓ (Sept 2025), Firefox Windows ✓ / macOS recent / Linux rolling +out, Chrome Linux driver-dependent. Weak populations for *our* users: Linux +workstations, remote-desktop/VM sessions (no adapter even in supporting +browsers), older Safari. Estimated 15–25 % of scientific users today. + +**Contract:** WebGPU is a progressive enhancement. `navigator.gpu` present +→ `requestAdapter()` resolves → device created → *then* a panel may switch. +Any failure at any point (including mid-session device loss) lands on the +Canvas2D path silently and permanently for that session. A figure must never +render nothing because GPU was attempted. + +## 4. Architecture + +### 4.1 Activation policy + +- Python: `gpu="auto" | True | False` kwarg on `scatter3d()` / `voxels()` + → state field `gpu_mode`. Default `"auto"`. +- JS (`auto`): attempt WebGPU only when `vertices_count > GPU_THRESHOLD` + (initial: 20 000 — at/below this Canvas2D is already smooth, so the + fallback population loses nothing). `True` forces an attempt at any count + (still falls back); `False` never attempts. + +### 4.2 Device lifecycle (the async-init problem) + +- One **module-level singleton** `_gpuDevicePromise` (adapter + device + requested once per page, on first demand). +- Per-panel state `p._gpu ∈ {undefined, 'pending', 'active', 'unavailable'}`. +- First frame is ALWAYS Canvas2D (render() stays synchronous). When the + device promise resolves, the panel builds its buffers/pipeline, flips to + `'active'`, and redraws; on rejection → `'unavailable'`. +- `device.lost.then(...)`: mark every GPU panel `'unavailable'`, drop GPU + resources, redraw via Canvas2D. Never re-attempt within the session. + +### 4.3 Canvas split — decorations stay 2D + +Add one `gpuCanvas` to the 3D panel stack, *below* `plotCanvas`: + +``` +gpuCanvas (WebGPU) geometry only: instanced points / cubes +plotCanvas (2D ctx) axes, ticks, labels (_drawTex), reference sphere, + plane-widget quads, highlight — unchanged code, + drawn on a now-transparent background +overlayCanvas / markersCanvas / statusBar — unchanged +``` + +This is the key cost-control decision: **all decoration, label, TeX, sphere, +plane-widget, and highlight code is reused verbatim**; only the instanced +geometry moves to the GPU. The camera matrix is shared (same turntable +`_rot3` semantics → one orthographic view-projection matrix uniform). + +### 4.4 Pipelines + +- **Points**: instanced screen-facing quads (point_size px), per-instance + position (f32×3) + colour (unorm8×4). Fragment discards outside the disc. +- **Voxels**: one 36-vertex cube, instanced; per-instance position + colour. + Per-face shading via vertex normals (match the 0.82/0.68/1.0 canvas look). + Depth buffer → **no sorting at all**. +- **Slice emphasis & planes as uniforms**: plane axis/position/count go into + a uniform buffer; the fragment shader computes emphasis + (`|pos[axis] − plane| ≤ size/2`). Plane drags therefore re-render with a + **uniform write only** — no geometry re-upload, no Python round-trip + needed for the visual. +- Wire format already fits: `vertices_b64` (f32) and `point_colors_b64` (u8) + upload to GPUBuffers unchanged. + +### 4.5 Transparency strategy + +- Phase 1–2 GPU mode is **opaque** (depth-tested). For ≥100k elements this + reads *better* than alpha soup; it differs visually from the canvas + translucent look — documented, and `voxel_alpha` still applies on the + canvas path. +- Phase 3 adds weighted-blended OIT (two extra render targets + composite + pass) to restore the translucent-volume aesthetic at scale. Gate: only + build if genuinely needed after using opaque mode in practice. + +### 4.6 Capability feedback → adaptive budgets (Python) + +JS reports the outcome once per panel via the existing state echo: a +`_gpu_active: true|false` field written into the panel state (no new event +type needed). Python exposes `plot.gpu_active`. The resampling helper +(Phase 0) uses it: send full-resolution boundary voxels to GPU clients, +auto-stride to ≤20k for canvas clients. **No client ever receives a payload +it can't render.** + +### 4.7 Payload reality check (often the real bottleneck) + +1M points = 12 MB f32 → ~16 MB as b64-in-JSON through the comm. Phase 2 +includes moving large geometry to **binary traits** (ipywidgets/anywidget +support binary buffers; `_repr_utils._widget_state` already handles `bytes`) +with b64 kept for small payloads and the standalone/Pyodide paths. Without +this, the wire — not the GPU — caps practical sizes around ~200k points. + +## 5. Phases + +### Phase 0 — Canvas cheats + resampling API (no GPU code; do first) +*~2–3 days. Worth shipping regardless of WebGPU.* + +1. Interaction LOD: stride the draw set 2–4× while a drag is active; full + set on release/settle. +2. Analytic back-to-front order for grid voxels (camera octant → lexicographic + traversal; kills the O(n log n) sort). +3. Layered plane-drag cache: bake the translucent base cloud to a bitmap; + redraw only the emphasized slice voxels per drag frame. +4. `Axes.voxels_from_volume(vol, *, max_voxels=15000, mode="boundary"|"stride", + colors=...)` — formalises the explorer example's hand-rolled extraction. + +**Acceptance:** 25–30k voxels orbit smoothly on canvas (bench: orbit ≤35 ms +software); plane drag ≤10 ms at 20k; new benchmarks committed. + +### Phase 1 — GPU infrastructure + instanced points +*~4–5 days. The risk-retiring phase.* + +Device singleton, `gpuCanvas` stack integration, async swap, device-lost +fallback, `gpu_mode`/`_gpu_active` plumbing, instanced point pipeline. + +**Acceptance:** +- 1M points orbit ≥30fps on a real GPU (manual + flagged CI job). +- Kill switch verified: adapter-absent, mid-session device loss, and + `gpu=False` all render identically to today via canvas (automated). +- Embedding `mount()` and the Pyodide docs page work in GPU mode + (verify WebGPU inside the gallery iframes — srcdoc/permission policy). + +### Phase 2 — Instanced voxels + shader slice emphasis + binary traits +*~3–4 days.* + +Cube pipeline, plane uniforms (emphasis in-shader), plane-drag = uniform +update, binary-trait transport for large buffers. + +**Acceptance:** 500k cubes orbit ≥30fps; plane drag 60fps at 500k; voxel +grain explorer runs a 192³-extracted volume (~150k boundary voxels) live. + +### Phase 3 — Translucency (weighted-blended OIT) *(gated)* +*~4–6 days. Only if opaque mode proves insufficient in real use.* + +**Acceptance:** GPU translucent render within visual tolerance of the canvas +look at N ≤ 4k (screenshot comparison), correct at 500k. + +### Phase 4 — Future options *(not scoped)* +GPU compute culling/LOD, surfaces/lines on GPU, picking via ID buffer. + +## 6. Testing & CI strategy + +- **Canvas path keeps 100 % of today's coverage** and remains the default CI + matrix — GPU never reduces existing test fidelity. +- New **flagged headless GPU smoke job** (ubuntu): Chromium with + `--enable-unsafe-webgpu --enable-features=Vulkan` on lavapipe/SwiftShader- + Vulkan; tests `pytest.skip` cleanly when `requestAdapter()` yields null so + the job can never hard-fail on runner GPU availability. +- Fallback tests run in the NORMAL suite (no flags): assert `_gpu_active` + is false and rendering matches canvas baselines when GPU is absent — + this is the path that protects "no problems". +- Benchmarks: `js_gpu_points_1M`, `js_gpu_voxels_500k` added to the existing + hardware-gated baseline framework (recorded on a real-GPU machine). +- Phase 3 parity: SSIM-style screenshot comparison GPU vs canvas at small N. + +## 7. Risks + +| Risk | Severity | Mitigation | +|---|---|---| +| Async init race / blank first paint | High | First frame always canvas; swap on resolve; `'pending'` state | +| CI has no GPU adapter | High | Skip-on-unavailable smoke job; canvas keeps full coverage | +| Device lost mid-session | Med | Permanent per-session fallback; tested by forcing `device.destroy()` | +| Comm payload size (≥200k pts) | High | Phase 2 binary traits; capability-aware resampling caps payloads | +| Opaque-vs-translucent visual surprise | Med | Document; Phase 3 OIT; `gpu=False` escape hatch | +| WebGPU inside docs iframes (permission policy) | Med | Verify in Phase 1 acceptance; fall back if blocked | +| Safari/WGSL implementation quirks | Low-Med | Stick to core WGSL, no extensions; manual Safari pass per phase | +| Two render paths drift apart | Med | Shared camera/constants; parity screenshots; FIGURE_ESM.md section per path | + +## 8. Decision gates + +- **Gate A (after Phase 0):** if resampled canvas + linked slices satisfies + the 512×512×300 workflow in practice, pause here — GPU work is demand- + driven, not speculative. +- **Gate B (after Phase 1):** confirmed-working fallback matrix + real-GPU + point benchmark before any voxel pipeline work. +- **Gate C (before Phase 3):** a concrete use case that opaque mode cannot + serve. + +## 9. API sketch + +```python +# Python +plot = ax.voxels_from_volume(gid_volume, max_voxels=15_000, + mode="boundary", colors=grain_rgb) # Phase 0 +plot = ax.voxels(x, y, z, colors=c, gpu="auto") # Phase 2 +plot.gpu_active # bool, after first render echo +plot = ax.scatter3d(x, y, z, colors=c, gpu=True) # Phase 1 +``` + +```js +// JS internals (figure_esm.js) +_gpuDevice() // module singleton → Promise +p._gpu // 'pending' | 'active' | 'unavailable' +_buildPointPipeline(device, p) / _buildVoxelPipeline(device, p) +_drawGpu3d(p) // geometry; decorations still drawn by draw3d's 2D code +``` diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index e2b01f3c..d4305198 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -215,6 +215,16 @@ triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). `_writeState()` (sets `p._selfWrite`), and the panel-json listener skips self-writes — without this every drag frame paid a second JSON.parse + full redraw. +- **Geometry channel** (perf): plots that declare `_GEOM_KEYS` on the Python + side (Plot2D, Plot3D) split heavy keys (`vertices_b64`, `image_b64`, + `colormap_data`, …) into a second `panel__geom` trait, re-sent only + when their content hash changes; the view trait carries `_geom_rev`. JS + caches the decoded geom (`p._geomCache`/`p._geomRev`) and `_applyGeom` + splices it into the state before every draw, so view-only updates + (highlight, camera, planes, title) never re-parse or re-transmit + geometry. Both the `change:panel__geom` and `change:panel__json` + listeners call `_applyGeom`; the geom trait is loaded before the first + draw. Pairs with `Figure.batch()` push-coalescing on the Python side. - **WebGPU path** (progressive enhancement, additive): scatter points (`_GPU_POINT_WGSL`) and voxels (`_GPU_VOXEL_WGSL`) render instanced on the GPU when available and above threshold (`GPU_POINT_THRESHOLD` 20k / diff --git a/anyplotlib/embed.py b/anyplotlib/embed.py new file mode 100644 index 00000000..6b17d317 --- /dev/null +++ b/anyplotlib/embed.py @@ -0,0 +1,194 @@ +""" +embed.py +======== + +Use anyplotlib figures **outside Jupyter** — in Electron apps, MDI +sub-windows, kiosk dashboards, or any plain web page. No kernel, no +ipywidgets, no anywidget runtime in the page. + +Three levels of integration +--------------------------- + +**1. Static / self-contained (no Python at runtime)** — export a fully +self-contained HTML page (renderer + data inlined) and load it anywhere a +browser engine runs, e.g. an Electron ``BrowserWindow`` or ````:: + + import anyplotlib as apl + fig, ax = apl.subplots(1, 1) + ax.imshow(data) + fig.save_html("plot.html") # win.loadFile('plot.html') + +All client-side interactivity (pan, zoom, widgets, markers) works; Python +callbacks obviously do not. + +**2. JS-driven (your app owns the data)** — ship ``figure_esm.js`` with your +app and mount figures from JavaScript using the exported ``mount()``:: + + import { mount } from './figure_esm.js'; + const handle = mount(container, state, { onEvent: ev => ... }); + handle.setPanelState(panelId, newPanelState); // live updates + handle.resize(w, h); handle.dispose(); + +``state`` is the JSON dict produced by :func:`figure_state` — generate it +once from Python (build time, or a one-shot script) or construct it in JS. +Each ``mount()`` is fully self-contained, so one window can host many +figures (MDI-style) by mounting into separate containers. + +**3. Live Python backend** — run Python alongside your app (sidecar process, +local WebSocket server, …) and keep figures fully interactive with Python +callbacks via :class:`FigureBridge`, which is transport-agnostic:: + + # Python side (e.g. behind a websocket) + bridge = FigureBridge(fig, send=lambda key, value: ws.send( + json.dumps({"key": key, "value": value}))) + ws.on_message = lambda m: bridge.receive(**json.loads(m)) + + // JS side + const handle = mount(el, snapshot, { + onSync: (key, value) => ws.send(JSON.stringify({key, value})), + }); + ws.onmessage = (m) => { const u = JSON.parse(m.data); + handle.applyUpdate(u.key, u.value); }; + +See ``docs/embedding.rst`` for a complete Electron walkthrough. +""" + +from __future__ import annotations + +import pathlib + +from anyplotlib._repr_utils import build_standalone_html, _widget_state + +__all__ = ["figure_state", "to_html", "save_html", "esm_path", "FigureBridge"] + + +def figure_state(fig) -> dict: + """Return the figure's full serialised state as a plain JSON-safe dict. + + The dict contains every synced trait — ``layout_json``, ``fig_width``, + ``fig_height``, ``event_json``, and one ``panel__json`` entry per + panel — and is exactly what the JS ``mount(el, state)`` entry point + expects. + + Parameters + ---------- + fig : Figure + + Returns + ------- + dict + """ + # _widget_state also picks up ipywidgets infrastructure traits (layout, + # tabbable, …) whose values aren't JSON. The renderer only reads scalar + # traits, so keep exactly those. + return {k: v for k, v in _widget_state(fig).items() + if isinstance(v, (str, int, float, bool)) or v is None} + + +def to_html(fig, *, resizable: bool = True) -> str: + """Return a fully self-contained HTML page rendering *fig*. + + The page inlines the renderer and all figure data; it needs no network, + kernel, or Python at view time. Client-side interactivity (pan, zoom, + overlay widgets) is preserved. + + Parameters + ---------- + fig : Figure + resizable : bool, optional + Keep the figure's drag-to-resize handle. Default ``True``. + """ + return build_standalone_html(fig, resizable=resizable) + + +def save_html(fig, path, *, resizable: bool = True) -> pathlib.Path: + """Write :func:`to_html` output to *path* and return it as a ``Path``.""" + p = pathlib.Path(path) + p.write_text(to_html(fig, resizable=resizable), encoding="utf-8") + return p + + +def esm_path() -> pathlib.Path: + """Return the path to ``figure_esm.js`` for bundling into a JS app. + + Copy (or import) this file into your Electron / web build; it exports + ``mount`` and ``createLocalModel`` alongside the anywidget ``render``. + """ + return pathlib.Path(__file__).parent / "figure_esm.js" + + +class FigureBridge: + """Transport-agnostic two-way sync between a live ``Figure`` and a + remote JS view mounted with ``mount(el, state, {onSync})``. + + You supply the pipe (WebSocket, Electron IPC via a sidecar, stdio, …); + the bridge supplies the protocol: plain ``(key, value)`` pairs. + + Parameters + ---------- + fig : Figure + The live figure. All Python-side mutations (``plot.set_data(...)``, + marker/widget updates, layout changes) are forwarded automatically. + send : callable(key: str, value) -> None + Called for every outbound state change. Wire it to your transport. + + Notes + ----- + * **Python → JS**: any synced trait change triggers ``send(key, value)``; + deliver it to ``handle.applyUpdate(key, value)`` in JS. + * **JS → Python**: deliver each JS ``onSync(key, value)`` message to + :meth:`receive`. Interaction events (``event_json``) are dispatched to + the figure's callback registries exactly as in Jupyter, so + ``@plot.add_event_handler(...)`` handlers fire unchanged. + * Echo is suppressed in both directions. + """ + + def __init__(self, fig, send) -> None: + self._fig = fig + self._send = send + self._applying = False + # names=traitlets.All: also covers panel traits added dynamically + # after the bridge is created (Figure.add_traits on new panels). + import traitlets + fig.observe(self._on_trait_change, names=traitlets.All) + + # ── outbound (Python → JS) ──────────────────────────────────────────── + def _on_trait_change(self, change) -> None: + if self._applying: + return + name = change["name"] + trait = self._fig.traits().get(name) + if trait is None or not trait.metadata.get("sync") or name.startswith("_"): + return + self._send(name, change["new"]) + + def snapshot(self) -> dict: + """Full state dict for the initial ``mount()`` on the JS side.""" + return figure_state(self._fig) + + # ── inbound (JS → Python) ───────────────────────────────────────────── + def receive(self, key: str, value) -> None: + """Apply one inbound ``(key, value)`` message from the JS view. + + ``event_json`` messages are dispatched to plot/widget callbacks; + other keys (e.g. a panel's view state after a JS-side 3D rotate) + are stored on the figure without echoing back. + """ + if key == "event_json": + self._fig._dispatch_event(value) + return + if not self._fig.has_trait(key): + return + self._applying = True + try: + setattr(self._fig, key, value) + finally: + self._applying = False + + def close(self) -> None: + """Stop forwarding (unobserve the figure).""" + import traitlets + try: + self._fig.unobserve(self._on_trait_change, names=traitlets.All) + except ValueError: + pass diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 08a2a2b1..fe5c16f6 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -119,6 +119,11 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self._wspace: float | None = None self._batching: bool = False self._batch_dirty: set = set() + # Geometry-channel bookkeeping (per panel id): a monotonic revision + # and the last geometry dict sent, so geometry is re-transmitted only + # when its values genuinely change. + self._geom_rev: dict = {} + self._geom_last: dict = {} with self.hold_trait_notifications(): self.fig_width = figsize[0] self.fig_height = figsize[1] @@ -238,6 +243,14 @@ def _register_panel(self, ax: Axes, plot) -> None: pid = plot._id if not self.has_trait(f"panel_{pid}_json"): self.add_traits(**{f"panel_{pid}_json": traitlets.Unicode("{}").tag(sync=True)}) + # Plots that declare _GEOM_KEYS get a second trait carrying only the + # heavy geometry, re-sent only when that geometry changes. The light + # view trait then references it by revision so JS reuses the cached + # decode across view-only updates (highlight, camera, planes). + if getattr(plot, "_GEOM_KEYS", None) and not self.has_trait(f"panel_{pid}_geom"): + self.add_traits(**{f"panel_{pid}_geom": traitlets.Unicode("{}").tag(sync=True)}) + self._geom_rev[pid] = 0 + self._geom_last[pid] = None self._plots_map[pid] = plot self._axes_map[pid] = ax self._push(pid) @@ -262,7 +275,26 @@ def _push(self, panel_id: str) -> None: tname = f"panel_{panel_id}_json" if not self.has_trait(tname): return - setattr(self, tname, json.dumps(plot.to_state_dict())) + + state = plot.to_state_dict() + geom_keys = getattr(plot, "_GEOM_KEYS", None) + gname = f"panel_{panel_id}_geom" + if geom_keys and self.has_trait(gname): + # Split heavy geometry into its own channel. Detect change by + # comparing the geom values themselves (the b64 strings / LUT + # lists) against the last-sent snapshot — a reference/equality + # check that avoids re-serialising hundreds of KB on every + # view-only frame. Only on a real change do we serialise the + # geom blob, bump the revision, and write the geom trait. + geom = {k: state.pop(k) for k in geom_keys if k in state} + if geom != self._geom_last.get(panel_id): + self._geom_last[panel_id] = geom + self._geom_rev[panel_id] = self._geom_rev.get(panel_id, 0) + 1 + setattr(self, gname, json.dumps(geom, sort_keys=True)) + state["_geom_rev"] = self._geom_rev.get(panel_id, 0) + setattr(self, tname, json.dumps(state)) + else: + setattr(self, tname, json.dumps(state)) @contextlib.contextmanager def batch(self): diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index ee7a46e2..dfaca30d 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -339,6 +339,30 @@ function render({ model, el }) { } } + // Geometry channel: heavy keys (vertices/image/colormap) travel in a + // separate panel__geom trait, re-sent only when they change. The light + // view payload carries _geom_rev; we cache the decoded geom per panel and + // splice it into the state before drawing, so view-only updates (highlight, + // camera, planes) never re-parse or re-transmit geometry. + function _applyGeom(p2, state) { + if (state._geom_rev === undefined) return state; // panel has no geom channel + // Splice the last-decoded geometry into the view state. The geom trait + // is sent before (or with) the first view payload and only re-sent on + // change, so the cache is the authoritative geometry for every frame; + // _geom_rev is carried for diagnostics / future invalidation but we + // always apply the cache when present (never drop geometry on a rev skew). + if (p2._geomCache) Object.assign(state, p2._geomCache); + return state; + } + + // Parse the geom trait into the per-panel cache. + function _loadGeom(p2, raw, rev) { + try { + p2._geomCache = JSON.parse(raw || '{}'); + p2._geomRev = rev; + } catch (_) {} + } + // Factory: returns a debounced commit function. // onCommit is called once per animation frame after the last request. function _makeCommitter(onCommit) { @@ -831,6 +855,20 @@ function render({ model, el }) { _resizePanelDOM(id, pw, ph); _attachPanelEvents(p); + // Geometry channel (only when this panel declared one on the Python side). + const _geomTrait = `panel_${id}_geom`; + const _hasGeom = model.get(_geomTrait) !== undefined; + if (_hasGeom) { + model.on(`change:${_geomTrait}`, () => { + const p2 = panels.get(id); + if (!p2) return; + const rev = (p2.state && p2.state._geom_rev !== undefined) + ? p2.state._geom_rev : ((p2._geomRev || 0) + 1); + _loadGeom(p2, model.get(_geomTrait), rev); + if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } + }); + } + model.on(`change:panel_${id}_json`, () => { const p2 = panels.get(id); if (!p2) return; @@ -841,6 +879,7 @@ function render({ model, el }) { try { const newState = JSON.parse(model.get(`panel_${id}_json`)); _preserveView(p2, newState); + _applyGeom(p2, newState); p2.state = newState; } catch(_) { return; } @@ -848,7 +887,11 @@ function render({ model, el }) { _redrawPanel(p2); }); - try { p.state = JSON.parse(model.get(`panel_${id}_json`)); } catch(_) {} + if (_hasGeom) _loadGeom(p, model.get(_geomTrait), 1); + try { + p.state = JSON.parse(model.get(`panel_${id}_json`)); + _applyGeom(p, p.state); + } catch(_) {} _redrawPanel(p); } @@ -946,6 +989,20 @@ function render({ model, el }) { }); + // Geometry channel (only when this panel declared one on the Python side). + const _geomTrait = `panel_${id}_geom`; + const _hasGeom = model.get(_geomTrait) !== undefined; + if (_hasGeom) { + model.on(`change:${_geomTrait}`, () => { + const p2 = panels.get(id); + if (!p2) return; + const rev = (p2.state && p2.state._geom_rev !== undefined) + ? p2.state._geom_rev : ((p2._geomRev || 0) + 1); + _loadGeom(p2, model.get(_geomTrait), rev); + if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } + }); + } + model.on(`change:panel_${id}_json`, () => { const p2 = panels.get(id); if (!p2) return; @@ -956,6 +1013,7 @@ function render({ model, el }) { try { const newState = JSON.parse(model.get(`panel_${id}_json`)); _preserveView(p2, newState); + _applyGeom(p2, newState); p2.state = newState; } catch(_) { return; } @@ -963,7 +1021,11 @@ function render({ model, el }) { _redrawPanel(p2); }); - try { p.state = JSON.parse(model.get(`panel_${id}_json`)); } catch(_) {} + if (_hasGeom) _loadGeom(p, model.get(_geomTrait), 1); + try { + p.state = JSON.parse(model.get(`panel_${id}_json`)); + _applyGeom(p, p.state); + } catch(_) {} _redrawPanel(p); } @@ -1644,10 +1706,19 @@ function render({ model, el }) { const padT = p._padT || PAD_T; // strip grows with title_size > 11 p.titleCtx.clearRect(0, 0, tw, padT); if (title2d) { + // Clamp the drawn size so even the tallest glyphs (caps, descenders, + // TeX superscripts) fit the strip WITH clear top/bottom margin on + // every platform. Font hinting varies — macOS Chromium renders ~1px + // taller than Windows at the same px — so a strip-tight title was + // clipped at row 0 on macOS CI. Reserve ~4px total vertical margin; + // padT grows for large/TeX titles (see _padT) so this only bites the + // 12px default strip, capping an 11px title to ~8px — a sub-pixel + // change well within the visual-regression tolerance. + const px = Math.min(st.title_size || 11, padT - 4); p.titleCtx.fillStyle = theme.tickText; p.titleCtx.textBaseline = 'middle'; _drawTex(p.titleCtx, title2d, tw / 2, padT / 2, - st.title_size || 11, { align: 'center', weight: 'bold' }); + px, { align: 'center', weight: 'bold' }); } } } diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 6d484f63..b4d95b5f 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -31,6 +31,10 @@ class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): plot.markers["circles"]["g1"].set(radius=8) """ + #: Heavy state keys routed to the geometry channel (see Figure._push). + #: ``colormap_data`` is large and only changes on set_colormap. + _GEOM_KEYS = frozenset({"image_b64", "colormap_data", "overlay_mask_b64"}) + def __init__(self, data: np.ndarray, x_axis=None, y_axis=None, units: str = "px", cmap: str | None = None, diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 09414ddb..7c115f46 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -152,6 +152,14 @@ class Plot3D(_BasePlot): calls ``_push()`` which writes to the parent Figure's panel trait. """ + #: Heavy, rarely-changing state keys routed to the separate geometry + #: channel — re-sent only when their content changes, so view updates + #: (highlight / camera / planes) never re-transmit them. + _GEOM_KEYS = frozenset({ + "vertices_b64", "faces_b64", "z_values_b64", "point_colors_b64", + "colormap_data", + }) + def __init__(self, geom_type: str, x, y, z, *, colormap: str = "viridis", diff --git a/anyplotlib/tests/test_benchmarks/benchmarks/baselines.json b/anyplotlib/tests/test_benchmarks/benchmarks/baselines.json new file mode 100644 index 00000000..1e12d6e3 --- /dev/null +++ b/anyplotlib/tests/test_benchmarks/benchmarks/baselines.json @@ -0,0 +1,30 @@ +{ + "js_voxels_orbit_4000": { + "mean_ms": 30.14, + "min_ms": 20.1, + "max_ms": 120.3, + "fps": 33.18, + "n": 19, + "updated_at": "2026-06-12T23:25:48.401614+00:00" + }, + "_meta": { + "updated_at": "2026-06-12T23:25:50.961808+00:00", + "host": "DESKTOP-Q7GIRJO" + }, + "js_voxels_orbit_10000": { + "mean_ms": 71.67, + "min_ms": 54.9, + "max_ms": 197.9, + "fps": 13.95, + "n": 19, + "updated_at": "2026-06-12T23:25:50.073738+00:00" + }, + "js_voxels_reblit_4000": { + "mean_ms": 32.84, + "min_ms": 23.1, + "max_ms": 129.6, + "fps": 30.45, + "n": 19, + "updated_at": "2026-06-12T23:25:50.961808+00:00" + } +} \ No newline at end of file diff --git a/anyplotlib/tests/test_embed/__init__.py b/anyplotlib/tests/test_embed/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/tests/test_embed/test_embed_api.py b/anyplotlib/tests/test_embed/test_embed_api.py new file mode 100644 index 00000000..5dbb421e --- /dev/null +++ b/anyplotlib/tests/test_embed/test_embed_api.py @@ -0,0 +1,133 @@ +""" +Unit tests for anyplotlib.embed — kernel-free embedding API. + +Covers figure_state / to_html / save_html / esm_path / Figure.to_html and +the transport-agnostic FigureBridge (outbound forwarding, inbound event +dispatch, echo suppression, dynamic panel traits). +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.embed import ( + FigureBridge, esm_path, figure_state, save_html, to_html, +) + + +def _fig_with_image(): + fig, ax = apl.subplots(1, 1, figsize=(320, 240)) + plot = ax.imshow(np.zeros((16, 16), dtype=np.float32)) + return fig, plot + + +class TestFigureState: + def test_contains_core_keys(self): + fig, plot = _fig_with_image() + state = figure_state(fig) + assert "layout_json" in state + assert state["fig_width"] == 320 + assert f"panel_{plot._id}_json" in state + + def test_panel_state_is_json(self): + fig, plot = _fig_with_image() + state = figure_state(fig) + panel = json.loads(state[f"panel_{plot._id}_json"]) + assert panel["kind"] == "2d" + + +class TestHtmlExport: + def test_to_html_is_self_contained(self): + fig, plot = _fig_with_image() + html = to_html(fig) + assert html.startswith("") + assert "function render" in html # inlined ESM + assert f"panel_{plot._id}_json" in html # inlined state + + def test_figure_methods(self, tmp_path): + fig, _ = _fig_with_image() + assert fig.to_html() == to_html(fig) + out = fig.save_html(tmp_path / "fig.html") + assert out.read_text(encoding="utf-8") == fig.to_html() + + def test_save_html(self, tmp_path): + fig, _ = _fig_with_image() + p = save_html(fig, tmp_path / "plot.html", resizable=False) + assert p.exists() and p.stat().st_size > 10_000 + + def test_esm_path_exports_mount(self): + src = esm_path().read_text(encoding="utf-8") + assert "export function mount" in src + assert "export function createLocalModel" in src + + +class TestFigureBridge: + def test_outbound_forwarding(self): + fig, plot = _fig_with_image() + sent = [] + FigureBridge(fig, send=lambda k, v: sent.append(k)) + plot.set_title("hello") + assert f"panel_{plot._id}_json" in sent + + def test_outbound_layout_changes(self): + fig, _ = _fig_with_image() + sent = [] + FigureBridge(fig, send=lambda k, v: sent.append(k)) + fig.fig_width = 500 + assert "fig_width" in sent and "layout_json" in sent + + def test_dynamic_panel_traits_forwarded(self): + """Panels added AFTER bridge creation must still forward.""" + fig = apl.Figure(1, 2, figsize=(400, 200)) + sent = [] + FigureBridge(fig, send=lambda k, v: sent.append(k)) + plot = fig.add_subplot((0, 0)).plot(np.zeros(8)) + plot.set_title("late panel") + assert f"panel_{plot._id}_json" in sent + + def test_inbound_event_dispatches_callbacks(self): + fig, plot = _fig_with_image() + bridge = FigureBridge(fig, send=lambda k, v: None) + got = [] + + @plot.add_event_handler("pointer_down") + def on_down(event): + got.append((event.event_type, event.xdata)) + + bridge.receive("event_json", json.dumps({ + "panel_id": plot._id, "event_type": "pointer_down", + "x": 5, "y": 6, "xdata": 1.5, "ydata": 2.5, "button": 0, + })) + assert got == [("pointer_down", 1.5)] + + def test_inbound_no_echo(self): + """receive() must not re-send the same key back.""" + fig, plot = _fig_with_image() + sent = [] + bridge = FigureBridge(fig, send=lambda k, v: sent.append(k)) + key = f"panel_{plot._id}_json" + new_state = json.dumps({**plot.to_state_dict(), "title": "from js"}) + bridge.receive(key, new_state) + assert key not in sent + assert getattr(fig, key) == new_state + + def test_inbound_unknown_key_ignored(self): + fig, _ = _fig_with_image() + bridge = FigureBridge(fig, send=lambda k, v: None) + bridge.receive("panel_doesnotexist_json", "{}") # must not raise + + def test_snapshot_matches_figure_state(self): + fig, _ = _fig_with_image() + bridge = FigureBridge(fig, send=lambda k, v: None) + assert bridge.snapshot() == figure_state(fig) + + def test_close_stops_forwarding(self): + fig, plot = _fig_with_image() + sent = [] + bridge = FigureBridge(fig, send=lambda k, v: sent.append(k)) + bridge.close() + plot.set_title("after close") + assert sent == [] diff --git a/anyplotlib/tests/test_embed/test_embed_mount.py b/anyplotlib/tests/test_embed/test_embed_mount.py new file mode 100644 index 00000000..f2f19926 --- /dev/null +++ b/anyplotlib/tests/test_embed/test_embed_mount.py @@ -0,0 +1,237 @@ +""" +Playwright tests for the JS `mount()` embedding entry point. + +These build a page that uses ONLY the public embedding contract — import +``figure_esm.js``, call ``mount(el, state, opts)`` — exactly as an Electron +app would. No anywidget shim, no Jupyter, no `_repr_utils` template. +""" +from __future__ import annotations + +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.embed import esm_path, figure_state + +_MOUNT_PAGE = """ + + +
+ +""" + + +@pytest.fixture +def mount_page(_pw_browser): + """Open a figure via the public mount() API; return the live Page.""" + pages, paths = [], [] + + def _open(fig): + html = (_MOUNT_PAGE + .replace("__STATE__", json.dumps(figure_state(fig))) + .replace("__ESM__", json.dumps(esm_path().read_text(encoding="utf-8")))) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + paths.append(tmp) + page = _pw_browser.new_page() + pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +def _fig_with_image(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + q = np.linspace(0, 10, 32) + plot = ax.imshow(np.random.default_rng(0).random((32, 32)), axes=[q, q]) + return fig, plot + + +def _plot_canvas_ink(page) -> int: + return page.evaluate("""() => { + const c = document.querySelector('#host canvas'); + if (!c) return -1; + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++; + return n; + }""") + + +class TestMountRenders: + def test_canvases_created_with_ink(self, mount_page): + fig, _ = _fig_with_image() + page = mount_page(fig) + n_canvas = page.evaluate("() => document.querySelectorAll('#host canvas').length") + assert n_canvas >= 3, f"expected canvas stack, got {n_canvas}" + assert _plot_canvas_ink(page) > 1000, "image canvas has no rendered pixels" + + def test_multiple_mounts_one_page_mdi_style(self, mount_page): + """Two figures in one page must not interfere (MDI sub-windows).""" + fig, _ = _fig_with_image() + page = mount_page(fig) + # Mount a second, independent figure into a fresh container. + fig2, _ = _fig_with_image() + state2 = json.dumps(figure_state(fig2)) + page.evaluate(f"""() => {{ + const div = document.createElement('div'); + div.id = 'host2'; + document.body.appendChild(div); + const esm = {json.dumps(esm_path().read_text(encoding="utf-8"))}; + const blobUrl = URL.createObjectURL(new Blob([esm], {{type:'text/javascript'}})); + return import(blobUrl).then(mod => {{ + window._handle2 = mod.mount(div, {state2}, {{}}); + }}); + }}""") + page.wait_for_function("() => window._handle2 !== undefined", timeout=15_000) + n1 = page.evaluate("() => document.querySelectorAll('#host canvas').length") + n2 = page.evaluate("() => document.querySelectorAll('#host2 canvas').length") + assert n1 >= 3 and n2 >= 3 + + def test_dispose_clears_dom(self, mount_page): + fig, _ = _fig_with_image() + page = mount_page(fig) + page.evaluate("() => window._handle.dispose()") + n = page.evaluate("() => document.querySelectorAll('#host canvas').length") + assert n == 0 + + +class TestMountLiveUpdates: + def test_set_panel_state_rerenders(self, mount_page): + """setPanelState() with a new title must draw title pixels.""" + fig, plot = _fig_with_image() + page = mount_page(fig) + + def title_ink(): + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('#host canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const d = tc.getContext('2d').getImageData(0,0,tc.width,tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++; + return n; + }""") + + assert title_ink() == 0 + new_state = {**plot.to_state_dict(), "title": "Live from JS"} + page.evaluate( + "(args) => window._handle.setPanelState(args[0], args[1])", + [plot._id, new_state], + ) + page.wait_for_timeout(150) + assert title_ink() > 0, "setPanelState() did not re-render the title" + + def test_apply_update_does_not_echo(self, mount_page): + """applyUpdate() (Python → JS path) must not bounce back via onSync.""" + fig, plot = _fig_with_image() + page = mount_page(fig) + new_state = json.dumps({**plot.to_state_dict(), "title": "no echo"}) + page.evaluate( + "(args) => window._handle.applyUpdate('panel_' + args[0] + '_json', args[1])", + [plot._id, new_state], + ) + page.wait_for_timeout(100) + syncs = page.evaluate("() => window._syncs.map(s => s.key)") + assert f"panel_{plot._id}_json" not in syncs + + +class TestMountEvents: + def test_pointer_event_reaches_onevent_and_onsync(self, mount_page): + fig, plot = _fig_with_image() + page = mount_page(fig) + # Click the centre of the image area. + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(200) + + events = page.evaluate("() => window._events") + assert any(e.get("event_type") == "pointer_down" for e in events), ( + f"no pointer_down in onEvent stream: {[e.get('event_type') for e in events]}" + ) + assert all(e.get("panel_id") == plot._id + for e in events if "panel_id" in e) + syncs = page.evaluate("() => window._syncs.map(s => s.key)") + assert "event_json" in syncs, "event_json was not flushed through onSync" + + +class TestBridgeRoundTrip: + """End-to-end Level-3 pattern: mount() in a real browser wired to a live + Python FigureBridge, with the test harness acting as the transport + (in an Electron app this would be a WebSocket / IPC pipe).""" + + def test_full_round_trip(self, mount_page): + from anyplotlib.embed import FigureBridge + + fig, plot = _fig_with_image() + clicks = [] + + @plot.add_event_handler("pointer_down") + def on_click(event): + clicks.append((event.xdata, event.ydata)) + + outbound = [] # Python → JS queue + bridge = FigureBridge(fig, send=lambda k, v: outbound.append((k, v))) + page = mount_page(fig) + + # ── JS → Python: click in the browser, pump onSync into the bridge ── + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(200) + for s in page.evaluate("() => window._syncs"): + bridge.receive(s["key"], s["value"]) + assert clicks, "browser click did not reach the Python callback" + assert clicks[0][0] is not None, "event lost its data coordinates" + + # ── Python → JS: set_title streams back into rendered pixels ── + outbound.clear() + plot.set_title("From Python") + assert outbound, "Python mutation produced no bridge messages" + for k, v in outbound: + page.evaluate("(a) => window._handle.applyUpdate(a[0], a[1])", [k, v]) + page.wait_for_timeout(150) + title_ink = page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('#host canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const d = tc.getContext('2d').getImageData(0,0,tc.width,tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++; + return n; + }""") + assert title_ink > 0, "Python set_title did not render in the browser" + bridge.close() diff --git a/anyplotlib/tests/test_labels/__init__.py b/anyplotlib/tests/test_labels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/tests/test_labels/test_label_api.py b/anyplotlib/tests/test_labels/test_label_api.py new file mode 100644 index 00000000..a4cc7714 --- /dev/null +++ b/anyplotlib/tests/test_labels/test_label_api.py @@ -0,0 +1,132 @@ +""" +Unit tests for the label font-size API and TeX pass-through. + +Covers: + * fontsize kwarg on set_xlabel / set_ylabel / set_zlabel / set_title / + set_colorbar_label for every panel type + * fontsize=None leaves the size state untouched (JS falls back to defaults) + * set_tick_label_size + * TeX-formatted label strings are stored verbatim (parsing happens in JS) +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _imshow(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((8, 8))) + + +def _plot(): + fig, ax = apl.subplots(1, 1) + return ax.plot(np.zeros(16)) + + +def _bar(): + fig, ax = apl.subplots(1, 1) + return ax.bar(["a", "b"], [1.0, 2.0]) + + +def _surface(): + fig, ax = apl.subplots(1, 1) + g = np.linspace(-1, 1, 8) + XX, YY = np.meshgrid(g, g) + return ax.plot_surface(XX, YY, XX * YY) + + +class TestFontsizeKwarg: + def test_plot2d_xlabel_fontsize(self): + v = _imshow() + v.set_xlabel("x", fontsize=14) + assert v._state["x_label"] == "x" + assert v._state["x_label_size"] == 14.0 + + def test_plot2d_ylabel_fontsize(self): + v = _imshow() + v.set_ylabel("y", fontsize=16) + assert v._state["y_label"] == "y" + assert v._state["y_label_size"] == 16.0 + + def test_plot2d_colorbar_label_fontsize(self): + v = _imshow() + v.set_colorbar_label("counts", fontsize=13) + assert v._state["colorbar_label"] == "counts" + assert v._state["colorbar_label_size"] == 13.0 + + def test_plot1d_label_fontsize_maps_to_units(self): + v = _plot() + v.set_xlabel("eV", fontsize=12) + v.set_ylabel("counts", fontsize=11) + assert v._state["units"] == "eV" + assert v._state["x_label_size"] == 12.0 + assert v._state["y_units"] == "counts" + assert v._state["y_label_size"] == 11.0 + + def test_plotbar_label_fontsize(self): + v = _bar() + v.set_xlabel("category", fontsize=12) + v.set_ylabel("value", fontsize=13) + assert v._state["x_label_size"] == 12.0 + assert v._state["y_label_size"] == 13.0 + + def test_plot3d_label_fontsize(self): + v = _surface() + v.set_xlabel("x", fontsize=14) + v.set_ylabel("y", fontsize=15) + v.set_zlabel("z", fontsize=16) + assert v._state["x_label_size"] == 14.0 + assert v._state["y_label_size"] == 15.0 + assert v._state["z_label_size"] == 16.0 + + def test_title_fontsize_all_panel_types(self): + for make in (_imshow, _plot, _bar, _surface): + v = make() + v.set_title("T", fontsize=12) + assert v._state["title"] == "T" + assert v._state["title_size"] == 12.0 + + +class TestFontsizeNoneKeepsState: + def test_none_does_not_create_size_key(self): + v = _imshow() + v.set_xlabel("x") + assert "x_label_size" not in v._state + + def test_none_does_not_overwrite_previous_size(self): + v = _imshow() + v.set_xlabel("x", fontsize=18) + v.set_xlabel("renamed") # no fontsize — keep 18 + assert v._state["x_label"] == "renamed" + assert v._state["x_label_size"] == 18.0 + + +class TestTickLabelSize: + @pytest.mark.parametrize("make", [_imshow, _plot, _bar]) + def test_set_tick_label_size(self, make): + v = make() + v.set_tick_label_size(14) + assert v._state["tick_size"] == 14.0 + + +class TestTexPassThrough: + """Python stores TeX strings verbatim; all parsing happens at JS draw time.""" + + def test_tex_label_stored_verbatim(self): + v = _imshow() + label = r"$q$ ($\AA^{-1}$)" + v.set_xlabel(label) + assert v._state["x_label"] == label + + def test_tex_exponent_title(self): + v = _plot() + v.set_title(r"Intensity $\times 10^{-3}$") + assert v._state["title"] == r"Intensity $\times 10^{-3}$" + + def test_tex_subscript_colorbar(self): + v = _imshow() + v.set_colorbar_label(r"$E_F$ (eV)") + assert v._state["colorbar_label"] == r"$E_F$ (eV)" diff --git a/anyplotlib/tests/test_labels/test_label_rendering.py b/anyplotlib/tests/test_labels/test_label_rendering.py new file mode 100644 index 00000000..933b70aa --- /dev/null +++ b/anyplotlib/tests/test_labels/test_label_rendering.py @@ -0,0 +1,137 @@ +""" +Playwright tests for label font sizes and mini-TeX rendering. + +Strategy +-------- +Canvas text cannot be read back as strings, so these tests assert on *ink*: + +* a larger ``fontsize`` must produce more non-background pixels in the + axis gutter than a smaller one; +* a TeX string like ``$10^{-3}$`` must render *narrower* than the same + characters drawn literally (``10^{-3}``) — the ``$`` delimiters are + consumed and the exponent shrinks to a superscript; +* TeX titles must produce visible pixels in the title canvas. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + +PAD_B = 42 # bottom axis gutter height (PAD_* constants in figure_esm.js) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _x_gutter(img: np.ndarray) -> np.ndarray: + """Return the bottom PAD_B-row strip of a widget screenshot.""" + return img[-PAD_B:, :, :3].astype(int) + + +def _ink_mask(strip: np.ndarray) -> np.ndarray: + """Boolean mask of pixels that differ from the strip's corner colour.""" + bg = strip[2, 2] + return np.abs(strip - bg).sum(axis=-1) > 60 + + +def _x_gutter_ink(take_screenshot, label: str, fontsize=None) -> np.ndarray: + """Render an imshow with the given x label; return the gutter ink mask.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 10, 32)] * 2, + units="nm", + ) + if fontsize is None: + plot.set_xlabel(label) + else: + plot.set_xlabel(label, fontsize=fontsize) + return _ink_mask(_x_gutter(take_screenshot(fig))) + + +def _title_pixel_count(page) -> int: + """Count non-transparent pixels in the 2D titleCanvas (z-index:8).""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const ctx = tc.getContext('2d'); + const d = ctx.getImageData(0, 0, tc.width, tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) { if (d[i] > 0) n++; } + return n; + }""") + + +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestFontsizeRendering: + def test_larger_fontsize_more_ink(self, take_screenshot): + small = _x_gutter_ink(take_screenshot, "Distance", fontsize=9) + large = _x_gutter_ink(take_screenshot, "Distance", fontsize=18) + assert large.sum() > small.sum() * 1.3, ( + f"fontsize=18 must draw more label ink than fontsize=9 " + f"(got {large.sum()} vs {small.sum()})" + ) + + def test_tick_label_size_changes_ink(self, take_screenshot): + def gutter_ink(tick_size): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 10, 32)] * 2, + ) + if tick_size: + plot.set_tick_label_size(tick_size) + return _ink_mask(_x_gutter(take_screenshot(fig))).sum() + + assert gutter_ink(16) > gutter_ink(None) * 1.2, ( + "set_tick_label_size(16) must draw more tick ink than the default" + ) + + +class TestTexRendering: + def test_tex_label_renders_ink(self, take_screenshot): + ink = _x_gutter_ink(take_screenshot, r"$10^{-3}$ m") + assert ink.sum() > 0, "TeX label must render visible pixels" + + def test_tex_consumes_dollars_and_shrinks_exponent(self, take_screenshot): + """$10^{-3}$ must be narrower than the literal text 10^{-3}. + + The TeX path drops the two ``$`` delimiters and the ``^{}`` braces + and renders ``-3`` at ~0.68× size, so its ink must span fewer + columns than the literal 7-glyph string. + """ + tex = _x_gutter_ink(take_screenshot, r"$10^{-3}$") + lit = _x_gutter_ink(take_screenshot, "10^{-3}") + # Width = number of columns containing any ink in the label row band. + # Restrict to the bottom 14 rows where the centred label is drawn, + # away from tick numbers at the top of the gutter. + tex_cols = np.flatnonzero(tex[-14:, :].any(axis=0)) + lit_cols = np.flatnonzero(lit[-14:, :].any(axis=0)) + assert len(tex_cols) > 0 and len(lit_cols) > 0 + tex_w = tex_cols[-1] - tex_cols[0] + lit_w = lit_cols[-1] - lit_cols[0] + assert tex_w < lit_w, ( + f"TeX '$10^{{-3}}$' must render narrower than literal '10^{{-3}}' " + f"(got {tex_w} vs {lit_w} px)" + ) + + def test_tex_title_renders_pixels(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title(r"$\sigma^2 = \langle x^2 \rangle$") + page = interact_page(fig) + page.wait_for_timeout(200) + n = _title_pixel_count(page) + assert n > 0, "TeX title must produce visible pixels in titleCanvas" + + def test_greek_and_symbols_render(self, take_screenshot): + ink = _x_gutter_ink(take_screenshot, r"$\Delta E$ ($\mu$eV) $\times$ $\AA$") + assert ink.sum() > 0 + + def test_plain_label_unaffected(self, take_screenshot): + """A label with no $ must render through the fast path identically.""" + ink = _x_gutter_ink(take_screenshot, "plain label, no math") + assert ink.sum() > 0 diff --git a/anyplotlib/tests/test_labels/test_no_clipping.py b/anyplotlib/tests/test_labels/test_no_clipping.py new file mode 100644 index 00000000..85b302c3 --- /dev/null +++ b/anyplotlib/tests/test_labels/test_no_clipping.py @@ -0,0 +1,102 @@ +""" +Playwright regression tests: labels, titles, and tick text must never be +clipped by their canvas bounds. + +Strategy: read back each text-bearing canvas with ``getImageData`` and assert +no ink (non-transparent pixel) sits on the canvas's first/last row. Text +whose glyphs are cut by the canvas edge always leaves ink on the edge row, so +"no ink on the edge" ⇒ "nothing clipped vertically". + +The 2D title canvas is fully transparent except for the title, making it the +cleanest probe for both the dynamic title strip and the TeX superscript rise. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _title_ink_rows(page) -> dict: + """Return {h, minRow, maxRow} of ink in the 2D titleCanvas (z-index 8).""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return null; + const d = tc.getContext('2d').getImageData(0, 0, tc.width, tc.height).data; + let minR = 1e9, maxR = -1; + for (let y = 0; y < tc.height; y++) for (let x = 0; x < tc.width; x++) { + if (d[(y * tc.width + x) * 4 + 3] > 0) { + if (y < minR) minR = y; + if (y > maxR) maxR = y; + } + } + return { h: tc.height, minRow: minR, maxRow: maxR }; + }""") + + +def _open_imshow_with_title(interact_page, title, fontsize=None): + fig, ax = apl.subplots(1, 1, figsize=(460, 380)) + q = np.linspace(-2.3, 2.3, 64) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32), axes=[q, q], units="nm") + if fontsize is None: + plot.set_title(title) + else: + plot.set_title(title, fontsize=fontsize) + page = interact_page(fig) + page.wait_for_timeout(150) + return page + + +class TestTitleNeverClipped: + @pytest.mark.parametrize("title,fontsize", [ + ("Plain gyp TX", None), # default plain — baseline case + (r"TeX: $|F(q)|^2$ gyp", None), # default TeX — strip grows for sup + (r"Large $x^2$ gyp", 16), # large TeX — strip grows + ("Plain large gyp", 16), # large plain + (r"XL $y_i^2$ gyp", 22), # extreme, sup + sub + descenders + ]) + def test_title_ink_within_strip(self, interact_page, title, fontsize): + page = _open_imshow_with_title(interact_page, title, fontsize) + r = _title_ink_rows(page) + assert r is not None and r["maxRow"] >= 0, "title produced no ink" + assert r["minRow"] > 0, ( + f"title ink touches the top edge (clipped ascender/superscript): {r}" + ) + assert r["maxRow"] < r["h"] - 1, ( + f"title ink touches the bottom edge (clipped descender): {r}" + ) + + +class TestColorbarLabelVisible: + def test_colorbar_label_renders_in_reserved_gutter(self, interact_page): + """The image must shrink so the colorbar strip + label fit the panel.""" + fig, ax = apl.subplots(1, 1, figsize=(460, 380)) + q = np.linspace(-2.3, 2.3, 64) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32), axes=[q, q]) + plot.set_colorbar_visible(True) + plot.set_colorbar_label(r"counts $\times 10^{3}$") + page = interact_page(fig) + page.wait_for_timeout(150) + + res = page.evaluate("""() => { + // cbCanvas: the only canvas right of the image, width > 16 + const panel = 460; + for (const c of document.querySelectorAll('canvas')) { + const left = parseFloat(c.style.left || '0'); + if (c.width > 16 && c.width < 80 && left > 300) { + // entire canvas must sit inside the panel width + const fits = left + c.width <= panel; + // ink in the label gutter (x > 16) + const d = c.getContext('2d').getImageData(16, 0, c.width - 16, c.height).data; + let ink = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) ink++; + return { w: c.width, left, fits, labelInk: ink }; + } + } + return null; + }""") + assert res is not None, "colorbar canvas not found" + assert res["fits"], f"colorbar extends past the panel edge: {res}" + assert res["labelInk"] > 0, f"colorbar label has no visible ink: {res}" diff --git a/anyplotlib/tests/test_layouts/test_batch.py b/anyplotlib/tests/test_layouts/test_batch.py new file mode 100644 index 00000000..21e6a548 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_batch.py @@ -0,0 +1,90 @@ +"""Tests for Figure.batch() push coalescing — the linked-view lag fix.""" +from __future__ import annotations + +import numpy as np +import anyplotlib as apl + + +def _fig3(): + fig = apl.Figure(figsize=(600, 200)) + gs = apl.GridSpec(1, 3) + axs = [fig.add_subplot(gs[0, c]) for c in range(3)] + px = [np.arange(16)] * 2 + plots = [a.imshow(np.zeros((16, 16, 3), dtype=np.uint8), axes=px) for a in axs] + return fig, plots + + +def _count_pushes(fig): + calls = {"n": 0} + orig = type(fig)._push + def counting(self, pid): + # count only real trait writes (batch dirty-marking returns early) + if not self._batching: + calls["n"] += 1 + return orig(self, pid) + type(fig)._push = counting + return calls, lambda: setattr(type(fig), "_push", orig) + + +class TestBatch: + def test_coalesces_multiple_pushes_per_panel(self): + fig, plots = _fig3() + calls, restore = _count_pushes(fig) + try: + with fig.batch(): + for p in plots: + p.set_data(np.ones((16, 16, 3), dtype=np.uint8)) + p.set_title("x") # 2nd mutation, same panel + # 3 panels × 2 mutations each = 6 mutations → 3 pushes + assert calls["n"] == 3, f"expected 3 coalesced pushes, got {calls['n']}" + finally: + restore() + + def test_without_batch_pushes_per_mutation(self): + fig, plots = _fig3() + calls, restore = _count_pushes(fig) + try: + for p in plots: + p.set_data(np.ones((16, 16, 3), dtype=np.uint8)) + p.set_title("x") + assert calls["n"] == 6, f"expected 6 pushes, got {calls['n']}" + finally: + restore() + + def test_batch_applies_state(self): + fig, plots = _fig3() + with fig.batch(): + plots[0].set_title("hello") + assert plots[0]._state["title"] == "hello" + # trait reflects the change after the block + import json + st = json.loads(getattr(fig, f"panel_{plots[0]._id}_json")) + assert st["title"] == "hello" + + def test_nested_batch_is_transparent(self): + fig, plots = _fig3() + calls, restore = _count_pushes(fig) + try: + with fig.batch(): + with fig.batch(): + plots[0].set_title("a") + plots[1].set_title("b") + assert calls["n"] == 2 + finally: + restore() + + def test_3d_view_and_highlight_coalesce(self): + fig = apl.Figure(figsize=(300, 300)) + ax = fig.add_subplot(apl.GridSpec(1, 1)[0, 0]) + v = ax.scatter3d(np.zeros(4), np.zeros(4), np.zeros(4), + bounds=((-1, 1),) * 3) + calls, restore = _count_pushes(fig) + try: + with fig.batch(): + v.set_highlight(0.1, 0.2, 0.3) + v.set_view(azimuth=10, elevation=20) + assert calls["n"] == 1, f"expected 1 coalesced push, got {calls['n']}" + assert v._state["highlight"]["x"] == 0.1 + assert v._state["azimuth"] == 10 + finally: + restore() diff --git a/anyplotlib/tests/test_layouts/test_geom_channel.py b/anyplotlib/tests/test_layouts/test_geom_channel.py new file mode 100644 index 00000000..9660b016 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_geom_channel.py @@ -0,0 +1,71 @@ +"""Tests for the geometry channel: heavy geometry rides a separate trait and +is re-sent only when it actually changes (view updates don't re-transmit it).""" +from __future__ import annotations + +import json +import numpy as np +import anyplotlib as apl + + +def _scatter(): + fig = apl.Figure(figsize=(300, 300)) + ax = fig.add_subplot(apl.GridSpec(1, 1)[0, 0]) + v = ax.scatter3d(np.zeros(8), np.zeros(8), np.zeros(8), + bounds=((-1, 1),) * 3, + colors=np.tile([1, 2, 3], (8, 1)).astype(np.uint8)) + return fig, v + + +class TestGeomChannel: + def test_geom_trait_allocated(self): + fig, v = _scatter() + assert fig.has_trait(f"panel_{v._id}_geom") + + def test_view_trait_excludes_geometry(self): + fig, v = _scatter() + view = json.loads(getattr(fig, f"panel_{v._id}_json")) + for k in ("vertices_b64", "faces_b64", "point_colors_b64", "colormap_data"): + assert k not in view, f"{k} leaked into the view trait" + assert view["_geom_rev"] >= 1 + + def test_geom_trait_contains_geometry(self): + fig, v = _scatter() + geom = json.loads(getattr(fig, f"panel_{v._id}_geom")) + assert "vertices_b64" in geom and "point_colors_b64" in geom + + def test_highlight_does_not_resend_geometry(self): + fig, v = _scatter() + gkey = f"panel_{v._id}_geom" + before = getattr(fig, gkey) + rev_before = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + v.set_highlight(0.1, 0.2, 0.3) + assert getattr(fig, gkey) == before, "geometry re-sent on highlight move" + rev_after = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + assert rev_after == rev_before, "geom_rev bumped without geometry change" + assert json.loads(getattr(fig, f"panel_{v._id}_json"))["highlight"]["x"] == 0.1 + + def test_view_change_does_not_resend_geometry(self): + fig, v = _scatter() + before = getattr(fig, f"panel_{v._id}_geom") + v.set_view(azimuth=42, elevation=15) + assert getattr(fig, f"panel_{v._id}_geom") == before + assert json.loads(getattr(fig, f"panel_{v._id}_json"))["azimuth"] == 42 + + def test_geometry_change_bumps_rev_and_resends(self): + fig, v = _scatter() + gkey = f"panel_{v._id}_geom" + before = getattr(fig, gkey) + rev_before = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + v.set_data(np.ones(8) * 3, np.ones(8) * 4, np.ones(8) * 5) # new geometry + assert getattr(fig, gkey) != before, "geometry change not re-sent" + rev_after = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + assert rev_after == rev_before + 1, "geom_rev not bumped on geometry change" + + def test_plot_without_geom_keys_unaffected(self): + # Plot1D declares no _GEOM_KEYS → single-trait path, no geom trait. + fig = apl.Figure(figsize=(300, 200)) + ax = fig.add_subplot(apl.GridSpec(1, 1)[0, 0]) + p = ax.plot(np.sin(np.linspace(0, 6, 64))) + assert not fig.has_trait(f"panel_{p._id}_geom") + view = json.loads(getattr(fig, f"panel_{p._id}_json")) + assert "data_b64" in view # geometry stays inline for non-split plots diff --git a/anyplotlib/tests/test_plot2d/test_imshow_rgb.py b/anyplotlib/tests/test_plot2d/test_imshow_rgb.py new file mode 100644 index 00000000..55776aa6 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_imshow_rgb.py @@ -0,0 +1,117 @@ +""" +Tests for true-colour (H, W, 3|4) imshow support. + +Unit tests cover state encoding and dtype handling; Playwright tests verify +actual rendered pixel colours on the canvas. +""" +from __future__ import annotations + +import base64 + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _rgb_quadrants(n=32): + """Image with pure-red TL, pure-green TR, pure-blue BL, white BR.""" + img = np.zeros((n, n, 3), dtype=np.uint8) + h = n // 2 + img[:h, :h] = [255, 0, 0] + img[:h, h:] = [0, 255, 0] + img[h:, :h] = [0, 0, 255] + img[h:, h:] = [255, 255, 255] + return img + + +class TestRgbState: + def test_uint8_rgb_sets_state(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(_rgb_quadrants()) + assert v._state["is_rgb"] is True + raw = base64.b64decode(v._state["image_b64"]) + assert len(raw) == 32 * 32 * 4 # RGBA bytes + assert raw[0:4] == bytes([255, 0, 0, 255]) + + def test_float_01_rgb_scaled(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.full((4, 4, 3), 0.5)) + raw = base64.b64decode(v._state["image_b64"]) + assert raw[0] == 127 or raw[0] == 128 # 0.5 * 255 + + def test_rgba_alpha_preserved(self): + img = np.zeros((4, 4, 4), dtype=np.uint8) + img[..., 3] = 99 + fig, ax = apl.subplots(1, 1) + v = ax.imshow(img) + raw = base64.b64decode(v._state["image_b64"]) + assert raw[3] == 99 + + def test_grayscale_unchanged(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((8, 8))) + assert v._state["is_rgb"] is False + assert len(base64.b64decode(v._state["image_b64"])) == 64 # 1 byte/px + + def test_two_channel_raises(self): + fig, ax = apl.subplots(1, 1) + with pytest.raises(ValueError, match="3 .RGB. or 4"): + ax.imshow(np.zeros((8, 8, 2))) + + def test_set_data_switches_modes(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((8, 8))) + v.set_data(_rgb_quadrants(8)) + assert v._state["is_rgb"] is True + v.set_data(np.zeros((8, 8))) + assert v._state["is_rgb"] is False + + def test_origin_lower_flips_rgb(self): + img = np.zeros((2, 2, 3), dtype=np.uint8) + img[0, 0] = [255, 0, 0] # red in row 0 + fig, ax = apl.subplots(1, 1) + v = ax.imshow(img, origin="lower") + raw = base64.b64decode(v._state["image_b64"]) + # flipud → red pixel is now in the LAST row, first column + last_row_first_px = raw[(2 * 1 + 0) * 4: (2 * 1 + 0) * 4 + 4] + assert last_row_first_px == bytes([255, 0, 0, 255]) + + +class TestRgbRendering: + def test_quadrant_colors_on_canvas(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + ax.imshow(_rgb_quadrants()) + page = interact_page(fig) + page.wait_for_timeout(150) + + px = page.evaluate("""() => { + const c = document.querySelector('canvas'); + const ctx = c.getContext('2d'); + const w = c.width, h = c.height; + const grab = (fx, fy) => Array.from( + ctx.getImageData(Math.round(w*fx), Math.round(h*fy), 1, 1).data); + return { tl: grab(0.25, 0.25), tr: grab(0.75, 0.25), + bl: grab(0.25, 0.75), br: grab(0.75, 0.75) }; + }""") + assert px["tl"][:3] == [255, 0, 0], f"top-left not red: {px['tl']}" + assert px["tr"][:3] == [0, 255, 0], f"top-right not green: {px['tr']}" + assert px["bl"][:3] == [0, 0, 255], f"bottom-left not blue: {px['bl']}" + assert px["br"][:3] == [255, 255, 255], f"bottom-right not white: {px['br']}" + + def test_colorbar_suppressed_for_rgb(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + q = np.linspace(0, 1, 32) + v = ax.imshow(_rgb_quadrants(), axes=[q, q]) + v.set_colorbar_visible(True) # must be ignored for RGB + page = interact_page(fig) + page.wait_for_timeout(150) + visible = page.evaluate("""() => { + for (const c of document.querySelectorAll('canvas')) { + const left = parseFloat(c.style.left || '0'); + if (c.width <= 80 && left > 150 && c.style.display !== 'none') + return true; // a visible colorbar-sized canvas + } + return false; + }""") + assert not visible, "colorbar must stay hidden for RGB images" diff --git a/anyplotlib/tests/test_plot3d/test_colors_highlight.py b/anyplotlib/tests/test_plot3d/test_colors_highlight.py new file mode 100644 index 00000000..574539d8 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_colors_highlight.py @@ -0,0 +1,174 @@ +""" +Tests for Plot3D per-point scatter colors, the highlight point, and the +bounds override — the capabilities behind the IPF explorer example. +""" +from __future__ import annotations + +import base64 + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _scatter(**kwargs): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + pts = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + return ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], **kwargs) + + +class TestPointColors: + def test_hex_list(self): + v = _scatter(colors=["#ff0000", "#00ff00", "#0000ff"]) + raw = base64.b64decode(v._state["point_colors_b64"]) + assert list(raw) == [255, 0, 0, 0, 255, 0, 0, 0, 255] + + def test_float_array(self): + v = _scatter(colors=np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1.0]])) + raw = base64.b64decode(v._state["point_colors_b64"]) + assert list(raw) == [255, 0, 0, 0, 255, 0, 0, 0, 255] + + def test_wrong_length_raises(self): + with pytest.raises(ValueError, match="2 colors for 3 points"): + _scatter(colors=["#ff0000", "#00ff00"]) + + def test_colors_on_surface_raises(self): + fig, ax = apl.subplots(1, 1) + g = np.linspace(0, 1, 4) + XX, YY = np.meshgrid(g, g) + with pytest.raises(ValueError, match="only supported for scatter"): + apl.Plot3D("surface", XX, YY, XX * YY, colors=["#fff"] * 16) + + def test_set_point_colors_update_and_clear(self): + v = _scatter() + assert v._state["point_colors_b64"] == "" + v.set_point_colors(["#112233"] * 3) + assert v._state["point_colors_b64"] != "" + v.set_point_colors(None) + assert v._state["point_colors_b64"] == "" + + +class TestHighlight: + def test_set_and_clear(self): + v = _scatter() + v.set_highlight(0.1, 0.2, 0.3, color="#ffffff", size=9) + hl = v._state["highlight"] + assert hl == {"x": 0.1, "y": 0.2, "z": 0.3, + "color": "#ffffff", "size": 9.0} + v.clear_highlight() + assert v._state["highlight"] is None + + +class TestSphere: + def test_set_and_clear(self): + v = _scatter(bounds=((-1, 1),) * 3) + v.set_sphere(1.0, color="#777777", alpha=0.2, wireframe=False) + assert v._state["sphere"] == {"radius": 1.0, "color": "#777777", + "alpha": 0.2, "wireframe": False} + v.clear_sphere() + assert v._state["sphere"] is None + + def test_sphere_renders_silhouette(self, interact_page): + """The shaded disk + wireframe must add substantial ink, bounded by + the silhouette circle.""" + def ink(with_sphere): + v = _scatter(bounds=((-1, 1),) * 3, point_size=2) + v.set_axis_off() + if with_sphere: + v.set_sphere(1.0) + page = interact_page(v._fig) + page.wait_for_timeout(200) + return 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; + // count pixels that differ from the corner background + const bg = [d[0], d[1], d[2]]; + let n = 0; + for (let i = 0; i < d.length; i += 4) { + if (Math.abs(d[i]-bg[0])+Math.abs(d[i+1]-bg[1]) + +Math.abs(d[i+2]-bg[2]) > 24) n++; + } + return n; + }""") + + without = ink(False) + with_s = ink(True) + assert with_s > without + 2000, ( + f"sphere added too little ink: {without} -> {with_s}") + + +class TestBoundsOverride: + def test_bounds_fix_data_bounds(self): + v = _scatter(bounds=((-1, 1), (-1, 1), (-1, 1))) + assert v._state["data_bounds"] == { + "xmin": -1.0, "xmax": 1.0, "ymin": -1.0, "ymax": 1.0, + "zmin": -1.0, "zmax": 1.0} + + def test_set_data_preserves_bounds(self): + v = _scatter(bounds=((-1, 1),) * 3) + v.set_data([0.5], [0.5], [0.5]) + assert v._state["data_bounds"]["xmin"] == -1.0 + + def test_default_bounds_fit_data(self): + v = _scatter() + assert v._state["data_bounds"]["xmax"] == 1.0 + assert v._state["data_bounds"]["xmin"] == 0.0 + + +class TestRendering: + def test_colored_points_and_highlight_render(self, interact_page): + """Pure-coloured points and a white highlight must appear on canvas.""" + v = _scatter(colors=["#ff0000", "#00ff00", "#0000ff"], + point_size=10, bounds=((-1, 1),) * 3) + v.set_axis_off() + v.set_highlight(-0.6, -0.6, -0.6, color="#ffffff", size=9) + fig = v._fig + page = interact_page(fig) + page.wait_for_timeout(200) + + found = 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; + const seen = { red: false, green: false, blue: false, white: false }; + for (let i = 0; i < d.length; i += 4) { + const r = d[i], g = d[i+1], b = d[i+2]; + if (r > 220 && g < 60 && b < 60) seen.red = true; + if (g > 220 && r < 60 && b < 60) seen.green = true; + if (b > 220 && r < 60 && g < 60) seen.blue = true; + if (r > 240 && g > 240 && b > 240) seen.white = true; + } + return seen; + }""") + assert found["red"] and found["green"] and found["blue"], ( + f"per-point colours missing from canvas: {found}") + assert found["white"], f"highlight dot missing from canvas: {found}" + + def test_highlight_moves_with_set_view(self, interact_page): + """After rotate-to-face, the highlight must sit near panel centre.""" + v = _scatter(bounds=((-1, 1),) * 3, point_size=2) + v.set_axis_off() + d = np.array([0.3, 0.4, 0.866]) + d = d / np.linalg.norm(d) + v.set_highlight(*d, color="#ff00ff", size=8) + # Turntable face-camera: el = asin(vz), az = atan2(vx, -vy) + el = float(np.degrees(np.arcsin(np.clip(d[2], -1, 1)))) + az = float(np.degrees(np.arctan2(d[0], -d[1]))) + v.set_view(azimuth=az, elevation=el) + page = interact_page(v._fig) + page.wait_for_timeout(200) + + pos = 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 sx = 0, sy = 0, n = 0; + for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) { + const i = (y * c.width + x) * 4; + if (d[i] > 220 && d[i+1] < 80 && d[i+2] > 220) { sx += x; sy += y; n++; } + } + return n ? { x: sx / n, y: sy / n, n, w: c.width, h: c.height } : null; + }""") + assert pos is not None, "magenta highlight not found on canvas" + # Facing the camera ⇒ projected at the panel centre (within tolerance) + assert abs(pos["x"] - pos["w"] / 2) < 6, f"highlight off-centre x: {pos}" + assert abs(pos["y"] - pos["h"] / 2) < 6, f"highlight off-centre y: {pos}" diff --git a/anyplotlib/tests/test_plot3d/test_gpu_fallback.py b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py new file mode 100644 index 00000000..0477e1e6 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py @@ -0,0 +1,170 @@ +""" +Tests for the WebGPU scatter path — focused on the FALLBACK CONTRACT. + +A real GPU adapter is rarely available in CI (headless Chromium exposes +``navigator.gpu`` but ``requestAdapter()`` returns null without Vulkan/ +lavapipe), so these tests assert the thing that must hold *everywhere*: +when the GPU is unavailable, a GPU-requesting scatter renders identically +to the Canvas2D path and ``gpu_active`` reports False. + +The actual GPU render is validated manually on a real-GPU machine; see +WEBGPU_PLAN.md Phase 1 acceptance. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _scatter(n=100, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + rng = np.random.default_rng(1) + pts = rng.uniform(-1, 1, size=(n, 3)) + return ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], + bounds=((-1, 1),) * 3, **kwargs) + + +class TestGpuApi: + def test_default_mode_auto(self): + assert _scatter()._state["gpu_mode"] == "auto" + + def test_gpu_true_is_always(self): + assert _scatter(gpu=True)._state["gpu_mode"] == "always" + + def test_gpu_false_is_off(self): + assert _scatter(gpu=False)._state["gpu_mode"] == "off" + + def test_gpu_active_starts_false(self): + assert _scatter()._gpu_active is False + assert _scatter().gpu_active is False + + def test_gpu_status_echo_updates_active(self): + v = _scatter() + fig = v._fig + fig._dispatch_event(json.dumps({ + "panel_id": v._id, "event_type": "gpu_status", "gpu_active": True})) + assert v.gpu_active is True + fig._dispatch_event(json.dumps({ + "panel_id": v._id, "event_type": "gpu_status", "gpu_active": False})) + assert v.gpu_active is False + + def test_gpu_only_for_scatter(self): + # voxels/surface don't carry gpu_mode into the GPU path (Phase 1 = + # points only); the kwarg simply isn't offered there. Sanity: scatter + # has the field, surface does not error. + assert "gpu_mode" in _scatter()._state + + +class TestFallbackRendersOnCanvas: + """gpu='always' with no adapter MUST render via Canvas2D, unchanged.""" + + def _red_ink(self, page): + return page.evaluate("""() => { + const cs = [...document.querySelectorAll('canvas')]; + const c = cs.find(x => !x.style.zIndex || x.style.zIndex === '1'); + 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] > 180 && d[i+1] < 140 && d[i+2] < 140) red++; + return red; + }""") + + def test_always_falls_back_to_canvas(self, interact_page): + v = _scatter(n=2000, gpu="always", + colors=np.tile([255, 80, 80], (2000, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(400) # allow the async device probe to resolve + # When requestAdapter() is null the gpuCanvas stays hidden … + disp = page.evaluate("""() => { + const g = [...document.querySelectorAll('canvas')] + .find(c => c.style.zIndex === '0'); + return g ? g.style.display : 'none'; + }""") + assert disp == 'none', "gpuCanvas must stay hidden without an adapter" + # … and the points still render on the 2D canvas. + assert self._red_ink(page) > 500, "canvas fallback produced no points" + + def test_auto_small_cloud_uses_canvas(self, interact_page): + """Below the threshold, 'auto' never even probes the GPU.""" + v = _scatter(n=500, gpu="auto", + colors=np.tile([255, 80, 80], (500, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(300) + assert self._red_ink(page) > 200 + + def test_gpu_off_renders_canvas(self, interact_page): + v = _scatter(n=1000, gpu=False, + colors=np.tile([255, 80, 80], (1000, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(300) + assert self._red_ink(page) > 300 + + def test_no_console_errors_on_fallback(self, interact_page): + v = _scatter(n=2000, gpu="always") + v.set_axis_off() + page = interact_page(v._fig) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.wait_for_timeout(400) + assert not errors, f"GPU fallback raised page errors: {errors}" + + +def _voxels(n_side=8, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + g = np.arange(0, n_side, dtype=float) + zz, yy, xx = np.meshgrid(g, g, g, indexing="ij") + return ax.voxels(xx.ravel(), yy.ravel(), zz.ravel(), + bounds=((0, n_side - 1),) * 3, **kwargs) + + +class TestVoxelGpuFallback: + """gpu='always' voxels with no adapter MUST render via Canvas2D.""" + + def test_voxel_gpu_mode_state(self): + assert _voxels(gpu=True)._state["gpu_mode"] == "always" + assert _voxels(gpu=False)._state["gpu_mode"] == "off" + assert _voxels()._state["gpu_mode"] == "auto" + + def test_voxel_always_falls_back_to_canvas(self, interact_page): + colors = np.tile([255, 60, 60], (512, 1)).astype(np.uint8) + v = _voxels(colors=colors, alpha=0.4, gpu="always") + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(400) + disp = page.evaluate("""() => { + const g = [...document.querySelectorAll('canvas')] + .find(c => c.style.zIndex === '0'); + return g ? g.style.display : 'none'; + }""") + assert disp == 'none', "voxel gpuCanvas must stay hidden without adapter" + red = 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 r = 0; + for (let i = 0; i < d.length; i += 4) + if (d[i] > 120 && d[i+1] < 120 && d[i+2] < 120) r++; + return r; + }""") + assert red > 500, "voxel canvas fallback produced no cubes" + + def test_voxel_gpu_no_console_errors(self, interact_page): + v = _voxels(colors=np.tile([200, 80, 80], (512, 1)).astype(np.uint8), + gpu="always") + v.set_axis_off() + v.add_widget("plane", axis="z", position=4) + page = interact_page(v._fig) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.wait_for_timeout(400) + assert not errors, f"GPU voxel fallback raised errors: {errors}" diff --git a/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py new file mode 100644 index 00000000..a70a6093 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_voxels_planes.py @@ -0,0 +1,158 @@ +""" +Tests for the 'voxels' geometry and 3-D PlaneWidget slice selectors. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _voxels(**kwargs): + 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") + return ax.voxels(xx.ravel(), yy.ravel(), zz.ravel(), + bounds=((0, 7),) * 3, **kwargs) + + +class TestVoxelsState: + def test_geom_and_alpha_state(self): + v = _voxels(size=1.0, alpha=0.2) + assert v._state["geom_type"] == "voxels" + assert v._state["voxel_size"] == 1.0 + assert v._state["voxel_alpha"] == 0.2 + assert v._state["voxel_slice_alpha"] == 0.95 + + def test_per_voxel_colors_allowed(self): + colors = np.zeros((512, 3), dtype=np.uint8) + v = _voxels(colors=colors) + assert v._state["point_colors_b64"] != "" + + def test_set_voxel_alpha(self): + v = _voxels() + v.set_voxel_alpha(0.1, slice_alpha=0.8) + assert v._state["voxel_alpha"] == 0.1 + assert v._state["voxel_slice_alpha"] == 0.8 + + +class TestPlaneWidget: + def test_add_plane_serialises(self): + v = _voxels() + pw = v.add_widget("plane", axis="z", position=4, color="#40c4ff") + ws = v._state["overlay_widgets"] + assert len(ws) == 1 + assert ws[0]["type"] == "plane" + assert ws[0]["axis"] == "z" + assert ws[0]["position"] == 4.0 + + def test_invalid_axis_raises(self): + v = _voxels() + with pytest.raises(ValueError, match="axis must be"): + v.add_widget("plane", axis="w", position=0) + + def test_only_plane_kind(self): + v = _voxels() + with pytest.raises(ValueError, match="only 'plane'"): + v.add_widget("crosshair") + + def test_set_position_from_python(self): + v = _voxels() + pw = v.add_widget("plane", axis="x", position=2) + pw.set(position=5) + assert pw.position == 5 + + def test_remove_widget(self): + v = _voxels() + pw = v.add_widget("plane", axis="y", position=3) + v.remove_widget(pw) + v._push() + assert v._state["overlay_widgets"] == [] + + def test_js_drag_event_round_trip(self): + """A JS plane-drag message must update position and fire callbacks.""" + v = _voxels() + pw = v.add_widget("plane", axis="z", position=4) + fig = v._fig + got = [] + + @pw.add_event_handler("pointer_move") + def on_drag(event): + got.append(pw.position) + + fig._dispatch_event(json.dumps({ + "panel_id": v._id, "widget_id": pw.id, + "event_type": "pointer_move", "axis": "z", "position": 6.25, + })) + assert got == [6.25] + assert pw.position == 6.25 + + +class TestVoxelRendering: + def test_voxels_render_with_slice_emphasis(self, interact_page): + """Voxels render; an on-plane slice draws more saturated ink.""" + colors = np.full((512, 3), [255, 0, 0], dtype=np.uint8) + v = _voxels(colors=colors, alpha=0.15) + v.set_axis_off() + v.add_widget("plane", axis="z", position=3, alpha=0.0) # invisible plane + page = interact_page(v._fig) + page.wait_for_timeout(250) + + 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 pale = 0, strong = 0; + for (let i = 0; i < d.length; i += 4) { + const r = d[i], g = d[i+1], b = d[i+2]; + if (r > 180 && g < 160 && b < 160) { + if (g > 60) pale++; else strong++; // strong = opaque red + } + } + return { pale, strong }; + }""") + assert res["pale"] > 500, f"translucent voxel ink missing: {res}" + assert res["strong"] > 200, f"opaque slice-plane voxels missing: {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) + v.set_axis_off() + pw = v.add_widget("plane", axis="z", position=3, alpha=0.3) + fig = v._fig + page = interact_page(fig) + page.wait_for_timeout(250) + + def js_position(): + return page.evaluate(f"""() => {{ + const st = JSON.parse(window._aplModel.get('panel_{v._id}_json')); + return st.overlay_widgets[0].position; + }}""") + + assert abs(js_position() - 3) < 1e-6 + # Locate the plane via its fully-opaque cyan border pixels, then drag + # from its centroid upward (the z screen-direction at the default view) + centre = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const r = c.getBoundingClientRect(); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let sx = 0, sy = 0, n = 0; + for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) { + const i = (y * c.width + x) * 4; + if (d[i] < 60 && d[i+1] > 200 && d[i+2] > 230) { + sx += x; sy += y; n++; + } + } + return n ? { x: r.left + sx / n, y: r.top + sy / n, n } : null; + }""") + assert centre is not None, "plane border pixels not found on canvas" + page.mouse.move(centre["x"], centre["y"]) + page.mouse.down() + page.mouse.move(centre["x"], centre["y"] - 50, steps=8) + page.mouse.up() + page.wait_for_timeout(250) + moved = js_position() + assert abs(moved - 3) > 0.5, ( + f"plane did not move on drag (position still {moved})") diff --git a/anyplotlib/widgets/_widgets3d.py b/anyplotlib/widgets/_widgets3d.py new file mode 100644 index 00000000..c3660534 --- /dev/null +++ b/anyplotlib/widgets/_widgets3d.py @@ -0,0 +1,50 @@ +""" +widgets/_widgets3d.py +===================== +Interactive overlay widgets for 3-D panels. +""" + +from __future__ import annotations + +from typing import Callable + +from anyplotlib.widgets._base import Widget + + +class PlaneWidget(Widget): + """A draggable axis-aligned plane in a 3-D panel. + + Rendered as a translucent quad spanning the panel's bounds, + perpendicular to *axis* at *position*. Drag it in the browser to slide + it along its normal — ideal as a slice selector for voxel volumes. + Voxels lying on a plane are rendered more opaque (see + :meth:`~anyplotlib.Axes.voxels`). + + Parameters + ---------- + axis : ``"x"`` | ``"y"`` | ``"z"`` + The plane's normal axis. + position : float + Position along *axis* in data coordinates. + color : str, optional + CSS colour of the plane fill and border. + alpha : float, optional + Fill opacity (0–1). Default 0.12. + + Examples + -------- + >>> pw = vol.add_widget("plane", axis="z", position=24) + >>> @pw.add_event_handler("pointer_move") + ... def on_drag(event): + ... print("slice now at", pw.position) + >>> pw.set(position=10) # move it from Python + """ + + def __init__(self, push_fn: Callable, axis: str = "z", + position: float = 0.0, color: str = "#00e5ff", + alpha: float = 0.12): + if axis not in ("x", "y", "z"): + raise ValueError(f"axis must be 'x', 'y', or 'z', got {axis!r}") + super().__init__("plane", push_fn, + axis=axis, position=float(position), + color=color, alpha=float(alpha)) diff --git a/docs/embedding.rst b/docs/embedding.rst new file mode 100644 index 00000000..b90a9ec8 --- /dev/null +++ b/docs/embedding.rst @@ -0,0 +1,181 @@ +================================= +Embedding outside Jupyter +================================= + +anyplotlib figures do not require Jupyter, ipywidgets, or the anywidget +runtime. The renderer is a single self-contained ES module +(``figure_esm.js``) that draws from a plain JSON state dict, so a figure can +live anywhere a browser engine runs: an **Electron** app, a Tauri/webview +app, an MDI-style multi-window workspace, a kiosk dashboard, or a static +web page. + +There are three levels of integration, from zero-Python-at-runtime to a +fully live Python backend. + +Level 1 — self-contained HTML (no Python at view time) +======================================================= + +Export the figure as a single HTML file with the renderer and all data +inlined:: + + import anyplotlib as apl + import numpy as np + + fig, ax = apl.subplots(1, 1, figsize=(800, 500)) + ax.imshow(np.load("frame.npy"), cmap="viridis") + fig.save_html("plot.html") + +Load it in an Electron window — that's the whole integration:: + + const { BrowserWindow } = require('electron'); + const win = new BrowserWindow({ width: 840, height: 560 }); + win.loadFile('plot.html'); + +Pan, zoom, overlay widgets, markers, and keyboard shortcuts all work; +Python callbacks (obviously) do not. ``fig.to_html()`` returns the same +page as a string if you want to serve or template it yourself. + +Level 2 — JS-driven: your app owns the data +============================================ + +Bundle ``figure_esm.js`` into your app (``anyplotlib.embed.esm_path()`` +tells you where to copy it from) and mount figures directly from +JavaScript: + +.. code-block:: javascript + + import { mount } from './figure_esm.js'; + + const handle = mount(document.getElementById('plot-host'), state, { + onEvent: (ev) => { + // every interaction event: pointer_down/up/move, wheel, key_down … + if (ev.event_type === 'pointer_down') + console.log('clicked data coords', ev.xdata, ev.ydata); + }, + }); + + // Live updates — replace one panel's state and it re-renders: + handle.setPanelState(panelId, newPanelState); + handle.resize(900, 600); + handle.dispose(); // remove the figure's DOM + +``state`` is the figure-state dict. Generate it from Python once (at build +time or via a one-shot script):: + + import json, anyplotlib as apl + from anyplotlib.embed import figure_state + + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(template_data) + json.dump(figure_state(fig), open("figure_state.json", "w")) + print("panel id:", plot._id) # key for setPanelState + +Each ``mount()`` call is fully independent — mount as many figures as you +like into separate containers in one window. This is the natural fit for +**MDI sub-windows**: give every sub-window its own host ``
`` (or +````/iframe for hard isolation) and call ``mount`` per window. +Call ``handle.resize(w, h)`` from your sub-window's resize hook. + +Level 3 — live Python backend (full callback support) +====================================================== + +Run Python next to your app (a sidecar process exposing a local WebSocket +is the common Electron pattern) and keep figures *fully* interactive — +``@plot.add_event_handler(...)`` callbacks fire exactly as in Jupyter. + +:class:`anyplotlib.embed.FigureBridge` is transport-agnostic: you supply +the pipe, it supplies the ``(key, value)`` protocol. + +**Python sidecar** (here with the ``websockets`` package):: + + import asyncio, json + import numpy as np + import websockets + import anyplotlib as apl + from anyplotlib.embed import FigureBridge + + fig, ax = apl.subplots(1, 1, figsize=(700, 450)) + plot = ax.imshow(np.random.rand(256, 256)) + cross = plot.add_widget("crosshair", cx=128, cy=128) + + async def serve(ws): + loop = asyncio.get_running_loop() + bridge = FigureBridge(fig, send=lambda key, value: + loop.create_task(ws.send(json.dumps({"key": key, "value": value})))) + await ws.send(json.dumps({"snapshot": bridge.snapshot()})) + + @cross.add_event_handler("pointer_move") # fires from Electron! + def follow(event): + print("crosshair at", cross.cx, cross.cy) + + async for message in ws: + m = json.loads(message) + bridge.receive(m["key"], m["value"]) # JS → Python + + asyncio.run(websockets.serve(serve, "localhost", 8765)) + +**Electron renderer**: + +.. code-block:: javascript + + import { mount } from './figure_esm.js'; + + const ws = new WebSocket('ws://localhost:8765'); + let handle = null; + + ws.onmessage = (msg) => { + const m = JSON.parse(msg.data); + if (m.snapshot) { + handle = mount(document.getElementById('plot-host'), m.snapshot, { + // forward every JS-side write (events, view changes) to Python + onSync: (key, value) => ws.send(JSON.stringify({ key, value })), + }); + } else if (handle) { + handle.applyUpdate(m.key, m.value); // Python → JS, echo-free + } + }; + +Any Python-side mutation — ``plot.set_data(...)``, markers, titles, layout +changes — streams to the window automatically; drags, clicks, and keys +stream back into your Python callbacks. Echo is suppressed in both +directions by the bridge and ``applyUpdate``. + +API reference +============= + +.. automodule:: anyplotlib.embed + :members: + :undoc-members: + +JS handle reference +------------------- + +``mount(el, state, opts) → handle`` + +================================ ============================================ +``handle.setPanelState(id, st)`` Replace one panel's state (dict or JSON + string) and re-render it. +``handle.set(key, value)`` Raw model write + sync flush. +``handle.get(key)`` Read any model key. +``handle.applyUpdate(key, v)`` Apply a Python-originated update without + echoing it back through ``onSync``. +``handle.resize(w, h)`` Resize the figure (CSS pixels). +``handle.dispose()`` Remove the figure's DOM and listeners. +``handle.model`` The underlying local model (advanced). +================================ ============================================ + +``opts.onEvent(ev)`` receives parsed interaction events (the same payloads +Python's :class:`~anyplotlib.Event` carries); ``opts.onSync(key, value)`` +receives every outbound model write for bridging to Python. + +Notes and caveats +================= + +* The state dict is the **wire format**, not a stable public schema — treat + panel-state internals as opaque where you can, and prefer regenerating + states from Python when upgrading anyplotlib versions. +* ``dispose()`` removes the figure's DOM; for hard teardown of all + window-level listeners, host each figure in its own iframe/webview and + drop the frame (this is also the most robust MDI isolation). +* One renderer file, no build step: ``figure_esm.js`` has no imports, so it + works with any bundler or directly as a ``