From ac6fe35ce5188187bbcac7d98788b575fe7cf7f8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 30 Jul 2026 21:27:50 -0500 Subject: [PATCH 1/5] feat: Add colormap projection onto surface --- AGENTS.md | 10 + README.md | 3 +- anyplotlib/FIGURE_ESM.md | 67 ++ anyplotlib/_utils.py | 86 ++ anyplotlib/axes/_axes.py | 22 +- anyplotlib/callbacks.py | 6 + anyplotlib/figure/_figure.py | 2 + anyplotlib/figure_esm.js | 759 ++++++++++++++++-- anyplotlib/plot3d/_plot3d.py | 185 ++++- .../test_interactive_examples.py | 1 + uv.lock | 2 +- 11 files changed, 1051 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed0a6f59..18a77256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,16 @@ make html make clean # wipe build artefacts ``` +**After editing `figure_esm.js`, docs need `make clean` first.** Sphinx Gallery +re-executes an example only when its *source* MD5 changes, and each example's +interactive widget page (`build/html/_static/viewer_widgets/*.html`, ~1 MB) is +generated during that execution with the renderer **inlined**. A plain +`make html` therefore rebuilds the pages and the Pyodide wheel but silently +serves widgets built from an older `figure_esm.js` — the figures still work, +so nothing looks broken; they just behave like the old renderer. Check with +`grep -c '' build/html/_static/viewer_widgets/_001.html` +when a JS change doesn't seem to reach the docs. + Changelog entries: add a fragment file to `upcoming_changes/` (e.g. `123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time. diff --git a/README.md b/README.md index 92392321..f662c0b8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ Python side for every frame. anyplotlib makes the opposite trade-off: - **Plot types** — `plot` (1-D lines with markers, linestyles, legends, log y), `imshow` (2-D images with colormaps, colorbars, scale bars, overlay masks), `pcolormesh` (non-uniform 2-D meshes), `bar` (grouped, horizontal, log, - value labels), and 3-D `plot_surface` / `scatter3d` / `plot3d`. + value labels), and 3-D `plot_surface` / `scatter3d` / `plot3d`. 3-D surfaces + can be wrapped in an image with `set_texture` — globes, planets, star charts. - **Layouts** — `subplots`, matplotlib-compatible `GridSpec` indexing (slices, spans, negative indices), `width_ratios`/`height_ratios`, `sharex`/`sharey` linked pan-zoom, and floating inset axes with diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index e3f8b90b..b160b4d9 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -404,6 +404,73 @@ triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). - **Reference sphere**: `st.sphere = {radius,color,alpha,wireframe}` draws a shaded silhouette disk + lat/long wireframe behind the geometry; far-side wireframe segments and scatter points are dimmed. +- **Surface textures** (`Plot3D.set_texture`, `geom_type 'surface'`): + `st.texture_url` (a `data:` URL) + `st.texture_uv_b64` (float32 per-vertex + `(u,v)`) ride the geom channel; `texture_alpha` / `texture_shade` / + `texture_cull` are light view fields. `_texEnsure(p, url)` decodes into + `p._3dTex` via an `Image` — asynchronous, so the first frame draws the + colormapped surface and the `onload` calls `_redrawPanel`. Each triangle is + then clipped and painted with an affine `drawImage` mapping texel space onto + the screen triangle (`ctx.transform`, **not** `setTransform` — the panel's + DPR scale must survive). Three things are easy to get wrong here: + - **Seams.** Neighbours each cover ~half the pixels on a shared edge, so + source-over leaves a mesh of background hairlines. Triangles are grown by + `_TEX_EXPAND` so they overlap — offsetting the three **edges** (a miter, + `_miter()`, capped at `_TEX_MITER_MAX`), never pushing vertices away from + the centroid: for the slivers a quad-split grid makes at a sphere's limb + the centroid sits on the long edges and barely moves them. + `test_no_seams_between_neighbouring_triangles` guards this. + - **`alpha < 1`.** Because the triangles overlap, drawing them translucent + double-composites every overlap into a scaly grid. The surface is instead + built opaque on a cached `p._3dTexOff` OffscreenCanvas (same DPR + transform) and blitted once at `texture_alpha`. + - **Shading.** `texture_shade` fills the clip black then draws the texture at + `globalAlpha = bright`, rather than darkening with a second translucent + pass (which would double-darken the overlaps). `bright` is Lambert against + a *camera-facing* normal (flipped when it points into the screen), so it + never depends on the grid's winding. + + A flat "sample one texel per small triangle" fast path was tried and removed: + it is ~3× cheaper per triangle but makes neighbours differ in colour, which + the seam overlap then widens into a visible herringbone. On Canvas2D, + textures therefore want a **coarse** grid (the image carries the detail) — + ~2k triangles orbits at ~7 ms/frame, ~32k at ~350 ms. **The WebGPU path + below removes that constraint entirely.** +- **Textured surfaces on WebGPU** (`_GPU_SURFACE_WGSL`, `_gpuInitSurfacePanel` + / `_gpuUploadSurface` / `_gpuDrawSurface`): indexed triangles with per-vertex + UVs and smooth normals, depth-tested, above `GPU_SURFACE_THRESHOLD` (2000 + faces) — measured 9k triangles 54 ms → 0.4 ms, 160k triangles 1.75 s → + 2.4 ms. Everything the Canvas2D path has to fake is free here: the depth + buffer replaces the per-frame painter's sort *and* makes `cull_backfaces` + unnecessary (`cullMode: 'none'` is correct for open and closed surfaces + alike); shared vertices mean no seams, so no miter expansion; and Lambert + runs per PIXEL against an interpolated normal. Normals are accumulated per + vertex at upload — `norm()` scales all axes by the same `2/maxR`, so a + data-space normal only needs the camera rotation (passed as `rot0..rot2`) to + reach view space. `_gpuWanted(st, texReady)` gates it: only textured + surfaces, and only once the `` has decoded. **`texture_alpha < 1` stays + on Canvas2D** — the overlapping-triangle composite that makes a translucent + skin look right has no cheap depth-buffer equivalent. + - **Mipmaps are mandatory**, not a nicety: without them a 1440×720 texture + minified onto a 300 px sphere aliases into sparkling noise, visibly worse + than Canvas2D (which gets mip-like filtering free from `drawImage` + + `imageSmoothingQuality`). WebGPU has no built-in mipmapper, so + `_gpuGenerateMips` downsamples level by level with a fullscreen-triangle + blit (`_GPU_MIP_WGSL`, pipeline cached per device+format). + - **Diagnosing "why is this on Canvas2D?"** — `draw3d` records + `globalThis.__apl_gpu3d[panelId]` every frame (`{geom, gpu, wanted, + texUrl, texReady, faces, mode, pw, ph, hasNavGpu}`), mirroring + `__apl_gpu2d`. It is the only way after the fact to tell "no adapter" from + "texture still decoding" from "panel had zero size at init" — and from "the + page is serving a stale inlined renderer", which is what a `make html` + without `make clean` produces (see AGENTS.md). + - **`_gpuMatrix`'s clip.z sign matters.** It must INCREASE with depth into + the screen for `depthCompare: 'less'` against a 1.0 clear to keep the + nearest fragment. It was originally negated, which inverted every + depth-tested GPU draw — scatter painted far points over near ones, and a + textured sphere rendered inside-out (you saw the far hemisphere). Voxels + never caught it because they disable depth writes. Pinned by + `tests/test_plot3d/test_gpu_depth.py`. - **Voxels** (`geom_type 'voxels'`): shaded translucent cubes at the vertex centres. `st.voxel_size`, `st.voxel_alpha`, `st.voxel_slice_alpha`. Performance design (budget ~3–6 µs/cube, ≤ ~20k cubes interactive): diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 6e12d3ff..f0353763 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -7,6 +7,7 @@ from __future__ import annotations import functools +import pathlib import numpy as np @@ -89,6 +90,91 @@ def _to_rgba_u8(data: np.ndarray) -> np.ndarray: return np.ascontiguousarray(data) +def _encode_png(rgba: np.ndarray) -> bytes: + """Encode an (H, W, 4) uint8 RGBA array as PNG bytes. + + Stdlib only (``zlib`` + ``struct``) — Pillow is deliberately not a + dependency. Every scanline uses filter type 0 (None), which costs a + little compression ratio but keeps this to a handful of lines; for the + smooth/procedural imagery textures are made of, zlib still shrinks the + raw RGBA by an order of magnitude, which is what matters when the bytes + have to cross a notebook comm as a base-64 data URL. + """ + import struct + import zlib + + h, w = rgba.shape[:2] + # One leading filter byte (0 = None) per scanline, then the RGBA row. + raw = np.zeros((h, w * 4 + 1), dtype=np.uint8) + raw[:, 1:] = rgba.reshape(h, w * 4) + + def _chunk(tag: bytes, data: bytes) -> bytes: + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + return (b"\x89PNG\r\n\x1a\n" + + _chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + _chunk(b"IDAT", zlib.compress(raw.tobytes(), 6)) + + _chunk(b"IEND", b"")) + + +#: ``(magic prefix, MIME type)`` for the raster formats every browser decodes. +_IMAGE_MAGIC: tuple = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), +) + + +def _sniff_image_mime(blob: bytes) -> "str | None": + """Return the MIME type of *blob* from its magic bytes, or ``None``.""" + for magic, mime in _IMAGE_MAGIC: + if blob.startswith(magic): + return mime + if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP": + return "image/webp" + return None + + +def _image_to_data_url(image) -> str: + """Return a ``data:`` URL the browser can decode into a texture. + + Accepts, in order of preference: + + * an already-encoded image — ``bytes`` of a PNG/JPEG/GIF/WebP, or a + ``str``/``os.PathLike`` path to such a file — passed through verbatim, + so a photo stays JPEG-compressed instead of being re-encoded; + * a ``data:`` URL string, returned unchanged; + * an ``(H, W, 3|4)`` array (uint8, or float 0–1) — anything + :func:`_to_rgba_u8` accepts, which includes a PIL image (it exposes the + array interface) — encoded as PNG. + """ + import base64 + import os + + if isinstance(image, (bytes, bytearray, memoryview)): + blob = bytes(image) + mime = _sniff_image_mime(blob) + if mime is None: + raise ValueError( + "texture bytes are not a recognised PNG/JPEG/GIF/WebP image") + return f"data:{mime};base64," + base64.b64encode(blob).decode("ascii") + + if isinstance(image, (str, os.PathLike)): + if isinstance(image, str) and image.startswith("data:"): + return image + blob = pathlib.Path(image).read_bytes() + mime = _sniff_image_mime(blob) + if mime is None: + raise ValueError( + f"{os.fspath(image)!r} is not a PNG/JPEG/GIF/WebP image") + return f"data:{mime};base64," + base64.b64encode(blob).decode("ascii") + + png = _encode_png(_to_rgba_u8(np.asarray(image))) + return "data:image/png;base64," + base64.b64encode(png).decode("ascii") + + def _normalize_image(data: np.ndarray, clim: "tuple | None" = None): """Normalise data to uint8, returning (img_u8, vmin, vmax) where vmin/vmax are the QUANTISATION endpoints the 8-bit codes span (the caller stores them as diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index 99b38220..d72ded9d 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -116,7 +116,10 @@ def plot_surface(self, X, Y, Z, *, colormap: str = "viridis", x_label: str = "x", y_label: str = "y", z_label: str = "z", azimuth: float = -60.0, elevation: float = 30.0, - zoom: float = 1.0) -> "Plot3D": + zoom: float = 1.0, + bounds=None, + texture=None, + gpu: "str | bool" = "auto") -> "Plot3D": """Attach a 3-D surface to this axes cell. Parameters @@ -128,6 +131,20 @@ def plot_surface(self, X, Y, Z, *, x_label, y_label, z_label : str, optional Axis labels. azimuth, elevation : float, optional Initial camera angles in degrees. zoom : float, optional Initial zoom factor. + bounds : ((xmin, xmax), (ymin, ymax), (zmin, zmax)), optional + Fix the axes bounds instead of fitting them to the data. Pass + ``((-1, 1),) * 3`` for a unit sphere so it projects as a true + circle rather than being stretched to fill the panel. + texture : array-like, bytes, or path, optional + Image to wrap around the surface — shorthand for + :meth:`~anyplotlib.Plot3D.set_texture`, which takes the mapping, + shading, and culling options. + gpu : ``"auto"`` | bool, optional + WebGPU acceleration policy for a *textured* surface (a + colormapped one always renders on Canvas2D). ``"auto"`` (default) + uses the GPU when available above ~2k triangles; ``True`` always + attempts it; ``False`` forces Canvas2D. Falls back silently when + WebGPU is unavailable — check :attr:`Plot3D.gpu_active`. Returns ------- @@ -135,7 +152,8 @@ def plot_surface(self, X, Y, Z, *, """ plot = Plot3D("surface", X, Y, Z, colormap=colormap, x_label=x_label, y_label=y_label, z_label=z_label, - azimuth=azimuth, elevation=elevation, zoom=zoom) + azimuth=azimuth, elevation=elevation, zoom=zoom, + bounds=bounds, texture=texture, gpu=gpu) self._attach(plot) return plot diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 47e656b6..d7c2a209 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -99,6 +99,12 @@ class Event: image_height: int | None = None display_width: int | None = None # panel device px (JS) → tile output resolution display_height: int | None = None + # Camera (Plot3D orbit) — the angles the drag just produced, in degrees. + # The JS has always sent these alongside `zoom`; without them a handler + # cannot react to an orbit, because a JS-side drag does not sync back into + # Plot3D._state. + azimuth: float | None = None + elevation: float | None = None # Key key: str | None = None last_widget_id: str | None = None diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index c6335a7e..8a002367 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -671,6 +671,8 @@ def _dispatch_event(self, raw: str) -> None: image_height=msg.get("image_height"), display_width=msg.get("display_width"), display_height=msg.get("display_height"), + azimuth=msg.get("azimuth"), + elevation=msg.get("elevation"), key=msg.get("key"), last_widget_id=msg.get("last_widget_id"), target=msg.get("target"), diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index e8fd0b73..84fa0812 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3516,6 +3516,68 @@ function render({ model, el, onResize }) { return `rgb(${c[0]},${c[1]},${c[2]})`; } + // ── 3D surface textures (Plot3D.set_texture) ───────────────────────────── + // st.texture_url is a data: URL and st.texture_uv_b64 the per-vertex (u, v) + // pairs; both ride the geometry channel. Image decode is asynchronous, so + // the frame that first sees a texture draws the colormapped surface and + // schedules a redraw once the bitmap is ready — the same progressive + // pattern the WebGPU init uses. Returns the ready cache entry or null. + // + // Every triangle is genuinely texture-mapped (clip + affine drawImage). + // A flat "sample one texel per small triangle" shortcut was tried and + // dropped: it is ~3× cheaper per triangle, but flat shading makes + // neighbours differ in colour, and the deliberate seam overlap below then + // widens each of those steps into a visible herringbone across the mesh. + // Textures therefore want a COARSE grid — the image carries the detail, so + // ~2k triangles (orbiting at ~7 ms/frame) looks better than ~32k flat ones. + function _texEnsure(p, url) { + const cache = (p._3dTex ||= {}); + if (cache.url === url) return cache.ready ? cache : null; + cache.url = url; cache.ready = false; cache.img = null; + if (!url) return null; + const img = new Image(); + img.onload = () => { + if (!p._3dTex || p._3dTex.url !== url || !panels.has(p.id)) return; + cache.img = img; + cache.tw = img.naturalWidth || img.width; + cache.th = img.naturalHeight || img.height; + cache.ready = cache.tw > 0 && cache.th > 0; + if (cache.ready) _redrawPanel(p); + }; + img.onerror = () => { + if (p._3dTex && p._3dTex.url === url) cache.ready = false; + console.warn('[anyplotlib] 3-D surface texture failed to decode'); + }; + img.src = url; + return null; + } + + // Light direction in VIEW space — (right, into-screen, up) — mostly from + // the camera with an upper-left bias, which reads as a lit sphere rather + // than a crescent. Dotted against a camera-facing normal, so shading is + // independent of the surface's triangle winding. + const _TEX_LIGHT = (() => { + const v = [-0.35, -0.85, 0.40]; + const m = Math.hypot(v[0], v[1], v[2]); + return [v[0]/m, v[1]/m, v[2]/m]; + })(); + + // Outward edge offset, in CSS px, applied to every textured triangle so + // neighbours overlap instead of leaving antialiased hairlines between them. + const _TEX_EXPAND = 0.75; + // Cap on how far a vertex may travel: the exact miter length runs away as a + // corner sharpens, and the near-degenerate slivers a sphere shows at its + // limb would otherwise spray far outside the surface. + const _TEX_MITER_MAX = 4.0; + + // Scale factor for a miter offset: `t·(nA+nB)/(1+nA·nB)`, length-clamped. + // `sx`/`sy` are nA+nB and `den` is 1+nA·nB. + function _miter(sx, sy, den) { + const k = _TEX_EXPAND / Math.max(1e-4, den); + const len = Math.hypot(sx, sy) * k; + return len > _TEX_MITER_MAX ? k * (_TEX_MITER_MAX / len) : k; + } + // ═══════════════════════════════════════════════════════════════════════ // WebGPU geometry renderer (progressive enhancement — Phase 1 prototype). // @@ -3538,6 +3600,12 @@ function render({ model, el, onResize }) { // this many pixels — below it the Canvas2D atob+LUT loop is already instant. // ~1 megapixel: a 1024² image and up (a large in-situ movie frame is 16-64 Mpx). const GPU_IMAGE_THRESHOLD = 1 << 20; + // A textured surface is worth the GPU almost immediately: each Canvas2D + // triangle costs a clip + affine drawImage (~6 µs), so a few thousand + // already misses 60 fps, while the GPU draws hundreds of thousands in one + // indexed pass. Below this a surface renders identically on canvas and + // skips the device-init round trip. + const GPU_SURFACE_THRESHOLD = 2000; let _gpuDevicePromise = null; // module singleton: Promise function _gpuDevice() { @@ -3590,11 +3658,23 @@ function render({ model, el, onResize }) { } // Should this 3-D panel try the GPU path for its current state? - function _gpuWanted(st) { + // `texReady` says a textured surface's image has finished decoding — a + // surface can only go to the GPU once there is something to sample. + function _gpuWanted(st, texReady) { if (typeof navigator === 'undefined' || !navigator.gpu) return false; const mode = st.gpu_mode || 'auto'; if (mode === 'off') return false; const geom = st.geom_type; + if (geom === 'surface') { + // Only TEXTURED surfaces have a GPU path; a colormapped one still draws + // its sorted, outlined triangles on Canvas2D. alpha < 1 also stays on + // canvas: the overlapping-triangle composite that makes a translucent + // skin look right has no cheap depth-buffer equivalent. + if (!st.texture_url || !texReady) return false; + if ((st.texture_alpha == null ? 1 : st.texture_alpha) < 1) return false; + if (mode === 'always') return true; + return (st.faces_count || 0) > GPU_SURFACE_THRESHOLD; + } if (geom !== 'scatter' && geom !== 'voxels') return false; if (mode === 'always') return true; const thr = geom === 'voxels' ? GPU_VOXEL_THRESHOLD : GPU_POINT_THRESHOLD; @@ -3754,6 +3834,137 @@ fn fs(in : VsOut) -> @location(0) vec4 { // The quad is fullscreen with NO zoom/pan: the GPU path is used only when the // view is unzoomed/uncentred (see _gpuWanted2d); a zoomed/panned view falls // back to Canvas2D so the base image stays registered with the axes/overlays. + // ── Textured surface (Plot3D.set_texture on the GPU) ───────────────────── + // Indexed triangles with per-vertex UVs and normals, depth-tested. Three + // things the Canvas2D path has to fake come for free here: + // • occlusion — the depth buffer replaces the per-frame painter's sort + // AND makes backface culling unnecessary (an open surface stays correct + // when viewed from behind, which `cull_backfaces` cannot manage); + // • seams — neighbouring triangles share vertices exactly, so there is no + // antialiased hairline to paper over with the miter expansion; + // • shading — Lambert per PIXEL against an interpolated vertex normal, + // instead of one flat value per triangle. + // `rot` is the camera rotation R. `norm()` scales all three axes by the + // same 2/maxR, so a data-space normal only needs rotating to reach view + // space (right, into-screen, up) — where the light lives. + // mat4x4 (64 B) + five vec4 (80 B). Keep in step with the struct below and + // with the float offsets written in _gpuDrawSurface. + const _GPU_SURFACE_UBO = 144; + + const _GPU_SURFACE_WGSL = ` +struct U { + mvp : mat4x4, + rot0 : vec4, + rot1 : vec4, + rot2 : vec4, + light : vec4, // xyz = light direction (view space), w = shade flag + misc : vec4, // x = surface alpha +}; +@group(0) @binding(0) var u : U; +@group(0) @binding(1) var samp : sampler; +@group(0) @binding(2) var tex : texture_2d; + +struct VsOut { + @builtin(position) pos : vec4, + @location(0) uv : vec2, + @location(1) nrm : vec3, +}; + +@vertex +fn vs(@location(0) p : vec3, + @location(1) uv : vec2, + @location(2) n : vec3) -> VsOut { + var o : VsOut; + o.pos = u.mvp * vec4(p, 1.0); + o.uv = uv; + o.nrm = vec3(dot(u.rot0.xyz, n), dot(u.rot1.xyz, n), dot(u.rot2.xyz, n)); + return o; +} + +@fragment +fn fs(in : VsOut) -> @location(0) vec4 { + var c = textureSample(tex, samp, in.uv); + if (u.light.w > 0.5) { + let n = normalize(in.nrm); + // Orient toward the camera (which sits at -y) so shading never depends on + // the grid's triangle winding — matching the Canvas2D path. + let o = select(1.0, -1.0, n.y > 0.0); + let lam = max(0.0, o * dot(n, u.light.xyz)); + c = vec4(c.rgb * (0.35 + 0.65 * lam), c.a); + } + return vec4(c.rgb, c.a * u.misc.x); +} +`; + + // ── Mipmap generation ───────────────────────────────────────────────────── + // WebGPU has no built-in mipmapper, and without one a 1440x720 sky texture + // minified onto a 300 px sphere aliases into sparkling noise — visibly WORSE + // than the Canvas2D path, which gets mip-like filtering free from + // drawImage + imageSmoothingQuality. So downsample level by level with a + // fullscreen-triangle blit. The pipeline is per (device, format) and cached. + const _GPU_MIP_WGSL = ` +@group(0) @binding(0) var samp : sampler; +@group(0) @binding(1) var src : texture_2d; +struct VsOut { @builtin(position) pos : vec4, @location(0) uv : vec2 }; +@vertex +fn vs(@builtin(vertex_index) vi : u32) -> VsOut { + var xy = array, 3>( + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + var o : VsOut; + o.pos = vec4(xy[vi], 0.0, 1.0); + o.uv = vec2((xy[vi].x + 1.0) * 0.5, (1.0 - xy[vi].y) * 0.5); + return o; +} +@fragment +fn fs(in : VsOut) -> @location(0) vec4 { return textureSample(src, samp, in.uv); } +`; + + let _gpuMipCache = null; // { device, pipeline, sampler } + + function _gpuMipTools(device, format) { + if (_gpuMipCache && _gpuMipCache.device === device + && _gpuMipCache.format === format) return _gpuMipCache; + const module = device.createShaderModule({ code: _GPU_MIP_WGSL }); + _gpuMipCache = { + device, format, + pipeline: device.createRenderPipeline({ + layout: 'auto', + vertex: { module, entryPoint: 'vs' }, + fragment: { module, entryPoint: 'fs', targets: [{ format }] }, + primitive: { topology: 'triangle-list' }, + }), + sampler: device.createSampler({ magFilter: 'linear', minFilter: 'linear' }), + }; + return _gpuMipCache; + } + + function _gpuGenerateMips(device, texture, format, levels) { + if (levels < 2) return; + const { pipeline, sampler } = _gpuMipTools(device, format); + const enc = device.createCommandEncoder(); + for (let lvl = 1; lvl < levels; lvl++) { + const bind = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: sampler }, + { binding: 1, resource: texture.createView({ + baseMipLevel: lvl - 1, mipLevelCount: 1 }) }, + ], + }); + const pass = enc.beginRenderPass({ + colorAttachments: [{ + view: texture.createView({ baseMipLevel: lvl, mipLevelCount: 1 }), + loadOp: 'clear', storeOp: 'store', + clearValue: { r: 0, g: 0, b: 0, a: 0 } }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bind); + pass.draw(3); + pass.end(); + } + device.queue.submit([enc.finish()]); + } + const _GPU_IMAGE_WGSL = ` // rect = the on-screen quad in CLIP space (x0,y0,x1,y1). For zoom<=1 this is the // fit-rect shrunk by zoom (centred); for zoom>=1 it's the full fit-rect. @@ -4397,6 +4608,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { // Opaque canvas: voxel alpha-blending happens inside the render pass over // the opaque background clear, so the canvas itself stays opaque. ctx.configure({ device, format: fmt, alphaMode: 'opaque' }); + if (geom === 'surface') { _gpuInitSurfacePanel(p, device, ctx, fmt); return; } const isVox = geom === 'voxels'; const module = device.createShaderModule({ code: isVox ? _GPU_VOXEL_WGSL : _GPU_POINT_WGSL }); @@ -4439,6 +4651,172 @@ fn fs(in : VsOut) -> @location(0) vec4 { count: 0, geomKey: null }; } + // ── Textured surface: pipeline, buffers, texture ───────────────────────── + function _gpuInitSurfacePanel(p, device, ctx, fmt) { + const module = device.createShaderModule({ code: _GPU_SURFACE_WGSL }); + const pipeline = device.createRenderPipeline({ + layout: 'auto', + vertex: { + module, entryPoint: 'vs', + buffers: [ + { arrayStride: 12, // position + attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }, + { arrayStride: 8, // uv + attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x2' }] }, + { arrayStride: 12, // normal + attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x3' }] }, + ], + }, + fragment: { module, entryPoint: 'fs', targets: [{ format: fmt, blend: { + // Only matters for a texture carrying its own per-texel alpha; an + // opaque one blends as a plain overwrite, so draw order is irrelevant. + color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' }, + alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' }, + } }] }, + // No culling: the depth buffer resolves occlusion exactly, so an open + // surface stays visible from behind (which `cull_backfaces` cannot do) + // and a closed one hides its own far side for free. + primitive: { topology: 'triangle-list', cullMode: 'none' }, + depthStencil: { format: 'depth24plus', + depthWriteEnabled: true, depthCompare: 'less' }, + }); + const uniformBuf = device.createBuffer({ + size: _GPU_SURFACE_UBO, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + const sampler = device.createSampler({ + magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear', + // U wraps so a sphere's 0h/24h seam interpolates across; V clamps so the + // poles do not bleed round to the opposite hemisphere. + addressModeU: 'repeat', addressModeV: 'clamp-to-edge', + }); + p._gpuObj = { device, ctx, fmt, pipeline, uniformBuf, sampler, + geom: 'surface', bindGroup: null, + posBuf: null, uvBuf: null, nrmBuf: null, idxBuf: null, + texture: null, texKey: null, depthTex: null, + count: 0, geomKey: null }; + } + + function _gpuUploadSurface(p, tex) { + const g = p._gpuObj, st = p.state, device = g.device; + const key = (st.vertices_b64 || '') + '|' + (st.faces_b64 || '') + + '|' + (st.texture_uv_b64 || ''); + if (g.geomKey !== key) { + g.geomKey = key; + for (const b of ['posBuf', 'uvBuf', 'nrmBuf', 'idxBuf']) { + if (g[b]) { g[b].destroy(); g[b] = null; } + } + const verts = p._3dVerts || [], uv = p._3dUV; + const idx = p._3dFacesFlat; + const n = verts.length; + if (!n || !uv || !idx || !idx.length) { g.count = 0; return; } + + const pos = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { + pos[i*3] = verts[i][0]; pos[i*3+1] = verts[i][1]; pos[i*3+2] = verts[i][2]; + } + // Smooth vertex normals: accumulate each face's normal onto its three + // corners. The absolute sign does not matter (the shader orients toward + // the camera), only that neighbouring faces agree — which a grid + // triangulation guarantees. This is what buys per-pixel shading. + const nrm = new Float32Array(n * 3); + for (let f = 0; f + 2 < idx.length; f += 3) { + const a = idx[f], b = idx[f+1], c = idx[f+2]; + const ax = pos[a*3], ay = pos[a*3+1], az = pos[a*3+2]; + const e1x = pos[b*3] - ax, e1y = pos[b*3+1] - ay, e1z = pos[b*3+2] - az; + const e2x = pos[c*3] - ax, e2y = pos[c*3+1] - ay, e2z = pos[c*3+2] - az; + const fx = e1y*e2z - e1z*e2y; + const fy = e1z*e2x - e1x*e2z; + const fz = e1x*e2y - e1y*e2x; + for (const v of [a, b, c]) { + nrm[v*3] += fx; nrm[v*3+1] += fy; nrm[v*3+2] += fz; + } + } + for (let i = 0; i < n; i++) { + const x = nrm[i*3], y = nrm[i*3+1], z = nrm[i*3+2]; + const L = Math.hypot(x, y, z); + if (L > 0) { nrm[i*3] = x/L; nrm[i*3+1] = y/L; nrm[i*3+2] = z/L; } + else { nrm[i*3+2] = 1; } + } + // UVs may be shorter than the vertex count only if state is mid-update; + // the caller already gates on length, so copy straight through. + const uvs = new Float32Array(uv.buffer, uv.byteOffset, n * 2); + + const mk = (data, usage) => { + const buf = device.createBuffer({ + size: Math.max(16, data.byteLength), usage: usage | GPUBufferUsage.COPY_DST }); + device.queue.writeBuffer(buf, 0, data); + return buf; + }; + g.posBuf = mk(pos, GPUBufferUsage.VERTEX); + g.uvBuf = mk(uvs, GPUBufferUsage.VERTEX); + g.nrmBuf = mk(nrm, GPUBufferUsage.VERTEX); + // Indices are non-negative, so the int32 bit patterns are already uint32. + g.idxBuf = mk(new Uint32Array(idx.buffer, idx.byteOffset, idx.length), + GPUBufferUsage.INDEX); + g.count = idx.length; + } + + // Texture: uploaded from the decoded , with a full mip chain. + if (g.texKey !== tex.url) { + const lim = device.limits.maxTextureDimension2D || 8192; + if (tex.tw > lim || tex.th > lim) { + throw new Error(`texture ${tex.tw}x${tex.th} exceeds the device limit ${lim}`); + } + if (g.texture) g.texture.destroy(); + const levels = 1 + Math.floor(Math.log2(Math.max(tex.tw, tex.th))); + g.texture = device.createTexture({ + size: [tex.tw, tex.th], format: 'rgba8unorm', mipLevelCount: levels, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + | GPUTextureUsage.RENDER_ATTACHMENT }); + device.queue.copyExternalImageToTexture( + { source: tex.img }, { texture: g.texture }, [tex.tw, tex.th]); + _gpuGenerateMips(device, g.texture, 'rgba8unorm', levels); + g.texKey = tex.url; + g.bindGroup = device.createBindGroup({ + layout: g.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: g.uniformBuf } }, + { binding: 1, resource: g.sampler }, + { binding: 2, resource: g.texture.createView() }, + ], + }); + } + } + + function _gpuDrawSurface(p, R, scale, cx, cy) { + const g = p._gpuObj; + if (!g || !g.count || !g.bindGroup) return; + const st = p.state, device = g.device; + const { W, H, dpr } = _gpuEnsureSize(p); + const gm = p._gpuGeom; + const mvp = _gpuMatrix(R, scale * dpr, cx * dpr, cy * dpr, W, H, + gm.bnds, gm.maxR, gm.xr, gm.yr, gm.zr); + // Uniform layout, in floats: mvp 0..15 | rot0 16..19 | rot1 20..23 | + // rot2 24..27 | light.xyz + shade flag 28..31 | misc.x = alpha 32..35. + // 36 floats = 144 bytes (mat4 + five vec4), matching _GPU_SURFACE_UBO. + const u = new Float32Array(36); + u.set(mvp, 0); + u[16] = R[0][0]; u[17] = R[0][1]; u[18] = R[0][2]; + u[20] = R[1][0]; u[21] = R[1][1]; u[22] = R[1][2]; + u[24] = R[2][0]; u[25] = R[2][1]; u[26] = R[2][2]; + u[28] = _TEX_LIGHT[0]; u[29] = _TEX_LIGHT[1]; u[30] = _TEX_LIGHT[2]; + u[31] = st.texture_shade ? 1 : 0; + u[32] = st.texture_alpha == null ? 1 : st.texture_alpha; + device.queue.writeBuffer(g.uniformBuf, 0, u); + + const enc = device.createCommandEncoder(); + const pass = _gpuBeginPass(g, enc); + pass.setPipeline(g.pipeline); + pass.setBindGroup(0, g.bindGroup); + pass.setVertexBuffer(0, g.posBuf); + pass.setVertexBuffer(1, g.uvBuf); + pass.setVertexBuffer(2, g.nrmBuf); + pass.setIndexBuffer(g.idxBuf, 'uint32'); + pass.drawIndexed(g.count); + pass.end(); + device.queue.submit([enc.finish()]); + } + function _gpuUploadGeometry(p) { const g = p._gpuObj, st = p.state, device = g.device; // Key on BOTH geometry and colours: the orthoslice explorer recolours @@ -4495,7 +4873,16 @@ fn fs(in : VsOut) -> @location(0) vec4 { // Substituting n = k*v - o, each clip component is affine in v: // clip.x = (2*scale*k/pw)*(r0·v) + [ -(2*scale/pw)*(r0·o) + (2*cx/pw - 1) ] // clip.y = -(2*scale*k/ph)*(r2·v) + [ (2*scale/ph)*(r2·o) + (1 - 2*cy/ph) ] - // clip.z = -0.35*k*(r1·v) + [ 0.35*(r1·o) + 0.5 ] + // clip.z = +0.35*k*(r1·v) + [ -0.35*(r1·o) + 0.5 ] → 0.5 + 0.35*depth + // + // DEPTH SIGN: r1·n is depth INTO the screen, so clip.z must INCREASE with it + // for `depthCompare: 'less'` against a 1.0 clear to keep the nearest + // fragment. This row used to be negated, which inverted every depth-tested + // GPU draw — a scatter cloud painted its far points over its near ones, and + // a textured surface rendered inside-out (you saw the far hemisphere). It + // went unnoticed because voxels, the other GPU consumer, disable depth + // writes entirely. The range stays inside [0,1] for the normalised + // [-1,1]³ box the renderer works in. // // Build the 4 ROWS, then transpose into WGSL's column-major storage. function _gpuMatrix(R, scale, cx, cy, pw, ph, bnds, maxR, xr, yr, zr) { @@ -4513,7 +4900,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { // negations CANCEL, so rowY's coefficients are +sy*r2, not -sy*r2. const rowX = [ sx*r0[0], sx*r0[1], sx*r0[2], -(2*scale/pw)*r0o + (2*cx/pw - 1) ]; const rowY = [ sy*r2[0], sy*r2[1], sy*r2[2], -(2*scale/ph)*r2o + (1 - 2*cy/ph) ]; - const rowZ = [-0.35*k*r1[0], -0.35*k*r1[1], -0.35*k*r1[2], 0.35*r1o + 0.5 ]; + const rowZ = [ 0.35*k*r1[0], 0.35*k*r1[1], 0.35*k*r1[2], -0.35*r1o + 0.5 ]; const rowW = [0, 0, 0, 1]; // Column-major: column j = [rowX[j], rowY[j], rowZ[j], rowW[j]] return new Float32Array([ @@ -4646,6 +5033,11 @@ fn fs(in : VsOut) -> @location(0) vec4 { g.colBuf && g.colBuf.destroy(); g.depthTex && g.depthTex.destroy(); g.uniformBuf && g.uniformBuf.destroy(); + // Textured-surface resources (the mip-chained texture can be tens of MB). + g.uvBuf && g.uvBuf.destroy(); + g.nrmBuf && g.nrmBuf.destroy(); + g.idxBuf && g.idxBuf.destroy(); + g.texture && g.texture.destroy(); } catch (_) {} p._gpuObj = null; } @@ -4699,12 +5091,18 @@ fn fs(in : VsOut) -> @location(0) vec4 { const arr = new Array(nf); for (let i = 0; i < nf; i++) arr[i] = [ff[i*3], ff[i*3+1], ff[i*3+2]]; p._3dFaces = arr; - } else { p._3dFaces = st.faces || []; } + p._3dFacesFlat = ff; // straight to a GPU index buffer + } else { p._3dFaces = st.faces || []; p._3dFacesFlat = null; } } if (p._3dZKey !== zKey) { p._3dZKey = zKey; p._3dZVals = zKey ? _decodeF32(zKey) : (st.z_values || []); } + const uvKey = st.texture_uv_b64 || ''; + if (p._3dUVKey !== uvKey) { + p._3dUVKey = uvKey; + p._3dUV = uvKey ? _decodeF32(uvKey) : null; + } const verts = p._3dVerts || []; const faces = p._3dFaces || []; const zVals = p._3dZVals || []; @@ -4736,7 +5134,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { const cx = pw / 2, cy = ph / 2; const scale = zoom * Math.min(pw, ph) * 0.32; - // ── WebGPU geometry path (instanced points + voxels) ────────────────── + // Resolve a surface texture BEFORE the GPU decision — a surface only wants + // the GPU once its image has decoded, and _texEnsure is what starts that + // (returning null and scheduling a redraw until the bitmap is ready). + const surfTex = (geom === 'surface' && p._3dUV + && p._3dUV.length >= verts.length * 2) + ? _texEnsure(p, st.texture_url || '') : null; + + // ── WebGPU geometry path (instanced points + voxels, textured surfaces) ─ // Decide once whether this panel renders geometry on the GPU. On the // first frame that wants it, kick off async device init; until it // resolves (or if it fails) the canvas path below runs unchanged. @@ -4744,7 +5149,15 @@ fn fs(in : VsOut) -> @location(0) vec4 { let gpuActive = false; const gpuGeomChanged = p._gpuObj && p._gpuObj.geom !== geom; if (gpuGeomChanged) { _gpuDisposePanel(p); p._gpu = undefined; } - if (p.kind === '3d' && _gpuWanted(st)) { + // Test/diagnostic hook, mirroring __apl_gpu2d: what did the GPU decision + // depend on this frame? Cheap, and the only way to tell "no adapter" from + // "texture not decoded yet" from "panel had zero size" after the fact. + try { (globalThis.__apl_gpu3d ||= {})[p.id] = { + geom, gpu: p._gpu, wanted: _gpuWanted(st, !!surfTex), + texUrl: !!st.texture_url, texReady: !!surfTex, + faces: st.faces_count || 0, mode: st.gpu_mode, pw, ph, + hasNavGpu: typeof navigator !== 'undefined' && !!navigator.gpu }; } catch (_) {} + if (p.kind === '3d' && _gpuWanted(st, !!surfTex)) { // Zero-drawable-size guard: SpyDE (and any host) mounts a 3-D panel HIDDEN // (display:none) and reveals it later, so the FIRST draw3d can run with // pw/ph == 0 (a zero-size layout) and the gpuCanvas collapsed to 0 client @@ -4814,9 +5227,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { p.plotCanvas.style.background = 'transparent'; ctx.clearRect(0, 0, pw, ph); try { - _gpuUploadGeometry(p); - if (geom === 'voxels') _gpuDrawVoxels(p, R, scale, cx, cy); - else _gpuDrawPoints(p, R, scale, cx, cy); + if (geom === 'surface') { + _gpuUploadSurface(p, surfTex); + _gpuDrawSurface(p, R, scale, cx, cy); + } else { + _gpuUploadGeometry(p); + if (geom === 'voxels') _gpuDrawVoxels(p, R, scale, cx, cy); + else _gpuDrawPoints(p, R, scale, cx, cy); + } } catch (e) { console.warn('[anyplotlib] GPU draw failed — falling back:', e); p._gpu = 'unavailable'; gpuActive = false; @@ -4910,7 +5328,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { ctx.restore(); } - if (geom === 'surface' && faces.length > 0) { + if (geom === 'surface' && faces.length > 0 && !gpuActive) { + // An image texture replaces the Z colormap when one is attached AND + // decoded; until the bitmap loads (or if it fails) the colormap path + // below renders unchanged. Already resolved above, because the GPU + // decision depends on it. + const uv = p._3dUV; + const tex = surfTex; + // Compute per-face mean depth and mean z for colour const faceData = faces.map(f => { const d = (proj[f[0]].d + proj[f[1]].d + proj[f[2]].d) / 3; @@ -4920,20 +5345,172 @@ fn fs(in : VsOut) -> @location(0) vec4 { // Painter's algorithm: draw back-to-front faceData.sort((a, b) => b.d - a.d); - for (const { f, zMean } of faceData) { - const t = (zMean - zMin) / zRange; - const fc = _colourFromLut(lut, t); - const [ax2, ay2] = proj[f[0]].s; - const [bx, by ] = proj[f[1]].s; - const [ccx2,ccy2] = proj[f[2]].s; - ctx.beginPath(); - ctx.moveTo(ax2, ay2); ctx.lineTo(bx, by); ctx.lineTo(ccx2, ccy2); - ctx.closePath(); - ctx.fillStyle = fc; - ctx.fill(); - ctx.strokeStyle = 'rgba(0,0,0,0.12)'; - ctx.lineWidth = 0.4; - ctx.stroke(); + if (tex) { + const texA = st.texture_alpha == null ? 1 : st.texture_alpha; + const shade = !!st.texture_shade; + const cull = !!st.texture_cull; + const tw = tex.tw, th = tex.th; + + // A translucent surface has to be built opaque on a scratch canvas and + // composited ONCE, because the triangles deliberately overlap (see the + // seam note below): drawing them at globalAlpha < 1 straight onto the + // panel would double-composite every overlap and etch the mesh into + // the surface as a scaly grid. + let dctx = ctx, off = null; + if (texA < 1) { + off = (p._3dTexOff ||= new OffscreenCanvas(1, 1)); + if (off.width !== ctx.canvas.width || off.height !== ctx.canvas.height) { + off.width = ctx.canvas.width; + off.height = ctx.canvas.height; + } + dctx = off.getContext('2d'); + dctx.setTransform(1, 0, 0, 1, 0, 0); + dctx.clearRect(0, 0, off.width, off.height); + dctx.setTransform(ctx.getTransform()); // inherit the DPR scale + } + const passA = off ? 1 : texA; // alpha applied at composite + + dctx.save(); + dctx.imageSmoothingEnabled = true; + dctx.imageSmoothingQuality = 'high'; + for (const { f } of faceData) { + const i0 = f[0], i1 = f[1], i2 = f[2]; + const [x0, y0] = proj[i0].s; + const [x1, y1] = proj[i1].s; + const [x2, y2] = proj[i2].s; + + // View-space edge vectors, recovered from the screen position and + // depth (all three components carry the same `scale` factor, which + // cancels out of a normal direction). The mesh centre is the + // origin here: `norm` maps the data-bounds midpoint to (0,0,0). + const ax3 = x0 - cx, ay3 = proj[i0].d * scale, az3 = cy - y0; + const e1x = (x1 - cx) - ax3, e1y = proj[i1].d * scale - ay3, e1z = (cy - y1) - az3; + const e2x = (x2 - cx) - ax3, e2y = proj[i2].d * scale - ay3, e2z = (cy - y2) - az3; + let nx = e1y * e2z - e1z * e2y; + let ny = e1z * e2x - e1x * e2z; + let nz = e1x * e2y - e1y * e2x; + const nLen = Math.hypot(nx, ny, nz); + if (!(nLen > 0)) continue; // degenerate (pole) triangle + nx /= nLen; ny /= nLen; nz /= nLen; + + if (cull) { + // Orient the normal outward per face using the vector from the + // mesh centre to the face centroid — no dependence on the + // triangulation's winding, and exact for any star-shaped closed + // surface (sphere, ellipsoid, blob). Skip it when it points + // away from the camera, which sits at -y. + const gx3 = ax3 + (e1x + e2x) / 3; + const gy3 = ay3 + (e1y + e2y) / 3; + const gz3 = az3 + (e1z + e2z) / 3; + const s = (nx * gx3 + ny * gy3 + nz * gz3) >= 0 ? 1 : -1; + if (s * ny > 0) continue; + } + + // Lambert against a camera-facing normal (flip when it points into + // the screen). Orienting by the camera rather than by triangle + // winding makes the shading independent of how the grid was + // generated, while still keeping the light's upper-left bias that + // an unsigned |n·L| would wash out. + let bright = 1; + if (shade) { + const o = ny > 0 ? -1 : 1; + const lam = o * (nx * _TEX_LIGHT[0] + ny * _TEX_LIGHT[1] + + nz * _TEX_LIGHT[2]); + bright = 0.35 + 0.65 * (lam > 0 ? lam : 0); + } + + // Grow the triangle before clipping. Adjacent triangles each cover + // about half the pixels along their shared edge, and source-over of + // two half-covered fills leaves a hairline of background showing — + // a mesh etched over the whole surface. Overlapping them paints + // those pixels fully; the overlap is harmless because the nearer + // triangle simply wins and everything inside is drawn opaque. + // + // The growth must offset the three EDGES, not push the vertices + // away from the centroid: for the sliver triangles a quad-split + // grid produces near a sphere's limb, the centroid lies almost on + // the long edges, so a vertex nudge moves them barely at all. + // Each vertex therefore travels along its miter direction, + // t·(nA + nB)/(1 + nA·nB) for unit outward edge normals — clamped + // at a very sharp corner, where the exact miter runs away. + const gx = (x0 + x1 + x2) / 3, gy = (y0 + y1 + y2) / 3; + let n0x = y1 - y0, n0y = x0 - x1; // outward normals, edge i → i+1 + let n1x = y2 - y1, n1y = x1 - x2; + let n2x = y0 - y2, n2y = x2 - x0; + const l0 = Math.hypot(n0x, n0y) || 1, l1 = Math.hypot(n1x, n1y) || 1, + l2 = Math.hypot(n2x, n2y) || 1; + n0x /= l0; n0y /= l0; n1x /= l1; n1y /= l1; n2x /= l2; n2y /= l2; + if (n0x * ((x0 + x1) / 2 - gx) + n0y * ((y0 + y1) / 2 - gy) < 0) { + n0x = -n0x; n0y = -n0y; n1x = -n1x; n1y = -n1y; n2x = -n2x; n2y = -n2y; + } + const m01 = _miter(n0x + n1x, n0y + n1y, 1 + n0x*n1x + n0y*n1y); + const m12 = _miter(n1x + n2x, n1y + n2y, 1 + n1x*n2x + n1y*n2y); + const m20 = _miter(n2x + n0x, n2y + n0y, 1 + n2x*n0x + n2y*n0y); + const ex0 = x0 + (n2x + n0x) * m20, ey0 = y0 + (n2y + n0y) * m20; + const ex1 = x1 + (n0x + n1x) * m01, ey1 = y1 + (n0y + n1y) * m01; + const ex2 = x2 + (n1x + n2x) * m12, ey2 = y2 + (n1y + n2y) * m12; + + // Texture coordinates in texel units. A degenerate UV triangle has + // no affine map onto the screen; skip it (it covers no texels). + const u0 = uv[i0*2] * tw, v0 = uv[i0*2+1] * th; + const u1 = uv[i1*2] * tw, v1 = uv[i1*2+1] * th; + const u2 = uv[i2*2] * tw, v2 = uv[i2*2+1] * th; + const den = (u1 - u0) * (v2 - v0) - (u2 - u0) * (v1 - v0); + if (Math.abs(den) < 1e-9) continue; + + // Affine map taking (u, v) texel space onto the screen triangle. + const a = ((x1-x0)*(v2-v0) - (x2-x0)*(v1-v0)) / den; + const b = ((y1-y0)*(v2-v0) - (y2-y0)*(v1-v0)) / den; + const c = ((x2-x0)*(u1-u0) - (x1-x0)*(u2-u0)) / den; + const d = ((y2-y0)*(u1-u0) - (y1-y0)*(u2-u0)) / den; + + dctx.save(); + dctx.beginPath(); + dctx.moveTo(ex0, ey0); dctx.lineTo(ex1, ey1); dctx.lineTo(ex2, ey2); + dctx.closePath(); + dctx.clip(); + if (shade) { + // Diffuse falloff as an opaque black base the texture is drawn + // over at `bright` — modulating this way keeps the seam overlap + // free of the double-darkened grid a translucent second pass + // would leave. + dctx.globalAlpha = passA; + dctx.fillStyle = '#000'; + dctx.fill(); + } + dctx.globalAlpha = passA * bright; + // transform(), not setTransform(): the panel's device-pixel-ratio + // scale is already on the context and must survive. + dctx.transform(a, b, c, d, x0 - a*u0 - c*v0, y0 - b*u0 - d*v0); + dctx.drawImage(tex.img, 0, 0); + dctx.restore(); + } + dctx.restore(); + + if (off) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); // off is already device-sized + ctx.globalAlpha = texA; + ctx.drawImage(off, 0, 0); + ctx.restore(); + } + + } else { + for (const { f, zMean } of faceData) { + const t = (zMean - zMin) / zRange; + const fc = _colourFromLut(lut, t); + const [ax2, ay2] = proj[f[0]].s; + const [bx, by ] = proj[f[1]].s; + const [ccx2,ccy2] = proj[f[2]].s; + ctx.beginPath(); + ctx.moveTo(ax2, ay2); ctx.lineTo(bx, by); ctx.lineTo(ccx2, ccy2); + ctx.closePath(); + ctx.fillStyle = fc; + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.12)'; + ctx.lineWidth = 0.4; + ctx.stroke(); + } } } else if (geom === 'scatter' && !gpuActive) { @@ -5220,65 +5797,70 @@ fn fs(in : VsOut) -> @location(0) vec4 { } // ── Draw axes ──────────────────────────────────────────────────────────── - const axisVerts = [ - [-1,0,0],[1,0,0],[0,-1,0],[0,1,0],[0,0,-1],[0,0,1] - ]; - const ap = axisVerts.map(v => _project3(_applyRot(R, v), cx, cy, scale)); - - const axDefs = [ - { i0:0, i1:1, label: st.x_label||'x', col:'#e06c75', size: st.x_label_size||11 }, - { i0:2, i1:3, label: st.y_label||'y', col:'#98c379', size: st.y_label_size||11 }, - { i0:4, i1:5, label: st.z_label||'z', col:'#61afef', size: st.z_label_size||11 }, - ]; - for (const { i0, i1, label, col, size } of axDefs) { - ctx.beginPath(); - ctx.moveTo(ap[i0][0], ap[i0][1]); - ctx.lineTo(ap[i1][0], ap[i1][1]); - ctx.strokeStyle = col; - ctx.lineWidth = 1.5; - ctx.setLineDash([4, 3]); - ctx.stroke(); - ctx.setLineDash([]); - // Positive-end label - ctx.fillStyle = col; - ctx.textBaseline = 'middle'; - _drawTex(ctx, label, ap[i1][0], ap[i1][1], size, - { align: 'center', weight: 'bold' }); - } + // set_axis_off() hides the axis lines, labels, and ticks — worth having on + // a 3-D panel whose geometry IS the subject (a textured globe reads badly + // with three dashed lines drawn through it). + if (st.axis_visible !== false) { + const axisVerts = [ + [-1,0,0],[1,0,0],[0,-1,0],[0,1,0],[0,0,-1],[0,0,1] + ]; + const ap = axisVerts.map(v => _project3(_applyRot(R, v), cx, cy, scale)); - // ── Tick marks on each axis (5 evenly spaced) ───────────────────────── - ctx.font = '9px sans-serif'; - ctx.fillStyle = theme.tickText; - const NTICK = 5; - const axisData = [ - { lo: bnds.xmin, hi: bnds.xmax, baseN: [0,0,0], dir: [1/maxR*2,0,0] }, - { lo: bnds.ymin, hi: bnds.ymax, baseN: [0,0,0], dir: [0,1/maxR*2,0] }, - { lo: bnds.zmin, hi: bnds.zmax, baseN: [0,0,0], dir: [0,0,1/maxR*2] }, - ]; - const axisColours = ['#e06c75','#98c379','#61afef']; - for (let ai = 0; ai < 3; ai++) { - const { lo, hi } = axisData[ai]; - const range = hi - lo || 1; - const step = findNice(range / NTICK); - ctx.fillStyle = axisColours[ai]; - ctx.strokeStyle = axisColours[ai]; - ctx.lineWidth = 0.8; - for (let tv = Math.ceil(lo / step) * step; tv <= hi + step * 0.01; tv += step) { - const t = (tv - lo) / range; // 0..1 - // Normalised position on the axis - let nv; - if (ai === 0) nv = [2*t*xr/maxR - xr/maxR, -yr/maxR, -zr/maxR]; - else if(ai===1) nv = [-xr/maxR, 2*t*yr/maxR - yr/maxR, -zr/maxR]; - else nv = [-xr/maxR, -yr/maxR, 2*t*zr/maxR - zr/maxR]; - const [tx, ty] = _project3(_applyRot(R, nv), cx, cy, scale); - // Small tick cross + const axDefs = [ + { i0:0, i1:1, label: st.x_label||'x', col:'#e06c75', size: st.x_label_size||11 }, + { i0:2, i1:3, label: st.y_label||'y', col:'#98c379', size: st.y_label_size||11 }, + { i0:4, i1:5, label: st.z_label||'z', col:'#61afef', size: st.z_label_size||11 }, + ]; + for (const { i0, i1, label, col, size } of axDefs) { ctx.beginPath(); - ctx.arc(tx, ty, 1.5, 0, Math.PI * 2); - ctx.fill(); - // Label (only every other tick to avoid crowding) - ctx.textAlign = 'left'; - ctx.textBaseline = 'bottom'; - ctx.fillText(fmtVal(tv), tx + 3, ty - 1); + ctx.moveTo(ap[i0][0], ap[i0][1]); + ctx.lineTo(ap[i1][0], ap[i1][1]); + ctx.strokeStyle = col; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.stroke(); + ctx.setLineDash([]); + // Positive-end label + ctx.fillStyle = col; + ctx.textBaseline = 'middle'; + _drawTex(ctx, label, ap[i1][0], ap[i1][1], size, + { align: 'center', weight: 'bold' }); + } + + // ── Tick marks on each axis (5 evenly spaced) ───────────────────────── + ctx.font = '9px sans-serif'; + ctx.fillStyle = theme.tickText; + const NTICK = 5; + const axisData = [ + { lo: bnds.xmin, hi: bnds.xmax, baseN: [0,0,0], dir: [1/maxR*2,0,0] }, + { lo: bnds.ymin, hi: bnds.ymax, baseN: [0,0,0], dir: [0,1/maxR*2,0] }, + { lo: bnds.zmin, hi: bnds.zmax, baseN: [0,0,0], dir: [0,0,1/maxR*2] }, + ]; + const axisColours = ['#e06c75','#98c379','#61afef']; + for (let ai = 0; ai < 3; ai++) { + const { lo, hi } = axisData[ai]; + const range = hi - lo || 1; + const step = findNice(range / NTICK); + ctx.fillStyle = axisColours[ai]; + ctx.strokeStyle = axisColours[ai]; + ctx.lineWidth = 0.8; + for (let tv = Math.ceil(lo / step) * step; tv <= hi + step * 0.01; tv += step) { + const t = (tv - lo) / range; // 0..1 + // Normalised position on the axis + let nv; + if (ai === 0) nv = [2*t*xr/maxR - xr/maxR, -yr/maxR, -zr/maxR]; + else if(ai===1) nv = [-xr/maxR, 2*t*yr/maxR - yr/maxR, -zr/maxR]; + else nv = [-xr/maxR, -yr/maxR, 2*t*zr/maxR - zr/maxR]; + const [tx, ty] = _project3(_applyRot(R, nv), cx, cy, scale); + // Small tick cross + ctx.beginPath(); + ctx.arc(tx, ty, 1.5, 0, Math.PI * 2); + ctx.fill(); + // Label (only every other tick to avoid crowding) + ctx.textAlign = 'left'; + ctx.textBaseline = 'bottom'; + ctx.fillText(fmtVal(tv), tx + 3, ty - 1); + } } } @@ -5444,8 +6026,15 @@ fn fs(in : VsOut) -> @location(0) vec4 { const {mx:_d3mx2, my:_d3my2} = _clientPos(e, overlayCanvas, p.pw, p.ph); const dx = _d3mx2 - dragStart.mx; const dy = _d3my2 - dragStart.my; - p.state.azimuth = dragStart.az + dx * 0.5; - p.state.elevation = Math.max(-89, Math.min(89, dragStart.el - dy * 0.5)); + // DIRECT MANIPULATION: the surface under the cursor follows the cursor, + // the way a globe does. Both signs are inverted relative to the camera + // angles because azimuth/elevation move the CAMERA: a feature's screen x + // is `cx + scale*cos(az - θ)`, so raising azimuth sweeps the surface + // LEFT, and raising elevation lifts the camera to show more of the top. + // Adding dx (and subtracting dy) therefore made a rightward drag push + // the surface left — it felt like grabbing the far side of the sphere. + p.state.azimuth = dragStart.az - dx * 0.5; + p.state.elevation = Math.max(-89, Math.min(89, dragStart.el + dy * 0.5)); draw3d(p); _writeState(); _emitEvent(p.id, 'pointer_move', null, @@ -8988,7 +9577,9 @@ fn fs(in : VsOut) -> @location(0) vec4 { let _need = false; for (const p of panels.values()) { if (p.kind === '3d' && p._gpu === undefined && p.state - && _gpuWanted(p.state)) { _need = true; break; } + && _gpuWanted(p.state, !!(p._3dTex && p._3dTex.ready))) { + _need = true; break; + } } if (_need) redrawAll(); }); diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index c9f3d1dd..d40a5d29 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -12,7 +12,8 @@ from anyplotlib._base_plot import _BasePlot from anyplotlib.callbacks import CallbackRegistry -from anyplotlib._utils import _arr_to_b64, _build_colormap_lut +from anyplotlib._utils import (_arr_to_b64, _build_colormap_lut, + _image_to_data_url) def _triangulate_grid(rows: int, cols: int) -> list: @@ -98,6 +99,7 @@ def _geometry_state(geom_type: str, x, y, z, bounds=None) -> dict: raise ValueError("x, y, z must have the same length") xf, yf, zf = x, y, z faces_list = [] + rows = cols = 0 # Encode geometry as b64 (float32 saves 50 % wire size vs float64) verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) # (N, 3) @@ -124,15 +126,56 @@ def _geometry_state(geom_type: str, x, y, z, bounds=None) -> dict: "faces_count": len(faces_arr), "z_values_b64": _arr_to_b64(zvals_arr, np.float32), "data_bounds": data_bounds, + # Parametric grid shape (0 for non-surface geometry) — texture UVs + # default to this grid's normalised (col, row) coordinates. + "grid_rows": int(rows), + "grid_cols": int(cols), } +def _uv_to_b64(uv, rows: int, cols: int, n: int, flip_v: bool) -> str: + """Validate texture coordinates and encode them as float32 ``(N, 2)`` b64. + + ``uv=None`` builds the default parametric mapping: ``u`` runs 0→1 across + the grid's columns and ``v`` 0→1 down its rows, so an equirectangular + image lands on a sphere parameterised the same way. Otherwise *uv* is + either a ``(U, V)`` pair (each 2-D grid- or flat-shaped) or a single + ``(N, 2)`` array of per-vertex coordinates. + """ + if uv is None: + if rows < 2 or cols < 2: + raise ValueError( + "default texture coordinates need a parametric grid surface; " + "pass uv=(U, V) explicitly") + u = np.tile(np.linspace(0.0, 1.0, cols), rows) + v = np.repeat(np.linspace(0.0, 1.0, rows), cols) + elif isinstance(uv, (tuple, list)) and len(uv) == 2: + u = np.asarray(uv[0], dtype=float).ravel() + v = np.asarray(uv[1], dtype=float).ravel() + if len(u) != n or len(v) != n: + raise ValueError( + f"uv=(U, V) must each hold {n} values (one per vertex), " + f"got {len(u)} and {len(v)}") + else: + arr = np.asarray(uv, dtype=float) + if arr.ndim != 2 or arr.shape != (n, 2): + raise ValueError( + f"uv must be a (U, V) pair or an ({n}, 2) array, " + f"got shape {arr.shape}") + u, v = arr[:, 0], arr[:, 1] + + if flip_v: + v = 1.0 - v + return _arr_to_b64(np.column_stack([u, v]).astype(np.float32), np.float32) + + class Plot3D(_BasePlot): """3-D plot panel. Supports four geometry types: - * ``'surface'`` – triangulated surface, Z-coloured via colormap. + * ``'surface'`` – triangulated surface, Z-coloured via colormap, or + wrapped in an image with :meth:`set_texture` (globes, star charts). * ``'scatter'`` – point cloud; single colour or per-point ``colors``. * ``'line'`` – connected line through 3-D points. * ``'voxels'`` – shaded translucent cubes at the given centres; @@ -157,7 +200,7 @@ class Plot3D(_BasePlot): #: (highlight / camera / planes) never re-transmit them. _GEOM_KEYS = frozenset({ "vertices_b64", "faces_b64", "z_values_b64", "point_colors_b64", - "colormap_data", + "colormap_data", "texture_url", "texture_uv_b64", }) def __init__(self, geom_type: str, @@ -176,10 +219,17 @@ def __init__(self, geom_type: str, bounds=None, voxel_size: float = 1.0, alpha: float | None = None, + texture=None, gpu: str | bool = "auto"): self._id: str = "" self._fig: object = None self._gpu_active: bool = False + #: True while the texture UVs are the auto parametric mapping, so + #: set_data() can rebuild them for the new grid shape. When they are + #: explicit instead, _texture_uv_n records how many vertices they + #: cover so a shape-changing set_data can reject them. + self._texture_uv_auto: bool = True + self._texture_uv_n: int = 0 geom_type = geom_type.lower() if geom_type not in ("surface", "scatter", "line", "voxels"): @@ -220,6 +270,14 @@ def __init__(self, geom_type: str, "colormap_data": cmap_lut, "color": color, "point_colors_b64": point_colors_b64, + # Image texture wrapped onto a surface (see set_texture). The + # image rides as a data: URL and the per-vertex UVs as float32 + # pairs; both are geometry-channel keys. + "texture_url": "", + "texture_uv_b64": "", + "texture_alpha": 1.0, + "texture_shade": False, + "texture_cull": False, # Highlight point: {"x","y","z","color","size"} or None "highlight": None, # Reference sphere: {"radius","color","alpha","wireframe"} or None @@ -255,6 +313,9 @@ def __init__(self, geom_type: str, self.callbacks = CallbackRegistry() self._widgets: dict = {} + if texture is not None: + self.set_texture(texture) + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -418,12 +479,128 @@ def get_zlim(self) -> tuple: def set_data(self, x, y, z) -> None: """Replace the geometry data (same shape rules as the constructor). - Bounds given at construction time (``bounds=``) are preserved. + Bounds given at construction time (``bounds=``) are preserved. An + auto-mapped texture (:meth:`set_texture` without ``uv=``) follows the + new grid; explicit UVs are kept and must still match the vertex count. """ self._state.update(_geometry_state( self._state["geom_type"], x, y, z, bounds=self._bounds)) + if self._state["texture_url"]: + n = self._state["vertices_count"] + if self._texture_uv_auto: + self._state["texture_uv_b64"] = _uv_to_b64( + None, self._state["grid_rows"], self._state["grid_cols"], + n, False) + self._texture_uv_n = n + elif self._texture_uv_n != n: + raise ValueError( + "the explicit texture coordinates set by set_texture(uv=…) " + f"cover {self._texture_uv_n} vertices but the new data has " + f"{n}; call set_texture again with matching uv=") + self._push() + + # ------------------------------------------------------------------ + # Image textures (surface only) + # ------------------------------------------------------------------ + def set_texture(self, image, *, uv=None, alpha: float = 1.0, + shade: bool = False, cull_backfaces: bool = False, + flip_v: bool = False) -> None: + """Wrap an image around this surface (``geom_type == 'surface'``). + + Each triangle is filled with the matching patch of *image*, so the + picture follows the geometry as you orbit — a globe, a planet, or a + star chart on the celestial sphere. By default the image is mapped + parametrically: its left edge to the surface grid's first column, its + right edge to the last, its top row to the grid's first row. Build the + sphere with longitude along the columns and latitude down the rows and + an equirectangular (plate-carrée) image lands exactly right. + + Rendering goes through WebGPU when it is available and the surface has + more than ~2k triangles (see the ``gpu`` argument to + :meth:`Axes.plot_surface`), which lifts the practical grid size from a + few thousand triangles to hundreds of thousands. Without a GPU — or + with ``alpha`` below 1, which needs the Canvas2D compositing path — + every triangle is texture-mapped on the CPU instead, so prefer a + coarse grid there and let the image carry the detail. + + Parameters + ---------- + image : array-like, bytes, or path + An ``(H, W, 3|4)`` colour array (uint8, or float 0–1 — a PIL image + works too), the raw bytes of a PNG/JPEG/GIF/WebP, or a path to + such a file. Encoded input is passed through untouched; arrays + are PNG-compressed. Very large images cost wire size and decode + time — 2048×1024 is plenty for a sphere. + uv : (U, V) or (N, 2) array, optional + Explicit texture coordinates in 0–1, one per vertex, instead of + the default parametric mapping. ``U``/``V`` may be given in the + grid's 2-D shape or already flattened. + alpha : float, optional + Opacity of the whole textured surface, 0–1. Default 1. Below 1 + the surface is composited as one translucent skin — whatever the + panel draws behind it (a reference sphere, a scatter cloud) shows + through, but the surface's own far side does not. + shade : bool, optional + Modulate the texture with diffuse lighting from the upper left. + Default False (the image is reproduced faithfully); True gives a + sphere its familiar lit look and makes relief read as relief. + cull_backfaces : bool, optional + Skip triangles facing away from the camera. Default False. Set + True for a *closed* surface (sphere, ellipsoid, blob) — it halves + the drawing work with no visual change. On an open surface it + makes the back side invisible, which is usually not what you want. + Ignored on the WebGPU path, where the depth buffer resolves + occlusion exactly and culling buys nothing. + flip_v : bool, optional + Mirror the mapping vertically (``v → 1 - v``). Use when the image + is stored bottom-up relative to the grid's row order. + + See Also + -------- + clear_texture : Remove the texture and fall back to colormapped Z. + + Examples + -------- + :: + + lat = np.linspace(np.pi / 2, -np.pi / 2, 180) # rows: N → S + lon = np.linspace(-np.pi, np.pi, 360) # cols: W → E + LON, LAT = np.meshgrid(lon, lat) + X = np.cos(LAT) * np.cos(LON) + Y = np.cos(LAT) * np.sin(LON) + Z = np.sin(LAT) + + globe = ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3) + globe.set_texture("earth.jpg", shade=True, cull_backfaces=True) + """ + if self._state["geom_type"] != "surface": + raise ValueError("textures are only supported for surface plots") + url = _image_to_data_url(image) + uv_b64 = _uv_to_b64(uv, self._state["grid_rows"], + self._state["grid_cols"], + self._state["vertices_count"], flip_v) + self._texture_uv_auto = uv is None + self._texture_uv_n = self._state["vertices_count"] + self._state.update( + texture_url=url, + texture_uv_b64=uv_b64, + texture_alpha=float(alpha), + texture_shade=bool(shade), + texture_cull=bool(cull_backfaces), + ) self._push() + def clear_texture(self) -> None: + """Remove the image texture; the surface reverts to colormapped Z.""" + self._texture_uv_auto = True + self._state.update(texture_url="", texture_uv_b64="") + self._push() + + @property + def has_texture(self) -> bool: + """``True`` when an image texture is currently applied.""" + return bool(self._state["texture_url"]) + def set_point_colors(self, colors) -> None: """Set (or clear) per-point colours on a scatter or voxels panel. diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py index 157ae253..438a9986 100644 --- a/anyplotlib/tests/test_examples/test_interactive_examples.py +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -12,6 +12,7 @@ "plot_threshold_explorer.py", "plot_spectra_roi_inspector.py", "plot_voxel_grain_explorer.py", + "plot_star_globe_explorer.py", ] diff --git a/uv.lock b/uv.lock index 16bb4717..55c6e37d 100644 --- a/uv.lock +++ b/uv.lock @@ -45,7 +45,7 @@ wheels = [ [[package]] name = "anyplotlib" -version = "0.3.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "anywidget" }, From 84733a07a43af6e4d1ad6980919bc38c4d352f70 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 30 Jul 2026 21:28:45 -0500 Subject: [PATCH 2/5] feat: Add example plotting a celestial globe --- Examples/PlotTypes/plot_3d_texture.py | 344 ++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 Examples/PlotTypes/plot_3d_texture.py diff --git a/Examples/PlotTypes/plot_3d_texture.py b/Examples/PlotTypes/plot_3d_texture.py new file mode 100644 index 00000000..a0c40fb7 --- /dev/null +++ b/Examples/PlotTypes/plot_3d_texture.py @@ -0,0 +1,344 @@ +""" +Textured 3-D surfaces — a celestial globe +========================================= + +:meth:`~anyplotlib.Plot3D.set_texture` wraps an image around any 3-D +surface, so the picture follows the geometry as you orbit it. Here we +build an equirectangular star map from a real bright-star catalogue and +project it onto the celestial sphere. + +Drag to spin the globe, scroll to zoom, press **R** to reset the view. + +.. note:: + You are looking at the sky from the *outside*, the way a physical + celestial globe is made — so the constellations read mirrored compared + with lying on your back and looking up. +""" +import numpy as np + +import anyplotlib as apl + +# ── A real bright-star catalogue ────────────────────────────────────────────── +# name, right ascension (hours), declination (degrees), visual magnitude. +# Positions are J2000, rounded to about an arcminute — plenty for a chart +# this size, but not a substitute for a proper catalogue. +STARS = { + "Sirius": (6.752, -16.72, -1.46), + "Canopus": (6.399, -52.70, -0.74), + "Rigil Kent": (14.660, -60.83, -0.27), + "Arcturus": (14.261, 19.18, -0.05), + "Vega": (18.615, 38.78, 0.03), + "Capella": (5.278, 46.00, 0.08), + "Rigel": (5.242, -8.20, 0.13), + "Procyon": (7.655, 5.22, 0.34), + "Achernar": (1.629, -57.24, 0.46), + "Betelgeuse": (5.919, 7.41, 0.50), + "Hadar": (14.064, -60.37, 0.61), + "Altair": (19.846, 8.87, 0.77), + "Acrux": (12.443, -63.10, 0.77), + "Aldebaran": (4.599, 16.51, 0.85), + "Spica": (13.420, -11.16, 1.04), + "Antares": (16.490, -26.43, 1.09), + "Pollux": (7.755, 28.03, 1.14), + "Fomalhaut": (22.961, -29.62, 1.16), + "Deneb": (20.690, 45.28, 1.25), + "Mimosa": (12.795, -59.69, 1.25), + "Regulus": (10.140, 11.97, 1.35), + "Adhara": (6.977, -28.97, 1.50), + "Castor": (7.577, 31.89, 1.58), + "Shaula": (17.560, -37.10, 1.62), + "Gacrux": (12.520, -57.11, 1.63), + "Bellatrix": (5.418, 6.35, 1.64), + "Elnath": (5.438, 28.61, 1.65), + "Miaplacidus": (9.220, -69.72, 1.67), + "Alnilam": (5.604, -1.20, 1.69), + "Alnair": (22.137, -46.96, 1.74), + "Alioth": (12.900, 55.96, 1.76), + "Alnitak": (5.679, -1.94, 1.77), + "Dubhe": (11.062, 61.75, 1.79), + "Mirfak": (3.405, 49.86, 1.79), + "Wezen": (7.140, -26.39, 1.83), + "Kaus Aust.": (18.403, -34.38, 1.85), + "Alkaid": (13.792, 49.31, 1.85), + "Avior": (8.375, -59.51, 1.86), + "Sargas": (17.622, -43.00, 1.86), + "Menkalinan": (5.992, 44.95, 1.90), + "Atria": (16.811, -69.03, 1.91), + "Alhena": (6.628, 16.40, 1.93), + "Peacock": (20.427, -56.74, 1.94), + "Polaris": (2.530, 89.26, 1.98), + "Mirzam": (6.378, -17.96, 1.98), + "Alphard": (9.460, -8.66, 2.00), + "Hamal": (2.120, 23.46, 2.00), + "Diphda": (0.727, -17.99, 2.04), + "Nunki": (18.921, -26.30, 2.05), + "Menkent": (14.112, -36.37, 2.06), + "Mirach": (1.162, 35.62, 2.06), + "Alpheratz": (0.140, 29.09, 2.06), + "Rasalhague": (17.582, 12.56, 2.08), + "Kochab": (14.845, 74.16, 2.08), + "Algieba": (10.333, 19.84, 2.08), + "Saiph": (5.796, -9.67, 2.09), + "Tiaki": (22.711, -46.88, 2.11), + "Algol": (3.136, 40.96, 2.12), + "Denebola": (11.818, 14.57, 2.14), + "Muhlifain": (12.692, -48.96, 2.20), + "Aspidiske": (9.285, -59.28, 2.21), + "Alphecca": (15.578, 26.71, 2.22), + "Sadr": (20.370, 40.26, 2.23), + "Mizar": (13.399, 54.93, 2.23), + "Eltanin": (17.943, 51.49, 2.23), + "Suhail": (9.133, -43.43, 2.23), + "Schedar": (0.675, 56.54, 2.24), + "Mintaka": (5.533, -0.30, 2.25), + "Caph": (0.153, 59.15, 2.28), + "Gamma Cas": (0.945, 60.72, 2.47), + "Merak": (11.030, 56.38, 2.37), + "Enif": (21.737, 9.88, 2.38), + "Phecda": (11.897, 53.69, 2.44), + "Ruchbah": (1.430, 60.24, 2.68), + "Delta Cru": (12.252, -58.75, 2.79), + "Alcyone": (3.792, 24.11, 2.87), + "Megrez": (12.257, 57.03, 3.31), + "Segin": (1.907, 63.67, 3.38), + "Meissa": (5.585, 9.93, 3.39), +} + +# Constellation figures, as chains of catalogue names. +FIGURES = [ + # Orion + ["Betelgeuse", "Bellatrix", "Mintaka", "Rigel"], + ["Mintaka", "Alnilam", "Alnitak", "Saiph"], + ["Betelgeuse", "Alnitak"], + ["Bellatrix", "Meissa", "Betelgeuse"], + # Ursa Major — the Plough / Big Dipper + ["Dubhe", "Merak", "Phecda", "Megrez", "Dubhe"], + ["Megrez", "Alioth", "Mizar", "Alkaid"], + # Cassiopeia — the W (crosses RA 0h, so the drawing must wrap) + ["Segin", "Ruchbah", "Gamma Cas", "Schedar", "Caph"], + # Crux — the Southern Cross + ["Acrux", "Gacrux"], + ["Mimosa", "Delta Cru"], + # Gemini, and the Summer Triangle asterism + ["Castor", "Pollux"], + ["Vega", "Deneb", "Altair", "Vega"], +] + +# ── Build the equirectangular sky image ─────────────────────────────────────── +# Columns run 0h → 24h of right ascension, rows +90° → −90° of declination. +# That is exactly the parametric order set_texture() maps by default. +TEX_W, TEX_H = 1440, 720 + +# North galactic pole and galactic centre (J2000) — used to lay the Milky Way +# band down where it actually belongs rather than freehand. +NGP_RA, NGP_DEC = np.radians(192.85948), np.radians(27.12825) +GC_RA, GC_DEC = np.radians(266.41684), np.radians(-29.00781) + + +def _sky_pixel(ra_h, dec_deg): + """Catalogue coordinates → (column, row) in the texture.""" + return (ra_h / 24.0 * (TEX_W - 1), (90.0 - dec_deg) / 180.0 * (TEX_H - 1)) + + +def _angsep(ra, dec, ra0, dec0): + """Great-circle angle between (ra, dec) and a fixed direction, in radians.""" + return np.arccos(np.clip( + np.sin(dec) * np.sin(dec0) + np.cos(dec) * np.cos(dec0) * np.cos(ra - ra0), + -1.0, 1.0)) + + +def _splat(img, x, y, radius, rgb, amp): + """Add a small Gaussian blob, wrapping in RA and clipping at the poles.""" + r = int(np.ceil(3 * radius)) + xs = np.arange(int(round(x)) - r, int(round(x)) + r + 1) + ys = np.arange(int(round(y)) - r, int(round(y)) + r + 1) + ys = ys[(ys >= 0) & (ys < img.shape[0])] + if len(ys) == 0: + return + g = (np.exp(-((ys[:, None] - y) ** 2 + (xs[None, :] - x) ** 2) + / (2 * radius ** 2)) * amp) + img[np.ix_(ys, xs % img.shape[1])] += g[..., None] * np.asarray(rgb) + + +def _stroke(img, p0, p1, rgb, amp): + """Draw a line between two texture points, taking the short way in RA.""" + (x0, y0), (x1, y1) = p0, p1 + if abs(x1 - x0) > img.shape[1] / 2: + x1 += img.shape[1] if x0 > x1 else -img.shape[1] + n = int(max(abs(x1 - x0), abs(y1 - y0))) * 2 + 2 + xs = (np.linspace(x0, x1, n).astype(int)) % img.shape[1] + ys = np.clip(np.linspace(y0, y1, n), 0, img.shape[0] - 1).astype(int) + img[ys, xs] = np.maximum(img[ys, xs], np.asarray(rgb) * amp) + + +rng = np.random.default_rng(20260730) + +ra_grid = np.linspace(0, 2 * np.pi, TEX_W)[None, :] +dec_grid = np.radians(np.linspace(90, -90, TEX_H))[:, None] + +sky = np.zeros((TEX_H, TEX_W, 3), np.float32) + +# Deep-space background, a touch bluer toward the poles. +sky[:] = np.array([0.020, 0.028, 0.062], np.float32) +sky += (np.abs(np.sin(dec_grid)) ** 3 * 0.02)[..., None] + +# The Milky Way: a band along galactic latitude 0, brightest toward the +# galactic centre in Sagittarius, mottled by dust lanes. +gal_lat = np.arcsin(np.sin(dec_grid) * np.sin(NGP_DEC) + + np.cos(dec_grid) * np.cos(NGP_DEC) + * np.cos(ra_grid - NGP_RA)) +band = np.exp(-(gal_lat / np.radians(11.0)) ** 2) +band = band * (0.45 + 0.85 * np.exp(-(_angsep(ra_grid, dec_grid, GC_RA, GC_DEC) + / np.radians(55.0)) ** 2)) +dust = np.zeros((TEX_H, TEX_W), np.float32) +amp = 1.0 +for k in range(1, 6): # cheap fBm, no scipy needed + dust += amp * (np.sin(3 * k * ra_grid + rng.uniform(0, 2 * np.pi)) + * np.sin(2.5 * k * (dec_grid + np.pi / 2) + + rng.uniform(0, 2 * np.pi))) + amp *= 0.55 +band = band * np.clip(0.72 + 0.28 * dust, 0.25, 1.0) +sky += band[..., None] * np.array([0.46, 0.44, 0.55], np.float32) + +# Background field: uniform on the sphere (density ∝ cos δ), thickened in +# the galactic plane where the real sky is crowded. +n_faint = 24_000 +f_ra = rng.uniform(0, TEX_W, n_faint) +f_dec = np.degrees(np.arcsin(rng.uniform(-1, 1, n_faint))) +fx = f_ra.astype(int) % TEX_W +fy = ((90.0 - f_dec) / 180.0 * (TEX_H - 1)).astype(int) +keep = rng.random(n_faint) < (0.22 + 0.78 * band[fy, fx]) +fx, fy = fx[keep], fy[keep] +sky[fy, fx] += rng.uniform(0.25, 0.95, (len(fx), 1)).astype(np.float32) + +# Constellation figures, under the named stars. +for chain in FIGURES: + for a, b in zip(chain, chain[1:]): + _stroke(sky, _sky_pixel(*STARS[a][:2]), _sky_pixel(*STARS[b][:2]), + (0.30, 0.58, 0.85), 1.0) + +# The named stars themselves: brighter and fatter with decreasing magnitude. +for name, (ra_h, dec_deg, mag) in STARS.items(): + x, y = _sky_pixel(ra_h, dec_deg) + scale = 10 ** (-0.4 * mag) # linear flux from magnitude + _splat(sky, x, y, 1.3 + 2.0 * scale ** 0.35, (1.0, 0.97, 0.90), + 0.85 + 2.2 * scale ** 0.30) + +sky_u8 = (np.clip(sky, 0, 1) * 255).astype(np.uint8) + +# ── The globe ───────────────────────────────────────────────────────────────── +# Longitude along the columns and latitude down the rows, matching the image. +# A coarse grid is fine — the texture carries the detail, and fewer triangles +# means a smoother spin. +NDEC, NRA = 49, 97 +ra = np.linspace(0, 2 * np.pi, NRA) # RA 0h → 24h, seam closes +dec = np.linspace(np.pi / 2, -np.pi / 2, NDEC) # +90° → −90° +RA, DEC = np.meshgrid(ra, dec) +X = np.cos(DEC) * np.cos(RA) +Y = np.cos(DEC) * np.sin(RA) +Z = np.sin(DEC) + +# The camera faces the sphere point (RA α, Dec δ) at +# ``elevation = δ`` and ``azimuth = atan2(cos δ cos α, −cos δ sin α)``. +# These angles put Orion — the easiest figure to pick out — near the middle. +fig, ax = apl.subplots(1, 1, figsize=(520, 520)) +globe = ax.plot_surface(X, Y, Z, + bounds=((-1, 1),) * 3, # keeps the sphere a true circle + azimuth=172, elevation=6) +globe.set_axis_off() # the globe *is* the subject +globe.set_texture(sky_u8, cull_backfaces=True) # closed surface → safe to cull +globe.set_title("the celestial sphere") + +fig + +# %% +# The source image +# ---------------- +# Nothing about the image is special — it is an ordinary ``(H, W, 3)`` array, +# shown here in the same panel type you would use for any picture. Because +# the sphere's columns run 0h → 24h of right ascension and its rows +90° → +# −90° of declination, the default parametric mapping lines the two up with +# no extra work. + +fig2, ax2 = apl.subplots(1, 1, figsize=(900, 470)) +sky_map = ax2.imshow(sky_u8) +sky_map.set_title("an equirectangular star map — 0h to 24h left to right, " + "+90$\\degree$ to −90$\\degree$ top to bottom") + +for name in ("Sirius", "Betelgeuse", "Rigel", "Vega", "Deneb", "Altair", + "Polaris", "Arcturus", "Antares", "Acrux", "Canopus", "Spica", + "Aldebaran", "Regulus", "Fomalhaut", "Capella"): + ra_h, dec_deg, _ = STARS[name] + x, y = _sky_pixel(ra_h, dec_deg) + # Marker text anchors on its left, so flip long labels inward near the + # right-hand edge rather than letting them run off the map. + flip = x > TEX_W * 0.88 + sky_map.add_text(x + (-10 - 6 * len(name) if flip else 10), y - 4, name, + color="#ffd54f", fontsize=9) + +fig2 + +# %% +# Any surface, not just spheres +# ----------------------------- +# The mapping is parametric — the image's left edge goes to the grid's first +# column, its top row to the grid's first row — so the same call drapes an +# image over an open surface. ``shade=True`` adds diffuse lighting, which is +# what makes the relief read as relief. +# +# Leave ``cull_backfaces`` off here: culling is only safe on a *closed* +# surface, and it would make this one vanish when viewed from below. + +gx = np.linspace(-3, 3, 90) +gy = np.linspace(-3, 3, 90) +GX, GY = np.meshgrid(gx, gy) +GZ = 0.55 * np.exp(-((GX - 0.8) ** 2 + (GY - 0.4) ** 2) / 1.6) \ + - 0.35 * np.exp(-((GX + 1.1) ** 2 + (GY + 1.0) ** 2) / 0.9) \ + + 0.12 * np.sin(2.2 * GX) * np.cos(2.0 * GY) + +# A bright graticule makes both effects easy to see: the grid bends with the +# relief, and the lighting picks out the hill and the hollow. Any +# ``(H, W, 3|4)`` array works here, as do the raw bytes of a PNG/JPEG or a +# path to one. +mx, my = np.meshgrid(np.arange(600), np.arange(400)) +chart = np.stack([ + (60 + 190 * mx / 600), + (110 + 90 * my / 400), + (200 - 120 * mx / 600), +], -1).astype(np.uint8) +chart[(mx % 40 < 2) | (my % 40 < 2)] = 245 # graticule + +fig3, ax3 = apl.subplots(1, 1, figsize=(560, 470)) +terrain = ax3.plot_surface(GX, GY, GZ, azimuth=-58, elevation=38, + x_label="x", y_label="y", z_label="height") +terrain.set_texture(chart, shade=True) +terrain.set_title("an image draped over an open surface") + +fig3 + +# %% +# Live updates +# ------------ +# :meth:`~anyplotlib.Plot3D.set_texture` re-wraps without rebuilding the +# panel, so the image and the mapping options can both be swapped on the fly +# — here to burn a coordinate grid onto the same globe. +# :meth:`~anyplotlib.Plot3D.clear_texture` drops back to the colormapped +# Z surface. + +sky_grid = sky.copy() +ra_lines = np.zeros(TEX_W, bool) +ra_lines[(np.arange(TEX_W) * 24 // TEX_W) % 2 == 0] = True # every 2 h +ra_lines &= np.r_[True, np.diff((np.arange(TEX_W) * 24 // TEX_W)) != 0] +for x in np.nonzero(ra_lines)[0]: + sky_grid[:, x] = np.maximum(sky_grid[:, x], (0.10, 0.30, 0.34)) +for dec_deg in range(-75, 90, 15): + y = int((90 - dec_deg) / 180 * (TEX_H - 1)) + lit = (0.22, 0.55, 0.60) if dec_deg == 0 else (0.10, 0.30, 0.34) + sky_grid[y] = np.maximum(sky_grid[y], lit) + +globe.set_texture((np.clip(sky_grid, 0, 1) * 255).astype(np.uint8), + cull_backfaces=True) +globe.set_view(azimuth=200, elevation=25) +globe.set_title("the celestial sphere, with an RA/Dec grid") + +fig From e860c2c79428a7ad9249fd7a99d42fa13b312d6c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 30 Jul 2026 22:19:37 -0500 Subject: [PATCH 3/5] fix: review follow-ups for the texture / brush / orbit work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the files that were left untracked, and fix the defects the branch review turned up. Untracked work now committed. test_interactive_examples.py already listed plot_star_globe_explorer.py in SCRIPTS, so a clean checkout failed that smoke test — the example was only ever in a working tree. Same for 1,100 lines of tests covering textures, GPU depth, orbit direction and the star globe linking, and for four changelog fragments. esm.diff, docs/_dummy/ and Examples/testing.ipynb are scratch, so they are gitignored instead. set_data() no longer discards flip_v. Rebuilding an auto UV mapping passed flip_v=False unconditionally, so a surface textured with flip_v=True turned upside-down on its first data update — the exact case a streaming globe hits. The flip is now remembered alongside _texture_uv_auto. WebGPU depth scale 0.35 -> 0.28. norm() puts geometry in a box reaching [-1,1] on its longest axis, so |r1*n| reaches sqrt(3) at a corner and clip.z peaked at 1.089 — outside the [0,1] WebGPU keeps. The near corner of a cube-shaped scatter cloud was silently clipped at the default camera. 0.5/sqrt(3) is the bound; 0.28 sits under it with depth24plus precision to spare. The comment claiming the old range was safe is gone. pointer_up resolves its widget by ID, not by the drag's original index. _brushCommit already did this because a model echo can replace p.state mid-drag; the emit on the next line did not, and would ship the wrong widget's dict in precisely that case. A brush no longer claims a Shift-drag that starts outside the image. _brushPaintAt drops out-of-image points, so a gesture begun in the axis margin took the drag and then painted nothing — a dead zone that also swallowed the pan. set_texture(alpha=) is validated to [0, 1], matching BrushWidget. Orbit reversal is filed as an api_change, a new towncrier type that leads the release notes. It is a fix, but what a reader upgrading needs to know is that every 3-D drag now goes the other way, and that is easy to miss under "Bug Fixes". FIGURE_ESM.md line anchors re-verified against all 9,285 lines. Every anchor from draw3d down had drifted 390-590 lines when the texture commit inserted above them, and the 2-D function table was over 1,000 out. AGENTS.md now says to re-check them in the same commit that moves them. --- .gitignore | 11 + AGENTS.md | 17 +- .../Interactive/plot_star_globe_explorer.py | 341 ++++++++++ anyplotlib/FIGURE_ESM.md | 50 +- anyplotlib/figure_esm.js | 47 +- anyplotlib/plot2d/_plot2d.py | 6 + anyplotlib/plot3d/_plot3d.py | 28 +- .../test_interactive/test_orbit_direction.py | 219 +++++++ .../test_star_globe_linking.py | 187 ++++++ .../tests/test_plot3d/test_gpu_depth.py | 133 ++++ anyplotlib/tests/test_plot3d/test_texture.py | 596 ++++++++++++++++++ anyplotlib/widgets/_widgets2d.py | 7 + pyproject.toml | 7 + .../+event_camera_angles.new_feature.rst | 6 + upcoming_changes/+gpu_depth_range.bugfix.rst | 6 + upcoming_changes/+gpu_depth_sign.bugfix.rst | 4 + .../+orbit_direction.api_change.rst | 8 + .../+surface_texture.new_feature.rst | 7 + upcoming_changes/README.rst | 5 + 19 files changed, 1646 insertions(+), 39 deletions(-) create mode 100644 Examples/Interactive/plot_star_globe_explorer.py create mode 100644 anyplotlib/tests/test_interactive/test_orbit_direction.py create mode 100644 anyplotlib/tests/test_interactive/test_star_globe_linking.py create mode 100644 anyplotlib/tests/test_plot3d/test_gpu_depth.py create mode 100644 anyplotlib/tests/test_plot3d/test_texture.py create mode 100644 upcoming_changes/+event_camera_angles.new_feature.rst create mode 100644 upcoming_changes/+gpu_depth_range.bugfix.rst create mode 100644 upcoming_changes/+gpu_depth_sign.bugfix.rst create mode 100644 upcoming_changes/+orbit_direction.api_change.rst create mode 100644 upcoming_changes/+surface_texture.new_feature.rst diff --git a/.gitignore b/.gitignore index 785d9799..a52c425d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,14 @@ docs/_static/anywidget_config.js # Generated by Sphinx-Gallery (anywidget iframe HTML) — never commit docs/_static/viewer_widgets/ + +# Scratch doc builds (`make html O=...` into a throwaway output dir) +docs/_dummy/ + +# Scratch working files that land in the repo root / Examples during +# development: saved diffs and throwaway notebooks. Real examples are +# `Examples/
/plot_*.py` and are picked up by Sphinx-Gallery. +*.diff +*.patch +Examples/testing.ipynb +Examples/*.ipynb diff --git a/AGENTS.md b/AGENTS.md index 18a77256..bf482b5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - **`Figure`** (`anyplotlib/figure/_figure.py`) — the only `anywidget.AnyWidget` subclass. Owns all traitlets and is the Python↔JS bridge. - **Plot objects** (`plot1d/`, `plot2d/`, `plot3d/`) — `Plot1D`, `PlotBar`, `Plot2D`, `PlotMesh`, `Plot3D` are **plain Python classes**, not widgets. They hold state in `_state` dicts and push to the Figure. Shared behaviour lives in `_base_plot.py` (`_BasePlot`, `_PanelMixin`, `_MarkerMixin`). - **`Axes`** (`axes/_axes.py`) — grid-cell container; factory methods (`imshow`, `plot`, `bar`, `pcolormesh`, `plot_surface`, …) create plot objects and attach them. -- **`figure_esm.js`** — pure-JS canvas renderer (~4,400 lines); all rendering logic lives here. **Read `anyplotlib/FIGURE_ESM.md` first** — it is the section map. +- **`figure_esm.js`** — pure-JS canvas renderer (~9,300 lines); all rendering logic lives here. **Read `anyplotlib/FIGURE_ESM.md` first** — it is the section map. - **`markers.py`** — static visual overlays (circles, arrows, lines, etc.) with a two-level dict registry: `plot.markers[type][name]`. - **`widgets/`** — interactive draggable overlays (`RectangleWidget`, `CrosshairWidget`, etc.) that receive JS position updates. - **`callbacks.py`** — event system: `Event` dataclass, `CallbackRegistry` (priority ordering, wildcard, pause/hold), `_EventMixin` (`add_event_handler`). @@ -105,8 +105,21 @@ so nothing looks broken; they just behave like the old renderer. Check with `grep -c '' build/html/_static/viewer_widgets/_001.html` when a JS change doesn't seem to reach the docs. +**Any insertion into `figure_esm.js` shifts every `FIGURE_ESM.md` line anchor +below it.** The anchors are maintained by hand, so re-check them in the *same* +commit that moves them — a map that is 500 lines out is worse than no map. Dump +the real numbers with: + +```bash +grep -nE '^\s*(function|const|let) [A-Za-z_]' anyplotlib/figure_esm.js +``` + +and reconcile against the two numbered tables (the section map near the top and +the 2-D function table). Both were last verified at 9,285 lines. + Changelog entries: add a fragment file to `upcoming_changes/` (e.g. `123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time. +Use `api_change` when existing behaviour changes, even if the change is a fix. ## Key Files @@ -116,7 +129,7 @@ Changelog entries: add a fragment file to `upcoming_changes/` (e.g. | `anyplotlib/figure/_gridspec.py` | `GridSpec`, `SubplotSpec` | | `anyplotlib/figure/_subplots.py` | `subplots()` factory | | `anyplotlib/axes/_axes.py` | `Axes` — plot factory methods | -| `anyplotlib/figure_esm.js` | All JS canvas rendering (~4,400 lines) | +| `anyplotlib/figure_esm.js` | All JS canvas rendering (~9,300 lines) | | `anyplotlib/FIGURE_ESM.md` | Section map for `figure_esm.js` — read this before editing the JS | | `anyplotlib/markers.py` | Static marker collections; `to_wire()` translation | | `anyplotlib/widgets/` | Interactive overlay widgets | diff --git a/Examples/Interactive/plot_star_globe_explorer.py b/Examples/Interactive/plot_star_globe_explorer.py new file mode 100644 index 00000000..ff66ff92 --- /dev/null +++ b/Examples/Interactive/plot_star_globe_explorer.py @@ -0,0 +1,341 @@ +""" +Star Globe Explorer — a linked celestial sphere and sky map +=========================================================== + +A celestial globe wired to a flat star map, with a sidereal clock. + +* **Left** — the celestial sphere. Whatever you turn toward the camera is + your **zenith**: the point directly overhead. +* **Right** — the same sky as an equirectangular map, with the zenith marked + and the **horizon** drawn as the great circle 90° away from it. Everything + inside the horizon is above your head right now; everything outside is + below the ground. +* **Bottom** — a sidereal clock and a latitude control. + +The coupling runs both ways. Orbit the globe and the clock and latitude +follow, because a zenith *is* a time and a place: the right ascension +overhead equals the local sidereal time, and its declination equals your +latitude. Drag either control and the globe turns to match. + +Star positions are real (approximate J2000, rounded to about an arcminute), +carried over from :doc:`../PlotTypes/plot_3d_texture`; the Milky Way band is +placed by true galactic latitude. + +.. note:: + The globe is the sky seen from *outside*, the way a physical celestial + globe is built — so the constellations read mirrored compared with lying + on your back and looking up. The flat map is the conventional + inside-out view. +""" +import numpy as np + +import anyplotlib as apl + +# ── Star catalogue ─────────────────────────────────────────────────────────── +# name: (right ascension in hours, declination in degrees, visual magnitude). +# Swapping in a full catalogue (e.g. HYG, ~9k stars to naked-eye magnitude) +# is a change to this table alone — everything below is generic. +STARS = { + "Sirius": (6.752, -16.72, -1.46), "Canopus": (6.399, -52.70, -0.74), + "Rigil Kent": (14.660, -60.83, -0.27), "Arcturus": (14.261, 19.18, -0.05), + "Vega": (18.615, 38.78, 0.03), "Capella": (5.278, 46.00, 0.08), + "Rigel": (5.242, -8.20, 0.13), "Procyon": (7.655, 5.22, 0.34), + "Achernar": (1.629, -57.24, 0.46), "Betelgeuse": (5.919, 7.41, 0.50), + "Hadar": (14.064, -60.37, 0.61), "Altair": (19.846, 8.87, 0.77), + "Acrux": (12.443, -63.10, 0.77), "Aldebaran": (4.599, 16.51, 0.85), + "Spica": (13.420, -11.16, 1.04), "Antares": (16.490, -26.43, 1.09), + "Pollux": (7.755, 28.03, 1.14), "Fomalhaut": (22.961, -29.62, 1.16), + "Deneb": (20.690, 45.28, 1.25), "Mimosa": (12.795, -59.69, 1.25), + "Regulus": (10.140, 11.97, 1.35), "Adhara": (6.977, -28.97, 1.50), + "Castor": (7.577, 31.89, 1.58), "Shaula": (17.560, -37.10, 1.62), + "Gacrux": (12.520, -57.11, 1.63), "Bellatrix": (5.418, 6.35, 1.64), + "Elnath": (5.438, 28.61, 1.65), "Miaplacidus": (9.220, -69.72, 1.67), + "Alnilam": (5.604, -1.20, 1.69), "Alnair": (22.137, -46.96, 1.74), + "Alioth": (12.900, 55.96, 1.76), "Alnitak": (5.679, -1.94, 1.77), + "Dubhe": (11.062, 61.75, 1.79), "Mirfak": (3.405, 49.86, 1.79), + "Wezen": (7.140, -26.39, 1.83), "Kaus Aust.": (18.403, -34.38, 1.85), + "Alkaid": (13.792, 49.31, 1.85), "Avior": (8.375, -59.51, 1.86), + "Sargas": (17.622, -43.00, 1.86), "Menkalinan": (5.992, 44.95, 1.90), + "Atria": (16.811, -69.03, 1.91), "Alhena": (6.628, 16.40, 1.93), + "Peacock": (20.427, -56.74, 1.94), "Polaris": (2.530, 89.26, 1.98), + "Mirzam": (6.378, -17.96, 1.98), "Alphard": (9.460, -8.66, 2.00), + "Hamal": (2.120, 23.46, 2.00), "Diphda": (0.727, -17.99, 2.04), + "Nunki": (18.921, -26.30, 2.05), "Menkent": (14.112, -36.37, 2.06), + "Mirach": (1.162, 35.62, 2.06), "Alpheratz": (0.140, 29.09, 2.06), + "Rasalhague": (17.582, 12.56, 2.08), "Kochab": (14.845, 74.16, 2.08), + "Algieba": (10.333, 19.84, 2.08), "Saiph": (5.796, -9.67, 2.09), + "Tiaki": (22.711, -46.88, 2.11), "Algol": (3.136, 40.96, 2.12), + "Denebola": (11.818, 14.57, 2.14), "Muhlifain": (12.692, -48.96, 2.20), + "Aspidiske": (9.285, -59.28, 2.21), "Alphecca": (15.578, 26.71, 2.22), + "Sadr": (20.370, 40.26, 2.23), "Mizar": (13.399, 54.93, 2.23), + "Eltanin": (17.943, 51.49, 2.23), "Suhail": (9.133, -43.43, 2.23), + "Schedar": (0.675, 56.54, 2.24), "Mintaka": (5.533, -0.30, 2.25), + "Caph": (0.153, 59.15, 2.28), "Gamma Cas": (0.945, 60.72, 2.47), + "Merak": (11.030, 56.38, 2.37), "Enif": (21.737, 9.88, 2.38), + "Phecda": (11.897, 53.69, 2.44), "Ruchbah": (1.430, 60.24, 2.68), + "Delta Cru": (12.252, -58.75, 2.79), "Alcyone": (3.792, 24.11, 2.87), + "Megrez": (12.257, 57.03, 3.31), "Segin": (1.907, 63.67, 3.38), + "Meissa": (5.585, 9.93, 3.39), +} + +FIGURES = [ + ["Betelgeuse", "Bellatrix", "Mintaka", "Rigel"], + ["Mintaka", "Alnilam", "Alnitak", "Saiph"], + ["Betelgeuse", "Alnitak"], ["Bellatrix", "Meissa", "Betelgeuse"], + ["Dubhe", "Merak", "Phecda", "Megrez", "Dubhe"], + ["Megrez", "Alioth", "Mizar", "Alkaid"], + ["Segin", "Ruchbah", "Gamma Cas", "Schedar", "Caph"], + ["Acrux", "Gacrux"], ["Mimosa", "Delta Cru"], + ["Castor", "Pollux"], ["Vega", "Deneb", "Altair", "Vega"], +] + +TEX_W, TEX_H = 1440, 720 +NGP_RA, NGP_DEC = np.radians(192.85948), np.radians(27.12825) +GC_RA, GC_DEC = np.radians(266.41684), np.radians(-29.00781) + + +def sky_pixel(ra_h, dec_deg): + """Catalogue coordinates → (column, row) in the equirectangular map.""" + return (ra_h / 24.0 * (TEX_W - 1), (90.0 - dec_deg) / 180.0 * (TEX_H - 1)) + + +def angsep(ra, dec, ra0, dec0): + return np.arccos(np.clip( + np.sin(dec) * np.sin(dec0) + np.cos(dec) * np.cos(dec0) * np.cos(ra - ra0), + -1.0, 1.0)) + + +def _splat(img, x, y, radius, rgb, amp): + r = int(np.ceil(3 * radius)) + xs = np.arange(int(round(x)) - r, int(round(x)) + r + 1) + ys = np.arange(int(round(y)) - r, int(round(y)) + r + 1) + ys = ys[(ys >= 0) & (ys < img.shape[0])] + if len(ys) == 0: + return + g = np.exp(-((ys[:, None] - y) ** 2 + (xs[None, :] - x) ** 2) + / (2 * radius ** 2)) * amp + img[np.ix_(ys, xs % img.shape[1])] += g[..., None] * np.asarray(rgb) + + +def _stroke(img, p0, p1, rgb, amp): + (x0, y0), (x1, y1) = p0, p1 + if abs(x1 - x0) > img.shape[1] / 2: # take the short way in RA + x1 += img.shape[1] if x0 > x1 else -img.shape[1] + n = int(max(abs(x1 - x0), abs(y1 - y0))) * 2 + 2 + xs = np.linspace(x0, x1, n).astype(int) % img.shape[1] + ys = np.clip(np.linspace(y0, y1, n), 0, img.shape[0] - 1).astype(int) + img[ys, xs] = np.maximum(img[ys, xs], np.asarray(rgb) * amp) + + +# ── Build the sky image ────────────────────────────────────────────────────── +rng = np.random.default_rng(20260730) +ra_grid = np.linspace(0, 2 * np.pi, TEX_W)[None, :] +dec_grid = np.radians(np.linspace(90, -90, TEX_H))[:, None] + +sky = np.zeros((TEX_H, TEX_W, 3), np.float32) +sky[:] = np.array([0.020, 0.028, 0.062], np.float32) +sky += (np.abs(np.sin(dec_grid)) ** 3 * 0.02)[..., None] + +gal_lat = np.arcsin(np.sin(dec_grid) * np.sin(NGP_DEC) + + np.cos(dec_grid) * np.cos(NGP_DEC) + * np.cos(ra_grid - NGP_RA)) +band = np.exp(-(gal_lat / np.radians(11.0)) ** 2) +band = band * (0.45 + 0.85 * np.exp( + -(angsep(ra_grid, dec_grid, GC_RA, GC_DEC) / np.radians(55.0)) ** 2)) +dust, amp = np.zeros((TEX_H, TEX_W), np.float32), 1.0 +for k in range(1, 6): + dust += amp * (np.sin(3 * k * ra_grid + rng.uniform(0, 2 * np.pi)) + * np.sin(2.5 * k * (dec_grid + np.pi / 2) + + rng.uniform(0, 2 * np.pi))) + amp *= 0.55 +band = band * np.clip(0.72 + 0.28 * dust, 0.25, 1.0) +sky += band[..., None] * np.array([0.46, 0.44, 0.55], np.float32) + +n_faint = 24_000 +f_dec = np.degrees(np.arcsin(rng.uniform(-1, 1, n_faint))) +fx = rng.uniform(0, TEX_W, n_faint).astype(int) % TEX_W +fy = ((90.0 - f_dec) / 180.0 * (TEX_H - 1)).astype(int) +keep = rng.random(n_faint) < (0.22 + 0.78 * band[fy, fx]) +fx, fy = fx[keep], fy[keep] +sky[fy, fx] += rng.uniform(0.25, 0.95, (len(fx), 1)).astype(np.float32) + +for chain in FIGURES: + for a, b in zip(chain, chain[1:]): + _stroke(sky, sky_pixel(*STARS[a][:2]), sky_pixel(*STARS[b][:2]), + (0.30, 0.58, 0.85), 1.0) + +for name, (ra_h, dec_deg, mag) in STARS.items(): + x, y = sky_pixel(ra_h, dec_deg) + flux = 10 ** (-0.4 * mag) + _splat(sky, x, y, 1.3 + 2.0 * flux ** 0.35, (1.0, 0.97, 0.90), + 0.85 + 2.2 * flux ** 0.30) + +sky_u8 = (np.clip(sky, 0, 1) * 255).astype(np.uint8) + +# ── Spherical geometry ─────────────────────────────────────────────────────── +NDEC, NRA = 49, 97 +_ra = np.linspace(0, 2 * np.pi, NRA) +_dec = np.linspace(np.pi / 2, -np.pi / 2, NDEC) +RA, DEC = np.meshgrid(_ra, _dec) +X, Y, Z = np.cos(DEC) * np.cos(RA), np.cos(DEC) * np.sin(RA), np.sin(DEC) + + +def camera_to_zenith(azimuth, elevation): + """Globe camera angles → the sky point facing you, as (RA hours, Dec°). + + The renderer's turntable faces the unit vector with ``el = asin(vz)`` and + ``az = atan2(vx, -vy)``. Feeding that through this example's + ``x = cos δ cos α`` parameterisation collapses to ``α = azimuth − 90°``. + """ + return ((azimuth - 90.0) % 360.0) / 15.0, elevation + + +def zenith_to_camera(lst_hours, latitude): + """The inverse: put (LST, latitude) overhead.""" + return (lst_hours * 15.0 + 90.0) % 360.0, latitude + + +def horizon_segments(lst_hours, latitude, n=240): + """Map-space polyline for the horizon: the circle 90° from the zenith. + + Built as an orthonormal frame about the zenith rather than by solving the + altitude equation, so it stays well conditioned at the poles. Returned as + ``(N, 2, 2)`` segments, split wherever the curve wraps in right ascension + so nothing is drawn straight across the map. + """ + a0, d0 = np.radians(lst_hours * 15.0), np.radians(latitude) + n_hat = np.array([np.cos(d0) * np.cos(a0), np.cos(d0) * np.sin(a0), np.sin(d0)]) + # Any vector not parallel to the zenith gives a usable first basis vector. + tmp = np.array([0.0, 0.0, 1.0]) if abs(n_hat[2]) < 0.9 else np.array([1.0, 0.0, 0.0]) + e1 = np.cross(n_hat, tmp) + e1 /= np.linalg.norm(e1) + e2 = np.cross(n_hat, e1) + + t = np.linspace(0, 2 * np.pi, n) + pts = np.cos(t)[:, None] * e1 + np.sin(t)[:, None] * e2 + ra_h = (np.degrees(np.arctan2(pts[:, 1], pts[:, 0])) % 360.0) / 15.0 + dec_d = np.degrees(np.arcsin(np.clip(pts[:, 2], -1, 1))) + px = np.column_stack(sky_pixel(ra_h, dec_d)) + + segs = np.stack([px[:-1], px[1:]], axis=1) # (N-1, 2, 2) + keep = np.abs(segs[:, 0, 0] - segs[:, 1, 0]) < TEX_W / 2 # drop wrap jumps + return segs[keep] + + +def visible_stars(lst_hours, latitude): + """Map pixels of the catalogue stars currently above the horizon.""" + a0, d0 = np.radians(lst_hours * 15.0), np.radians(latitude) + out = [] + for ra_h, dec_deg, _ in STARS.values(): + alt = np.pi / 2 - angsep(np.radians(ra_h * 15.0), np.radians(dec_deg), a0, d0) + if alt > 0: + out.append(sky_pixel(ra_h, dec_deg)) + return np.asarray(out) if out else np.zeros((0, 2)) + + +# ── Figure ─────────────────────────────────────────────────────────────────── +LST0, LAT0 = 5.5, 35.0 # Orion overhead, mid-northern latitude + +fig = apl.Figure(figsize=(1080, 620)) +gs = apl.GridSpec(3, 2, width_ratios=[1, 1.35], height_ratios=[6, 1, 1]) + +az0, el0 = zenith_to_camera(LST0, LAT0) +globe = fig.add_subplot(gs[0, 0]).plot_surface( + X, Y, Z, bounds=((-1, 1),) * 3, azimuth=az0, elevation=el0) +globe.set_axis_off() +globe.set_texture(sky_u8, cull_backfaces=True) +globe.set_title("celestial sphere — drag to move your zenith") + +sky_map = fig.add_subplot(gs[0, 1]).imshow(sky_u8) +sky_map.set_title("sky map — inside the curve is above the horizon") + +clock = fig.add_subplot(gs[1, :]).plot( + np.zeros(2), axes=[np.array([0.0, 24.0])], color="#37474f", linewidth=1) +clock.set_xlabel("local sidereal time (h)") + +lat_ax = fig.add_subplot(gs[2, :]).plot( + np.zeros(2), axes=[np.array([-90.0, 90.0])], color="#37474f", linewidth=1) +lat_ax.set_xlabel("observer latitude ($\\degree$)") + +# ── Overlays, redrawn whenever the zenith moves ────────────────────────────── +horizon = sky_map.add_lines(horizon_segments(LST0, LAT0), name="horizon", + edgecolors="#4dd0e1", linewidths=2.0) +# size_units="px" keeps the markers a fixed screen size as the map is zoomed. +zenith = sky_map.add_points(np.array([sky_pixel(LST0, LAT0)]), name="zenith", + sizes=9, color="#ffd54f", facecolors="#ffd54f", + alpha=1.0, size_units="px") +up_now = sky_map.add_points(visible_stars(LST0, LAT0), name="up", + sizes=4, color="#4dd0e1", facecolors="#4dd0e1", + alpha=0.55, size_units="px") + +lst_line = clock.add_vline_widget(LST0, color="#ffd54f") +lat_line = lat_ax.add_vline_widget(LAT0, color="#ffd54f") + +_syncing = False # guards the two-way coupling against feedback + + +def refresh(lst_hours, latitude, *, move_globe, move_sliders): + """Push a new zenith everywhere. One writer, three followers.""" + global _syncing + if _syncing: + return + _syncing = True + try: + with fig.batch(): + horizon.set(segments=horizon_segments(lst_hours, latitude)) + zenith.set(offsets=np.array([sky_pixel(lst_hours, latitude)])) + up_now.set(offsets=visible_stars(lst_hours, latitude)) + sky_map.set_title( + f"sky map — LST {lst_hours:04.1f} h, latitude {latitude:+.0f}°") + if move_globe: + az, el = zenith_to_camera(lst_hours, latitude) + globe.set_view(azimuth=az, elevation=el) + if move_sliders: + lst_line.set(x=lst_hours) + lat_line.set(x=latitude) + finally: + _syncing = False + + +@globe.add_event_handler("pointer_move") +def _on_orbit(event): + """Orbiting the globe IS choosing a time and a place.""" + if event.azimuth is None: + return + lst_hours, latitude = camera_to_zenith(event.azimuth, event.elevation) + refresh(lst_hours, latitude, move_globe=False, move_sliders=True) + + +@lst_line.add_event_handler("pointer_move") +def _on_clock(event): + refresh(lst_line.x % 24.0, lat_line.x, move_globe=True, move_sliders=False) + + +@lat_line.add_event_handler("pointer_move") +def _on_latitude(event): + refresh(lst_line.x % 24.0, np.clip(lat_line.x, -89, 89), + move_globe=True, move_sliders=False) + + +refresh(LST0, LAT0, move_globe=False, move_sliders=False) + +fig + +# %% +# Reading it +# ---------- +# The cyan circle is the horizon and the gold dot is the zenith. Stars inside +# the circle are above the ground and are marked in cyan; the rest have set. +# +# Two things fall out of the geometry that are worth watching for: +# +# * Push the latitude to :math:`+90°` and the horizon collapses onto the +# celestial equator — at the north pole the sky never rises or sets, it +# just wheels around Polaris. +# * Run the clock through 24 h at a mid latitude and the horizon sweeps the +# whole sky except two caps around the poles. Those caps are the +# circumpolar stars, the ones that never set from where you are standing. + +refresh(2.0, 89.0, move_globe=True, move_sliders=True) + +fig diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index b160b4d9..3c0d3a07 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -1,6 +1,6 @@ # FIGURE_ESM.md — Navigator for `figure_esm.js` -`figure_esm.js` is **~9,200 lines** and one big closure. Everything lives inside +`figure_esm.js` is **~9,300 lines** and one big closure. Everything lives inside `function render({ model, el })` so that all helpers share the same scope (`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you can jump straight to the relevant code without reading the whole file. @@ -52,7 +52,7 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | Shared math helpers | 64 | | b64 array decode helpers | 109 | | **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 161 / 228 / 250 | -| **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 310 / 323 / 333 | +| **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 313 / 323 / 333 | | **Layout engine** `applyLayout` | 774 | | `_buildCanvasStack` | 857 | | `_createPanelDOM` | 989 | @@ -65,13 +65,13 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `drawOverlay2d` / `drawMarkers2d` | 3169 / 3333 | | **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2500 / 2524 / 2585 | | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | -| **3D drawing**: `draw3d` | 4669 | -| Event emission `_emitEvent` | 5316 | -| 3D event handlers `_attachEvents3d` | 5368 | -| **1D drawing**: `draw1d` | 5582 | -| `_drawLine` (1D series + markers) | 5735 | -| `drawOverlay1d` / `drawMarkers1d` | 6028 / 6112 | -| Marker hit-test `_markerHitTest2d` | 6380 | +| **3D drawing**: `draw3d` | 5072 | +| Event emission `_emitEvent` | 5909 | +| 3D event handlers `_attachEvents3d` | 5961 | +| **1D drawing**: `draw1d` | 6182 | +| `_drawLine` (1D series + markers) | 6335 | +| `drawOverlay1d` / `drawMarkers1d` | 6628 / 6712 | +| Marker hit-test `_markerHitTest2d` | 6980 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -80,16 +80,16 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on > the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block > clips it to a curved sector. -| Panel event dispatch `_attachPanelEvents` | 6637 | -| 2D events `_attachEvents2d` | 6661 | -| 1D events `_attachEvents1d` | 7036 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7308 / 7568 | -| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 7489 / 7503 / 7532 / 7559 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 7691 / 7764 | -| Shared-axis propagation `_getShareGroups` | 7835 | -| Figure resize `_applyFigResizeDOM` | 7899 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8090 / 8153 / 8529 | -| Generic redraw `_redrawPanel` | 8719 | +| Panel event dispatch `_attachPanelEvents` | 7237 | +| 2D events `_attachEvents2d` | 7261 | +| 1D events `_attachEvents1d` | 7645 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7917 / 8190 | +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8103 / 8117 / 8146 / 8181 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8313 / 8386 | +| Shared-axis propagation `_getShareGroups` | 8457 | +| Figure resize `_applyFigResizeDOM` | 8521 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8712 / 8775 / 9151 | +| Generic redraw `_redrawPanel` | 9341 | > **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one > that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods` @@ -268,12 +268,12 @@ st.colorbar_label_size (label font sizes; optional) | Function | Line | Purpose | |----------|------|---------| -| **`_imgFitRect(iw,ih,cw,ch)`** | **1176** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this | -| `draw2d(p)` | 1258 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers | -| `drawScaleBar2d(p)` | 1360 | Physical scale bar | -| `drawColorbar2d(p)` | 1436 | Gradient strip + min/max marks + rotated label centred in the `_cbWidth` gutter | -| `_drawAxes2d(p)` | 1491 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | -| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 1629 / 1685 | Widgets / marker groups | +| **`_imgFitRect(iw,ih,cw,ch)`** | **2372** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this | +| `draw2d(p)` | 2680 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers | +| `drawScaleBar2d(p)` | 2875 | Physical scale bar | +| `drawColorbar2d(p)` | 2961 | Gradient strip + min/max marks + rotated label centred in the `_cbWidth` gutter | +| `_drawAxes2d(p)` | 3016 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | +| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3169 / 3333 | Widgets / marker groups | Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a `1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 84fa0812..f4516490 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -4873,7 +4873,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { // Substituting n = k*v - o, each clip component is affine in v: // clip.x = (2*scale*k/pw)*(r0·v) + [ -(2*scale/pw)*(r0·o) + (2*cx/pw - 1) ] // clip.y = -(2*scale*k/ph)*(r2·v) + [ (2*scale/ph)*(r2·o) + (1 - 2*cy/ph) ] - // clip.z = +0.35*k*(r1·v) + [ -0.35*(r1·o) + 0.5 ] → 0.5 + 0.35*depth + // clip.z = +DK*k*(r1·v) + [ -DK*(r1·o) + 0.5 ] → 0.5 + DK*depth // // DEPTH SIGN: r1·n is depth INTO the screen, so clip.z must INCREASE with it // for `depthCompare: 'less'` against a 1.0 clear to keep the nearest @@ -4881,10 +4881,20 @@ fn fs(in : VsOut) -> @location(0) vec4 { // GPU draw — a scatter cloud painted its far points over its near ones, and // a textured surface rendered inside-out (you saw the far hemisphere). It // went unnoticed because voxels, the other GPU consumer, disable depth - // writes entirely. The range stays inside [0,1] for the normalised - // [-1,1]³ box the renderer works in. + // writes entirely. // // Build the 4 ROWS, then transpose into WGSL's column-major storage. + // + // DEPTH SCALE: `norm()` puts the geometry in a box that reaches [-1,1] on its + // longest axis, so |r1·n| can reach √3 at a corner (r1 is a unit row). WebGPU + // clips fragments outside z ∈ [0,1] — there is no depth clamp without the + // `depth-clip-control` feature — so DK must satisfy DK*√3 ≤ 0.5, i.e. + // DK ≤ 0.2887. This was 0.35, which put the corners of a CUBE-shaped dataset + // outside the range: at the default camera (az -60, el 30) r1 = [.75,.433,-.5] + // and ‖r1‖₁ = 1.683, so clip.z peaked at 1.089 and the near corner of a dense + // scatter cloud was silently clipped away. A sphere (|n| = 1) never noticed. + // depth24plus has ample precision left at this scale. + const _GPU_DEPTH_K = 0.28; function _gpuMatrix(R, scale, cx, cy, pw, ph, bnds, maxR, xr, yr, zr) { const r0 = R[0], r1 = R[1], r2 = R[2]; const k = 2 / maxR; @@ -4900,7 +4910,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { // negations CANCEL, so rowY's coefficients are +sy*r2, not -sy*r2. const rowX = [ sx*r0[0], sx*r0[1], sx*r0[2], -(2*scale/pw)*r0o + (2*cx/pw - 1) ]; const rowY = [ sy*r2[0], sy*r2[1], sy*r2[2], -(2*scale/ph)*r2o + (1 - 2*cy/ph) ]; - const rowZ = [ 0.35*k*r1[0], 0.35*k*r1[1], 0.35*k*r1[2], -0.35*r1o + 0.5 ]; + const DK = _GPU_DEPTH_K; + const rowZ = [ DK*k*r1[0], DK*k*r1[1], DK*k*r1[2], -DK*r1o + 0.5 ]; const rowW = [0, 0, 0, 1]; // Column-major: column j = [rowX[j], rowY[j], rowZ[j], rowW[j]] return new Float32Array([ @@ -7303,6 +7314,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(hit){ p.ovDrag2d=hit; p.lastWidgetId=(st.overlay_widgets||[])[hit.idx]?.id||null; + // Stash the ID as well as the index: a model echo can replace p.state + // (and reorder overlay_widgets) mid-drag, after which `hit.idx` points + // at the wrong widget. mouseup resolves by ID and falls back to index. + hit.wid=p.lastWidgetId; if(hit.mode==='paint'||hit.mode==='erase'){ // Open the stroke HERE, not on the first move: a Shift-click with no // motion must still paint (or erase) a single dot. @@ -7365,8 +7380,13 @@ fn fs(in : VsOut) -> @location(0) vec4 { // _brushLiveBegin) — fold it into p.state FIRST, so the generic push + // emit below is what ships the finished stroke to Python, exactly once. if(p._brushLive) _brushCommit(p); - const _idx=p.ovDrag2d.idx; - const _dw=(p.state.overlay_widgets||[])[_idx]||{}; + // Resolve by ID the way _brushCommit does — an echo may have replaced + // p.state during the drag, and emitting the widget that happens to sit + // at the drag's original INDEX would ship the wrong payload (for a + // brush, another widget's dict in place of the finished stroke). + const _wid=p.ovDrag2d.wid, _idx=p.ovDrag2d.idx; + const _ws=p.state.overlay_widgets||[]; + const _dw=(_wid?_ws.find(x=>x&&x.id===_wid):null)||_ws[_idx]||{}; const _did=_dw.id||null; p.ovDrag2d=null; overlayCanvas.style.cursor='default'; model.set(`panel_${p.id}_json`, _viewStateJson(p)); @@ -7913,7 +7933,12 @@ fn fs(in : VsOut) -> @location(0) vec4 { // and still drags every other widget. Mirrors the "first pass" precedent in // _ovHitTest1d, where point widgets outrank the range band they sit in. // `silent` tells the drag loop this gesture emits nothing until release. - if (mods && mods.shift) { + // + // The START must be inside the image. `_brushPaintAt` drops out-of-image + // points, so a gesture begun in the axis margin would claim the drag and + // then paint nothing — a dead zone that also swallowed the pan. Leaving the + // image MID-stroke is still fine (it just breaks the stroke). + if (mods && mods.shift && _inImage2d(mx, my, st, imgW, imgH)) { for (let i = widgets.length - 1; i >= 0; i--) { const w = widgets[i]; if (w.type !== 'brush') continue; @@ -8137,6 +8162,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { L.strokes = outS; L.classes = outC; } + // Is CANVAS point (mx,my) over the image itself (not the axis margin)? Same + // bounds rule `_brushPaintAt` applies, so the hit-test can refuse to start a + // stroke exactly where painting would drop every point. + function _inImage2d(mx, my, st, imgW, imgH) { + const [ix, iy] = _canvasToImg2d(mx, my, st, imgW, imgH); + return ix >= 0 && ix < st.image_width && iy >= 0 && iy < st.image_height; + } + // Extend (or start) the brush's stroke at image point (ix,iy). Shared by // mousedown (so a Shift-CLICK with no motion still paints one dot) and the // drag loop. Points OUTSIDE the image are dropped and re-entry starts a NEW diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 8e9175a9..4ab50fdf 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -1771,6 +1771,12 @@ def add_brush_widget(self, radius: float | None = None, once — a stroke is tagged with the widget's current ``class_id`` and drawn in ``colors[class_id]``. + Painting is modal: while the brush is armed, a Shift-press over the + image no longer emits a panel ``pointer_down``, so a host that binds + Shift-click to something else needs a different modifier (or + ``active=False`` while that mode is on). A Shift-drag starting in the + axis margin is not a brush gesture and pans as usual. + The stroke accumulates in the browser and reaches Python **once**, on release, as a ``pointer_up`` event — ``pointer_move`` does not fire for a brush. See :class:`~anyplotlib.BrushWidget`. diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index d40a5d29..0ba10e79 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -227,9 +227,12 @@ def __init__(self, geom_type: str, #: True while the texture UVs are the auto parametric mapping, so #: set_data() can rebuild them for the new grid shape. When they are #: explicit instead, _texture_uv_n records how many vertices they - #: cover so a shape-changing set_data can reject them. + #: cover so a shape-changing set_data can reject them. _texture_flip_v + #: is the flip the mapping was built with — a rebuild has to reapply it + #: or a streaming surface flips vertically on its first update. self._texture_uv_auto: bool = True self._texture_uv_n: int = 0 + self._texture_flip_v: bool = False geom_type = geom_type.lower() if geom_type not in ("surface", "scatter", "line", "voxels"): @@ -481,7 +484,8 @@ def set_data(self, x, y, z) -> None: Bounds given at construction time (``bounds=``) are preserved. An auto-mapped texture (:meth:`set_texture` without ``uv=``) follows the - new grid; explicit UVs are kept and must still match the vertex count. + new grid, keeping the ``flip_v`` it was applied with; explicit UVs are + kept and must still match the vertex count. """ self._state.update(_geometry_state( self._state["geom_type"], x, y, z, bounds=self._bounds)) @@ -490,7 +494,7 @@ def set_data(self, x, y, z) -> None: if self._texture_uv_auto: self._state["texture_uv_b64"] = _uv_to_b64( None, self._state["grid_rows"], self._state["grid_cols"], - n, False) + n, self._texture_flip_v) self._texture_uv_n = n elif self._texture_uv_n != n: raise ValueError( @@ -553,7 +557,16 @@ def set_texture(self, image, *, uv=None, alpha: float = 1.0, occlusion exactly and culling buys nothing. flip_v : bool, optional Mirror the mapping vertically (``v → 1 - v``). Use when the image - is stored bottom-up relative to the grid's row order. + is stored bottom-up relative to the grid's row order. Remembered + for the auto mapping, so a later :meth:`set_data` rebuilds the UVs + with the same flip. + + Raises + ------ + ValueError + If this is not a ``'surface'`` panel, ``alpha`` is outside + ``[0, 1]``, *image* is not a decodable image, or *uv* does not + cover every vertex. See Also -------- @@ -575,16 +588,20 @@ def set_texture(self, image, *, uv=None, alpha: float = 1.0, """ if self._state["geom_type"] != "surface": raise ValueError("textures are only supported for surface plots") + alpha = float(alpha) + if not 0.0 <= alpha <= 1.0: + raise ValueError(f"alpha must be in [0, 1], got {alpha}") url = _image_to_data_url(image) uv_b64 = _uv_to_b64(uv, self._state["grid_rows"], self._state["grid_cols"], self._state["vertices_count"], flip_v) self._texture_uv_auto = uv is None self._texture_uv_n = self._state["vertices_count"] + self._texture_flip_v = bool(flip_v) self._state.update( texture_url=url, texture_uv_b64=uv_b64, - texture_alpha=float(alpha), + texture_alpha=alpha, texture_shade=bool(shade), texture_cull=bool(cull_backfaces), ) @@ -593,6 +610,7 @@ def set_texture(self, image, *, uv=None, alpha: float = 1.0, def clear_texture(self) -> None: """Remove the image texture; the surface reverts to colormapped Z.""" self._texture_uv_auto = True + self._texture_flip_v = False self._state.update(texture_url="", texture_uv_b64="") self._push() diff --git a/anyplotlib/tests/test_interactive/test_orbit_direction.py b/anyplotlib/tests/test_interactive/test_orbit_direction.py new file mode 100644 index 00000000..fae2738a --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_orbit_direction.py @@ -0,0 +1,219 @@ +""" +tests/test_interactive/test_orbit_direction.py +============================================== + +Orbit drag is DIRECT MANIPULATION: the surface under the cursor follows the +cursor, the way a globe does. + +Both signs in the handler are inverted relative to the camera angles, because +azimuth/elevation move the CAMERA rather than the object. A feature's screen +x is ``cx + scale*cos(az - θ)``, so raising azimuth sweeps the surface LEFT, +and raising elevation lifts the camera to show more of the top. The handler +originally added ``dx`` and subtracted ``dy``, which made a rightward drag +push the surface left — it felt like grabbing the far side of the sphere. + +These tests pin the sign at both levels: the camera angle the drag produces, +and the direction a tracked feature actually moves on screen. +""" +from __future__ import annotations + +import base64 +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 orbit_page(_pw_browser): + """Mount a figure via the public ``mount()`` and hand back the live page. + + Not ``orbit_page``: that fixture's template does not expose the + render API, and these tests need both the panel state and ``exportPNG``. + """ + 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(viewport={"width": 500, "height": 500}) + pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=20_000) + page.wait_for_timeout(300) + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +def _sphere(ndec=49, nra=97): + ra = np.linspace(0, 2 * np.pi, nra) + dec = np.linspace(np.pi / 2, -np.pi / 2, ndec) + RA, DEC = np.meshgrid(ra, dec) + return np.cos(DEC) * np.cos(RA), np.cos(DEC) * np.sin(RA), np.sin(DEC) + + +def _ramp_texture(h=256, w=512): + """Red ramps with longitude, green with latitude — two tracked gradients.""" + img = np.zeros((h, w, 3), np.uint8) + yy, xx = np.mgrid[0:h, 0:w] + img[..., 0] = xx * 255 // w + img[..., 1] = 255 - yy * 255 // h + img[..., 2] = 120 # constant: marks "on the sphere" + return img + + +def _globe(): + X, Y, Z = _sphere() + fig, ax = apl.subplots(1, 1, figsize=(360, 360)) + s = ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3, + azimuth=0, elevation=0, gpu=False) + s.set_axis_off() + s.set_texture(_ramp_texture(), cull_backfaces=True) + return fig, s + + +def _drag(page, dx, dy): + # Must be the PANEL's plotCanvas: the first canvas in the DOM is the + # hidden, zero-sized gpuCanvas, whose rect would put the drag at (0, 0). + box = page.evaluate( + """() => { + for (const p of window._api.api.panels.values()) { + if (p.kind !== '3d') continue; + const c = p.plotCanvas.getBoundingClientRect(); + return {cx: c.left + c.width/2, cy: c.top + c.height/2}; + } + }""") + page.mouse.move(box["cx"], box["cy"]) + page.mouse.down() + page.mouse.move(box["cx"] + dx, box["cy"] + dy, steps=20) + page.mouse.up() + page.wait_for_timeout(200) + + +def _view(page): + return page.evaluate("""() => { + for (const p of window._api.api.panels.values()) + if (p.kind === '3d') return {az: p.state.azimuth, el: p.state.elevation}; + }""") + + +class TestOrbitDirection: + """Camera-angle level: which way do the angles move?""" + + def test_drag_right_decreases_azimuth(self, orbit_page): + page = orbit_page(_globe()[0]) + assert _view(page)["az"] == pytest.approx(0, abs=1e-6) + _drag(page, 100, 0) + assert _view(page)["az"] < -5, ( + "dragging right must LOWER azimuth so the surface follows the " + "cursor; a positive azimuth sweeps the surface the other way") + + def test_drag_left_increases_azimuth(self, orbit_page): + page = orbit_page(_globe()[0]) + _drag(page, -100, 0) + assert _view(page)["az"] > 5 + + def test_drag_down_raises_elevation(self, orbit_page): + page = orbit_page(_globe()[0]) + _drag(page, 0, 80) + assert _view(page)["el"] > 5, ( + "dragging down must RAISE elevation — pulling the near face down " + "tips the top toward the camera") + + def test_drag_up_lowers_elevation(self, orbit_page): + page = orbit_page(_globe()[0]) + _drag(page, 0, -80) + assert _view(page)["el"] < -5 + + +def _export(page): + from anyplotlib.tests._png_utils import decode_png + url = page.evaluate( + "() => window._api.exportPNG({scale: 1}).then(r => r.dataUrl)") + return decode_png(base64.b64decode(url.split(",", 1)[1])).astype(int) + + +def _sphere_row(arr): + """(indices, red profile) along the image's centre row, sphere pixels only. + + The blue channel is a constant 120 in the texture, so it identifies what + is sphere and what is background. + """ + h = arr.shape[0] + row = arr[h // 2, :, :3] + on = np.nonzero((row[:, 2] > 60) & (row[:, 2] < 200))[0] + return on, row[on, 0] + + +def _x_of_red(arr, target): + """Screen x where the longitude ramp reaches *target*. + + Longitude increases monotonically across the visible hemisphere, so the + red profile is monotonic and the crossing is unique — but only for a + target that is actually in range, hence taking it from a live frame + rather than hard-coding one. + """ + on, red = _sphere_row(arr) + if len(on) < 20: + return None + cross = np.nonzero((red[:-1] - target) * (red[1:] - target) <= 0)[0] + return float(on[cross[0]]) if len(cross) else None + + +class TestSurfaceFollowsCursor: + """Pixel level: does the texture actually move with the cursor?""" + + def test_texture_moves_right_with_a_rightward_drag(self, orbit_page): + page = orbit_page(_globe()[0]) + page.wait_for_timeout(900) # async texture decode + + before = _export(page) + on, red = _sphere_row(before) + assert len(on) > 40, "sphere did not render" + # Track the longitude that starts at the centre of the disc; a modest + # drag keeps it comfortably on the visible hemisphere. + mid = len(on) // 2 + target = float(red[mid]) + x_before = float(on[mid]) + + _drag(page, 40, 0) + x_after = _x_of_red(_export(page), target) + assert x_after is not None, "lost track of the meridian after the drag" + assert x_after > x_before + 4, ( + f"the tracked meridian moved {x_before:.0f} -> {x_after:.0f}; it " + f"must follow the cursor to the right, not run away from it") diff --git a/anyplotlib/tests/test_interactive/test_star_globe_linking.py b/anyplotlib/tests/test_interactive/test_star_globe_linking.py new file mode 100644 index 00000000..c2fa4494 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_star_globe_linking.py @@ -0,0 +1,187 @@ +""" +tests/test_interactive/test_star_globe_linking.py +================================================= + +The linked celestial globe / sky map explorer +(``Examples/Interactive/plot_star_globe_explorer.py``). + +Two things are worth pinning: + +* the **coordinate identity** the whole demo rests on — orbiting the globe + chooses a zenith, and a zenith IS a local sidereal time and a latitude; +* the **event wiring** — a 3-D orbit reaches Python carrying its camera + angles, and the map overlays follow. ``Event.azimuth``/``elevation`` exist + precisely for this: a JS-side drag does not sync back into ``Plot3D._state``, + so without them a handler cannot react to an orbit at all. + +The browser cannot exercise the coupling: the callbacks live in Python, and a +plain ``mount()`` page has no kernel behind it. So the drag is injected the +way the real bridge injects it — as an ``event_json`` payload. +""" +from __future__ import annotations + +import importlib.util +import json +import pathlib + +import numpy as np +import pytest + +import anyplotlib as apl + + +EXAMPLE = (pathlib.Path(__file__).parents[3] + / "Examples" / "Interactive" / "plot_star_globe_explorer.py") + + +@pytest.fixture(scope="module") +def demo(): + """Execute the example once and hand back its module namespace.""" + spec = importlib.util.spec_from_file_location("_star_globe_demo", EXAMPLE) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# --------------------------------------------------------------------------- +# The coordinate identity +# --------------------------------------------------------------------------- + +class TestZenithMapping: + def test_round_trips(self, demo): + for lst, lat in [(0.0, 0.0), (5.5, 35.0), (18.25, -42.0), (23.9, 89.0)]: + az, el = demo.zenith_to_camera(lst, lat) + back_lst, back_lat = demo.camera_to_zenith(az, el) + assert back_lst == pytest.approx(lst, abs=1e-9) + assert back_lat == pytest.approx(lat, abs=1e-9) + + def test_matches_the_renderer_convention(self, demo): + """``azimuth = RA + 90°`` is what actually centres that RA on screen. + + Cross-check against the other example: azimuth 172.5 is the angle that + puts Orion (RA 5.5 h) in the middle of the globe. + """ + az, el = demo.zenith_to_camera(5.5, 35.0) + assert az == pytest.approx(172.5) + assert el == pytest.approx(35.0) + + def test_azimuth_wraps(self, demo): + assert demo.camera_to_zenith(90.0, 0.0)[0] == pytest.approx(0.0) + assert demo.camera_to_zenith(-270.0, 0.0)[0] == pytest.approx(0.0) + + +class TestHorizon: + def test_every_point_is_ninety_degrees_from_the_zenith(self, demo): + lst, lat = 7.0, 40.0 + segs = demo.horizon_segments(lst, lat) + assert len(segs) > 100 + # Map pixels back to sky coordinates and check the angular distance. + px = segs[:, 0, :] + ra_h = px[:, 0] / (demo.TEX_W - 1) * 24.0 + dec_d = 90.0 - px[:, 1] / (demo.TEX_H - 1) * 180.0 + sep = np.degrees(demo.angsep( + np.radians(ra_h * 15.0), np.radians(dec_d), + np.radians(lst * 15.0), np.radians(lat))) + assert np.allclose(sep, 90.0, atol=0.5), f"sep range {sep.min()}..{sep.max()}" + + def test_no_segment_spans_the_wrap(self, demo): + """A curve crossing RA 0h must be cut, not drawn back across the map.""" + for lst in (0.0, 6.0, 12.0, 23.5): + segs = demo.horizon_segments(lst, 20.0) + span = np.abs(segs[:, 0, 0] - segs[:, 1, 0]) + assert span.max() < demo.TEX_W / 2 + + def test_pole_zenith_is_the_celestial_equator(self, demo): + """At the pole the horizon IS the equator — the demo's punchline.""" + segs = demo.horizon_segments(3.0, 90.0) + dec = 90.0 - segs[:, 0, 1] / (demo.TEX_H - 1) * 180.0 + assert np.allclose(dec, 0.0, atol=0.5) + + +class TestVisibleStars: + def test_only_stars_above_the_horizon(self, demo): + lst, lat = 5.5, 35.0 + pts = demo.visible_stars(lst, lat) + assert 0 < len(pts) < len(demo.STARS) + ra_h = pts[:, 0] / (demo.TEX_W - 1) * 24.0 + dec_d = 90.0 - pts[:, 1] / (demo.TEX_H - 1) * 180.0 + alt = 90.0 - np.degrees(demo.angsep( + np.radians(ra_h * 15.0), np.radians(dec_d), + np.radians(lst * 15.0), np.radians(lat))) + assert (alt > -0.5).all() + + def test_north_pole_hides_the_southern_sky(self, demo): + pts = demo.visible_stars(0.0, 89.9) + dec_d = 90.0 - pts[:, 1] / (demo.TEX_H - 1) * 180.0 + assert (dec_d > -1.0).all() + # Sirius (dec -16.7) is down; Polaris (dec +89.3) is up. + assert len(pts) > 5 + + +# --------------------------------------------------------------------------- +# Event wiring +# --------------------------------------------------------------------------- + +def _orbit(fig, panel_id, azimuth, elevation): + """Inject a 3-D orbit exactly as the JS renderer emits it.""" + fig.event_json = json.dumps({ + "source": "js", "panel_id": panel_id, "event_type": "pointer_move", + "azimuth": azimuth, "elevation": elevation, "zoom": 1.0, + "x": 10, "y": 10, + }) + + +class TestEventCarriesCameraAngles: + def test_azimuth_and_elevation_reach_the_handler(self): + fig, ax = apl.subplots(1, 1, figsize=(240, 240)) + surf = ax.plot_surface(*np.meshgrid(np.linspace(0, 1, 4), + np.linspace(0, 1, 4)) + + (np.zeros((4, 4)),)) + seen = {} + + @surf.add_event_handler("pointer_move") + def _grab(event): + seen["az"] = event.azimuth + seen["el"] = event.elevation + + _orbit(fig, surf._id, 123.5, -17.25) + assert seen == {"az": 123.5, "el": -17.25} + + def test_absent_angles_stay_none(self): + """A 2-D pointer_move carries no camera, and must not invent one.""" + fig, ax = apl.subplots(1, 1, figsize=(240, 240)) + img = ax.imshow(np.zeros((8, 8))) + seen = {} + + @img.add_event_handler("pointer_move") + def _grab(event): + seen["az"] = event.azimuth + + fig.event_json = json.dumps({ + "source": "js", "panel_id": img._id, + "event_type": "pointer_move", "x": 1, "y": 1}) + assert seen == {"az": None} + + +class TestCoupling: + def test_orbiting_the_globe_moves_the_map_overlays(self, demo): + before = np.asarray(demo.zenith._data["offsets"])[0] + # Put RA 12h / dec 0 overhead and confirm the map follows. + az, el = demo.zenith_to_camera(12.0, 0.0) + _orbit(demo.fig, demo.globe._id, az, el) + after = np.asarray(demo.zenith._data["offsets"])[0] + assert after != pytest.approx(before) + assert after[0] == pytest.approx(demo.sky_pixel(12.0, 0.0)[0], abs=1.0) + assert after[1] == pytest.approx(demo.sky_pixel(12.0, 0.0)[1], abs=1.0) + + def test_orbiting_moves_the_sliders_but_not_the_globe(self, demo): + az, el = demo.zenith_to_camera(9.0, 20.0) + _orbit(demo.fig, demo.globe._id, az, el) + assert demo.lst_line.x == pytest.approx(9.0, abs=1e-6) + assert demo.lat_line.x == pytest.approx(20.0, abs=1e-6) + + def test_the_title_reports_the_zenith(self, demo): + az, el = demo.zenith_to_camera(15.25, -33.0) + _orbit(demo.fig, demo.globe._id, az, el) + title = demo.sky_map._state["title"] + assert "15.2" in title and "-33" in title diff --git a/anyplotlib/tests/test_plot3d/test_gpu_depth.py b/anyplotlib/tests/test_plot3d/test_gpu_depth.py new file mode 100644 index 00000000..9f38bb27 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_gpu_depth.py @@ -0,0 +1,133 @@ +""" +tests/test_plot3d/test_gpu_depth.py +=================================== + +Depth-ordering contract for the WebGPU 3-D paths. + +``_gpuMatrix`` emits ``clip.z`` from the geometry's depth into the screen, and +every depth-tested GPU pipeline compares with ``less`` against a 1.0 clear — +so ``clip.z`` MUST increase with depth for the nearest fragment to win. The +sign was originally negated, which inverted the test: a GPU scatter cloud +painted its far points over its near ones, and (once textured surfaces gained +a GPU path) a sphere rendered inside-out, showing the far hemisphere. + +It went unnoticed for scatter because the artifact only shows where two points +overlap on screen, and voxels — the other GPU consumer — disable depth writes +entirely, so they never exercised the comparison. + +These tests pin the behaviour against the Canvas2D path, which orders by an +explicit back-to-front sort and is the reference. +""" +from __future__ import annotations + +import base64 +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.embed import esm_path, figure_state +from anyplotlib.tests._png_utils import decode_png + + +_MOUNT_PAGE = """ + + +
+ +""" + + +@pytest.fixture +def render(_pw_gpu_browser): + """Render a figure in the WebGPU browser → (pixels, {gpu, active}).""" + pages, paths = [], [] + + def _render(fig, panel_id): + 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_gpu_browser.new_page() + pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=20_000) + page.wait_for_timeout(1200) + info = page.evaluate( + """(pid) => { + const p = window._api.api.panels.get(pid); + return p ? { gpu: p._gpu, active: !!p._gpuActiveNow } : null; + }""", panel_id) + url = page.evaluate( + "() => window._api.exportPNG({scale: 1}).then(r => r.dataUrl)") + px = decode_png(base64.b64decode(url.split(",", 1)[1])).astype(int) + return px, info + + yield _render + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +def _occluding_pair(gpu): + """Two points that coincide on screen, differing ONLY in depth. + + The camera's into-screen axis for (azimuth 0, elevation 30) is the second + row of ``_rot3``: ``(0, cos30, -sin30)``. Placing the pair along it makes + them project to the same pixel, so whichever is drawn on top is exactly + the depth-test answer. Near is red, far is blue. + + NB the elevation is 30 rather than a cleaner 0 because the renderer reads + it as ``st.elevation || 30`` — a passed 0 is falsy and becomes 30. + """ + e = np.radians(30.0) + d = np.array([0.0, np.cos(e), -np.sin(e)]) + pts = np.vstack([-0.8 * d, 0.8 * d]) + fig, ax = apl.subplots(1, 1, figsize=(240, 240)) + s = ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], + colors=["#ff0000", "#0000ff"], point_size=30, + bounds=((-1, 1),) * 3, gpu=gpu) + s.set_axis_off() + s.set_view(azimuth=0) + return fig, s + + +def _centre(arr): + h, w = arr.shape[:2] + return arr[h // 2, w // 2, :3] + + +class TestScatterDepth: + def test_canvas_draws_the_near_point_on_top(self, render): + fig, s = _occluding_pair(False) + arr, info = render(fig, s._id) + assert not info["active"] + px = _centre(arr) + assert px[0] > px[2] + 60, f"expected the near (red) point, got {px}" + + def test_gpu_draws_the_near_point_on_top(self, render): + fig, s = _occluding_pair("always") + arr, info = render(fig, s._id) + assert info["active"], "GPU path did not activate" + px = _centre(arr) + assert px[0] > px[2] + 60, ( + f"expected the near (red) point, got {px} — clip.z sign inverted?") diff --git a/anyplotlib/tests/test_plot3d/test_texture.py b/anyplotlib/tests/test_plot3d/test_texture.py new file mode 100644 index 00000000..b5e7a0e9 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_texture.py @@ -0,0 +1,596 @@ +""" +tests/test_plot3d/test_texture.py +================================= + +Image textures on 3-D surfaces (``Plot3D.set_texture``) — see +``Examples/PlotTypes/plot_3d_texture.py``. + +Covers: + * the encode path (array / PNG bytes / JPEG bytes / file path → data URL) + * default parametric UVs and explicit ``uv=`` + * validation (non-surface geometry, wrong UV length, bad image bytes) + * ``set_data`` regenerating auto UVs and rejecting stale explicit ones + * the texture riding the geometry channel, not the light view trait + * Playwright: the texture actually paints, and neighbouring triangles do + not leave a mesh of background-coloured seams between them +""" +from __future__ import annotations + +import base64 +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib._utils import _encode_png +from anyplotlib.embed import esm_path, figure_state +from anyplotlib.tests._png_utils import decode_png + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _sphere(ndec=25, nra=49): + """A closed unit sphere: columns run 0→2π, rows +90°→−90°.""" + ra = np.linspace(0, 2 * np.pi, nra) + dec = np.linspace(np.pi / 2, -np.pi / 2, ndec) + RA, DEC = np.meshgrid(ra, dec) + return (np.cos(DEC) * np.cos(RA), np.cos(DEC) * np.sin(RA), np.sin(DEC)) + + +def _surface(ndec=25, nra=49, **kwargs): + X, Y, Z = _sphere(ndec, nra) + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + return ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3, **kwargs) + + +def _checker(h=64, w=128): + img = np.zeros((h, w, 3), np.uint8) + yy, xx = np.mgrid[0:h, 0:w] + img[((yy // 8) + (xx // 8)) % 2 == 0] = [220, 40, 40] + img[((yy // 8) + (xx // 8)) % 2 == 1] = [40, 60, 220] + return img + + +def _uv(plot) -> np.ndarray: + return np.frombuffer( + base64.b64decode(plot._state["texture_uv_b64"]), " + + +
+ +""" + + +def _renderer(browser): + """Build a ``render(fig, panel_id=None) -> (pixels, panel_info)`` closure.""" + pages, paths = [], [] + + def _render(fig, panel_id=None): + 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 = browser.new_page() + pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=20_000) + # The decode is async and schedules its own redraw; on the GPU + # path the device init adds a second async hop before activation. + page.wait_for_timeout(1200) + info = None + if panel_id is not None: + info = page.evaluate( + """(pid) => { + const p = window._api.api.panels.get(pid); + return p ? { gpu: p._gpu, active: !!p._gpuActiveNow } : null; + }""", panel_id) + url = page.evaluate( + "() => window._api.exportPNG({scale: 1}).then(r => r.dataUrl)") + px = decode_png(base64.b64decode(url.split(",", 1)[1])).astype(int) + return (px, info) if panel_id is not None else px + + def _cleanup(): + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + return _render, _cleanup + + +@pytest.fixture +def render_png(_pw_browser): + """Mount a figure, wait for the async texture decode, return the pixels. + + The default headless shell has no ``navigator.gpu``, so this always + exercises the Canvas2D path. + """ + render, cleanup = _renderer(_pw_browser) + yield render + cleanup() + + +@pytest.fixture +def gpu_render_png(_pw_gpu_browser): + """Same, in the WebGPU-capable browser (skips without a usable adapter).""" + render, cleanup = _renderer(_pw_gpu_browser) + yield render + cleanup() + + +def _flat_globe(rgb, **kwargs): + """A sphere wrapped in one solid colour, filling the panel head-on.""" + X, Y, Z = _sphere(49, 97) + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + s = ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3, + azimuth=0, elevation=0) + s.set_axis_off() + tex = np.zeros((128, 256, 3), np.uint8) + tex[:] = rgb + s.set_texture(tex, cull_backfaces=True, **kwargs) + return fig, s + + +def _disc(arr, inset=3): + """Pixels well inside the sphere's silhouette (its own outline excluded).""" + h, w = arr.shape[:2] + non_bg = arr[..., :3].sum(2) < 600 + ys, xs = np.nonzero(non_bg) + assert len(ys), "nothing was drawn" + cy, cx = h / 2, w / 2 + rad = np.hypot(ys - cy, xs - cx).max() + yy, xx = np.mgrid[0:h, 0:w] + return arr[non_bg & (np.hypot(yy - cy, xx - cx) < rad - inset)][:, :3] + + +class TestRender: + def test_texture_colour_reaches_the_canvas(self, render_png): + fig, _ = _flat_globe((0, 0, 200)) + px = _disc(render_png(fig)) + assert len(px) > 1000 + # Every interior pixel is the texture colour, not a viridis ramp. + assert px[:, 2].mean() > 150 + assert px[:, 0].mean() < 40 + + def test_no_seams_between_neighbouring_triangles(self, render_png): + """A flat texture must render flat. + + The triangles are deliberately grown so neighbours overlap; without + that, each covers about half of the pixels along a shared edge and + the panel background bleeds through as a mesh of hairlines over the + whole surface. Assert on that directly: with one uniform colour, + any interior pixel that is lighter than it is an artifact. + """ + fig, _ = _flat_globe((20, 24, 46)) + px = _disc(render_png(fig)) + artifacts = (px[:, 0] > 20 + 8).sum() + assert artifacts / len(px) < 0.01, ( + f"{artifacts}/{len(px)} interior pixels bled through " + f"(max {px.max(0)}, expected [20 24 46])") + + def test_shade_lights_the_sphere_from_the_upper_left(self, render_png): + """Diffuse shading must fall off away from the key light. + + The light sits front-and-upper-left in view space, so on a flat grey + sphere the upper-left of the disc has to come out brighter than the + lower-right — and both dimmer than the same sphere unshaded. + """ + means = {} + for shade in (False, True): + fig, _ = _flat_globe((200, 200, 200), shade=shade) + arr = render_png(fig) + h, w = arr.shape[:2] + cy, cx = h / 2, w / 2 + yy, xx = np.mgrid[0:h, 0:w] + r = np.hypot(yy - cy, xx - cx) + drawn = arr[..., :3].sum(2) < 600 + inside = drawn & (r < r[drawn].max() - 4) + ul = inside & (yy < cy) & (xx < cx) + lr = inside & (yy > cy) & (xx > cx) + means[shade] = (arr[ul][:, :3].mean(), arr[lr][:, :3].mean()) + + assert means[True][0] > means[True][1] + 15, ( + f"shaded upper-left {means[True][0]:.0f} should beat " + f"lower-right {means[True][1]:.0f}") + assert means[False][0] == pytest.approx(means[False][1], abs=6), ( + f"unshaded halves should match: {means[False]}") + + def test_clear_texture_falls_back_to_the_colormap(self, render_png): + fig, s = _flat_globe((0, 0, 200)) + s.clear_texture() + px = _disc(render_png(fig)) + # viridis over a sphere is green/yellow at the top, purple at the + # bottom — in any case not the flat blue the texture painted. + assert px[:, 2].mean() < 150 + + def test_untextured_surface_still_renders(self, render_png): + X, Y, Z = _sphere(25, 49) + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3) + assert len(_disc(render_png(fig))) > 500 + + +# --------------------------------------------------------------------------- +# WebGPU path +# --------------------------------------------------------------------------- + +def _hemis_texture(h=128, w=256): + """Left half of the image red, right half blue. + + On a sphere this puts one colour on each hemisphere, so a wrong depth + direction — showing the FAR side instead of the near one — swaps which + colour faces the camera and is impossible to miss. + """ + img = np.zeros((h, w, 3), np.uint8) + img[:, :w // 2] = [220, 30, 30] + img[:, w // 2:] = [30, 30, 220] + return img + + +def _gpu_globe(gpu, ndec=49, nra=97, tex=None, **kw): + X, Y, Z = _sphere(ndec, nra) + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + s = ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3, gpu=gpu) + s.set_axis_off() + s.set_view(azimuth=0, elevation=0) + s.set_texture(_hemis_texture() if tex is None else tex, **kw) + return fig, s + + +class TestGpuSurface: + """A textured surface renders on WebGPU and agrees with Canvas2D.""" + + def test_activates_above_the_threshold(self, gpu_render_png): + fig, s = _gpu_globe("auto") # 9216 triangles > 2000 + _, info = gpu_render_png(fig, s._id) + assert info["gpu"] == "active" and info["active"] + + def test_gpu_false_forces_canvas(self, gpu_render_png): + fig, s = _gpu_globe(False) + _, info = gpu_render_png(fig, s._id) + assert not info["active"] + + def test_colormapped_surface_stays_on_canvas(self, gpu_render_png): + """Only TEXTURED surfaces have a GPU path.""" + X, Y, Z = _sphere(49, 97) + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + s = ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3, gpu="always") + _, info = gpu_render_png(fig, s._id) + assert not info["active"] + + def test_translucent_surface_stays_on_canvas(self, gpu_render_png): + """alpha < 1 needs the overlap-free Canvas2D composite.""" + fig, s = _gpu_globe("always", alpha=0.5) + _, info = gpu_render_png(fig, s._id) + assert not info["active"] + + def test_shows_the_near_hemisphere(self, gpu_render_png): + """The depth test must keep the NEAREST fragment. + + Regression for an inverted clip-z in ``_gpuMatrix``: with the sign + flipped the sphere rendered inside-out and the camera saw the far + hemisphere's texture instead of the near one. + """ + fig_c, s_c = _gpu_globe(False) + fig_g, s_g = _gpu_globe("always") + canvas, _ = gpu_render_png(fig_c, s_c._id) + gpu, info = gpu_render_png(fig_g, s_g._id) + assert info["active"] + + def split(arr): + px = _disc(arr, inset=8) + return (px[:, 0] > px[:, 2]).mean() # fraction reading "red" + + assert split(gpu) == pytest.approx(split(canvas), abs=0.08), ( + "GPU and Canvas2D disagree about which hemisphere faces the camera") + + def test_matches_canvas_pixels(self, gpu_render_png): + """Same geometry, same texture — the two paths must look the same. + + Not pixel-exact: the GPU shades per pixel against an interpolated + normal and samples through a mip chain, where Canvas2D shades per + triangle. A small mean difference is expected; a large one means the + mapping, orientation, or depth handling diverged. + """ + tex = _checker() + fig_c, s_c = _gpu_globe(False, tex=tex) + fig_g, s_g = _gpu_globe("always", tex=tex) + canvas, _ = gpu_render_png(fig_c, s_c._id) + gpu, info = gpu_render_png(fig_g, s_g._id) + assert info["active"] + assert gpu.shape == canvas.shape + diff = np.abs(gpu[..., :3] - canvas[..., :3]) + assert diff.mean() < 12, f"mean abs diff {diff.mean():.1f}" + + def test_shading_lights_the_upper_left(self, gpu_render_png): + flat = np.full((128, 256, 3), 200, np.uint8) + fig, s = _gpu_globe("always", tex=flat, shade=True) + arr, info = gpu_render_png(fig, s._id) + assert info["active"] + h, w = arr.shape[:2] + cy, cx = h / 2, w / 2 + yy, xx = np.mgrid[0:h, 0:w] + r = np.hypot(yy - cy, xx - cx) + drawn = arr[..., :3].sum(2) < 600 + inside = drawn & (r < r[drawn].max() - 4) + ul = arr[inside & (yy < cy) & (xx < cx)][:, :3].mean() + lr = arr[inside & (yy > cy) & (xx > cx)][:, :3].mean() + assert ul > lr + 15, f"upper-left {ul:.0f}, lower-right {lr:.0f}" + + def test_live_texture_swap_rebuilds_the_gpu_texture(self, gpu_render_png): + """set_texture after the first draw must reach the GPU texture.""" + fig, s = _gpu_globe("always", tex=np.full((64, 128, 3), 220, np.uint8)) + s.set_texture(np.zeros((64, 128, 3), np.uint8) + np.array( + [20, 200, 40], np.uint8)) + arr, info = gpu_render_png(fig, s._id) + assert info["active"] + px = _disc(arr).mean(0) + assert px[1] > px[0] + 40 and px[1] > px[2] + 40, f"got {px}" diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 5f26d187..9de2eb51 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -288,6 +288,13 @@ class BrushWidget(Widget): claimed a plain drag would hit-test as "anywhere in the image" and kill panning and click-to-select outright. + Painting is *modal*: while a brush is armed, a Shift-press that starts over + the image is consumed by the brush and no longer produces a panel + ``pointer_down``. If the host binds Shift-click to something else + (multi-select is the common one), give it a different modifier or set + ``active=False`` while that mode is on. A Shift-drag beginning outside the + image — in the axis margin — is not a brush gesture and pans as usual. + While the stroke is being drawn the points accumulate **in the browser** and only the finished stroke reaches Python, once, as a ``pointer_up`` event. So ``pointer_move`` does **not** fire for a brush stroke — register on diff --git a/pyproject.toml b/pyproject.toml index ec7bc498..7916e18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,13 @@ issue_format = "`#{issue} Date: Thu, 30 Jul 2026 23:18:55 -0500 Subject: [PATCH 4/5] fix(tests): derive the sphere mask from the render's background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_shade_lights_the_sphere_from_the_upper_left` blew up on macOS CI with "zero-size array to reduction operation maximum": the `drawn` mask was empty. The mask was `arr[..., :3].sum(2) < 600` — "darker than the lightest background". The shading tests wrap the sphere in a flat (200, 200, 200) texture, and 200 x 3 is exactly 600, so a strict `<` classifies every interior pixel as background. It only ever "passed" by accident: on Windows and Ubuntu a handful of antialiased 199-greys land at 597 and the test limped along on ~1400 of the disc's ~28000 pixels (which is also why its unshaded halves-match assertion was meaningless there — it compared 199.0 against 199.0). macOS rounds those stragglers to 200 as well, so nothing at all came back and the reduction had nothing to work on. Replace the magic sum with `_drawn_mask`, which asks the actual question: is this pixel one of the two flat colours the figure paints behind the sphere? Those colours are read out of the render itself — the projected sphere's radius is ~0.32 x the panel size, so anything covering a meaningful share of the image beyond 0.45 is background by construction, and the 5 % share cut drops sparse decorations (axis lines, tick labels) that sit out there when axes are on. Shared by `_disc` and both shading tests, so there is one definition of "the sphere" in the file. Measured against every render scenario the file has, the new mask agrees with the old one to within 1.5 % of the disc (seam-artifact fraction is identical at 0.0015) except in the broken case, where it recovers all 28169 pixels instead of 1442. --- anyplotlib/tests/test_plot3d/test_texture.py | 36 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/anyplotlib/tests/test_plot3d/test_texture.py b/anyplotlib/tests/test_plot3d/test_texture.py index b5e7a0e9..2567fee0 100644 --- a/anyplotlib/tests/test_plot3d/test_texture.py +++ b/anyplotlib/tests/test_plot3d/test_texture.py @@ -399,10 +399,40 @@ def _flat_globe(rgb, **kwargs): return fig, s +def _drawn_mask(arr, tol=6): + """Boolean mask of the pixels the sphere actually painted. + + "Not background", derived from the render itself: the figure paints two + flat colours behind the sphere (the figure margin and the panel's plot + background), and the projected sphere's radius is only ~0.32 x the panel + size, so every colour covering a meaningful share of the image OUTSIDE a + 0.45 radius is a background by construction. Sparse decorations out + there (axis lines, tick labels) fall under the 5 % share cut. + + This replaces a fixed ``arr[..., :3].sum(2) < 600`` threshold — "darker + than the lightest background". That threshold sits EXACTLY on the flat + (200, 200, 200) texture the shading tests use (200 x 3 == 600), so a + strict ``<`` classified the whole sphere as background: on Windows and + Ubuntu a handful of antialiased 199-greys squeaked under and the test + limped along on 5 % of the disc, on macOS none did and the mask came back + empty. Keying on the background colours instead is independent of how + light or dark the texture happens to be. + """ + rgb = arr[..., :3] + h, w = rgb.shape[:2] + yy, xx = np.mgrid[0:h, 0:w] + ring = rgb[np.hypot(yy - h / 2, xx - w / 2) > 0.45 * min(h, w)].reshape(-1, 3) + cols, counts = np.unique(ring, axis=0, return_counts=True) + mask = np.ones((h, w), bool) + for bg in cols[counts > 0.05 * len(ring)]: + mask &= np.abs(rgb - bg).max(2) > tol + return mask + + def _disc(arr, inset=3): """Pixels well inside the sphere's silhouette (its own outline excluded).""" h, w = arr.shape[:2] - non_bg = arr[..., :3].sum(2) < 600 + non_bg = _drawn_mask(arr) ys, xs = np.nonzero(non_bg) assert len(ys), "nothing was drawn" cy, cx = h / 2, w / 2 @@ -451,7 +481,7 @@ def test_shade_lights_the_sphere_from_the_upper_left(self, render_png): cy, cx = h / 2, w / 2 yy, xx = np.mgrid[0:h, 0:w] r = np.hypot(yy - cy, xx - cx) - drawn = arr[..., :3].sum(2) < 600 + drawn = _drawn_mask(arr) inside = drawn & (r < r[drawn].max() - 4) ul = inside & (yy < cy) & (xx < cx) lr = inside & (yy > cy) & (xx > cx) @@ -579,7 +609,7 @@ def test_shading_lights_the_upper_left(self, gpu_render_png): cy, cx = h / 2, w / 2 yy, xx = np.mgrid[0:h, 0:w] r = np.hypot(yy - cy, xx - cx) - drawn = arr[..., :3].sum(2) < 600 + drawn = _drawn_mask(arr) inside = drawn & (r < r[drawn].max() - 4) ul = arr[inside & (yy < cy) & (xx < cx)][:, :3].mean() lr = arr[inside & (yy > cy) & (xx > cx)][:, :3].mean() From 2205f0b5f0f98331876ddde3bd40a565a25b8fc7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 30 Jul 2026 23:19:19 -0500 Subject: [PATCH 5/5] fix(tests): wait for the 3-D GPU path to settle instead of sleeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five WebGPU surface tests failed on macOS CI reading `_gpu == 'pending'` — the renderer's in-flight state while `_gpuDevice()` resolves. macOS is the only runner where these tests do not skip: the `_pw_gpu_browser` capability probe finds no usable adapter on GitHub's ubuntu and windows images, so "Ubuntu and Windows CI pass" said nothing about this path. The harness slept a fixed 1200 ms. A textured surface needs TWO sequential async hops before it reaches its final rendering, and each schedules its own redraw: the `` decode (because `_gpuWanted` refuses the GPU for a surface until there is something to sample), then WebGPU device + pipeline init. Timed here on a fast box with a real adapter, the decode lands at ~315 ms and activation only at ~950 ms — a fixed 1200 ms had ~20 % headroom on the machine the test was written on. A macOS runner exceeding that is a race lost, not a renderer defect; the same runner passes the scatter test in test_gpu_depth, which starts its device request at mount and so skips the decode hop entirely. Wait for the condition instead: every 3-D panel has decoded its declared texture, is not `'pending'`, and (if `'active'`) has completed its activation redraw. On timeout, raise with draw3d's own `__apl_gpu3d` diagnostic, which distinguishes "no adapter" from "texture still decoding" from "panel had zero size" — and thread that same diagnostic into every GPU assertion message, so a CI-only failure explains itself instead of just printing `assert False`. `wait_3d_settled` / `gpu3d_diag` live in tests/conftest.py next to the WebGPU probe. test_gpu_depth gets the same treatment: it passes today, but by headroom rather than by construction. Also corrects the `render_png` docstring — newer Playwright headless shells do expose `navigator.gpu`; what keeps that fixture on Canvas2D is `requestAdapter()` returning null, which latches `_gpu = 'unavailable'`. --- anyplotlib/tests/conftest.py | 64 +++++++++++++++++++ .../tests/test_plot3d/test_gpu_depth.py | 8 ++- anyplotlib/tests/test_plot3d/test_texture.py | 32 ++++++---- 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/anyplotlib/tests/conftest.py b/anyplotlib/tests/conftest.py index 65e34343..0b693f57 100644 --- a/anyplotlib/tests/conftest.py +++ b/anyplotlib/tests/conftest.py @@ -377,6 +377,70 @@ def _pw_gpu_browser(_pw_browser): browser.close() +# --------------------------------------------------------------------------- +# 3-D render settling (Playwright) +# --------------------------------------------------------------------------- + +_3D_SETTLED_JS = """() => { + const api = window._api && window._api.api; + if (!api) return false; + for (const p of api.panels.values()) { + if (p.kind !== '3d') continue; + const st = p.state || {}; + // A declared surface texture must have finished decoding: _texEnsure + // builds an and schedules its own redraw, so the frame that first + // sees the texture still paints the untextured surface. + if (st.texture_url && !(p._3dTex && p._3dTex.ready)) return false; + // …and the GPU decision must not be in flight. 'pending' means + // _gpuDevice() has not resolved yet; 'active' without _gpuActiveNow means + // the activation redraw has not run yet. 'unavailable'/undefined are + // terminal — the panel is on Canvas2D and stays there. + if (p._gpu === 'pending') return false; + if (p._gpu === 'active' && !p._gpuActiveNow) return false; + } + return true; +}""" + + +def gpu3d_diag(page): + """Return ``globalThis.__apl_gpu3d`` — draw3d's per-panel GPU diagnostic. + + Each entry is ``{geom, gpu, wanted, texUrl, texReady, faces, mode, pw, ph, + hasNavGpu}`` as of that panel's last frame, which is what distinguishes + "no adapter" from "texture still decoding" from "panel had zero size". + """ + try: + return page.evaluate("() => globalThis.__apl_gpu3d || {}") + except Exception as exc: # page closed / crashed + return {"": str(exc)} + + +def wait_3d_settled(page, timeout=20_000): + """Block until every 3-D panel in *page* has finished its async work. + + A textured surface takes TWO sequential async hops to reach its final + rendering — the ```` decode, then (when the GPU path is wanted) + WebGPU device + pipeline init — and each schedules its own redraw. A + fixed sleep is therefore a race by construction: measured on a fast + workstation with a real adapter, decode lands at ~315 ms and activation + only at ~950 ms, so the 1200 ms sleep this replaced had ~20 % headroom. + The macOS CI runner ran past it and sampled panels while ``_gpu`` was + still ``'pending'``. + + Raises ``AssertionError`` carrying the ``__apl_gpu3d`` dump on timeout, so + a CI-only failure reports *which* hop never landed. + """ + from playwright.sync_api import Error as PwError + + try: + page.wait_for_function(_3D_SETTLED_JS, timeout=timeout) + except PwError as exc: + raise AssertionError( + f"3-D panels never settled within {timeout} ms: {exc}\n" + f"__apl_gpu3d = {gpu3d_diag(page)}" + ) from None + + @pytest.fixture def gpu_interact_page(_pw_gpu_browser): """Like ``interact_page`` but in the WebGPU-capable browser. diff --git a/anyplotlib/tests/test_plot3d/test_gpu_depth.py b/anyplotlib/tests/test_plot3d/test_gpu_depth.py index 9f38bb27..62e4f059 100644 --- a/anyplotlib/tests/test_plot3d/test_gpu_depth.py +++ b/anyplotlib/tests/test_plot3d/test_gpu_depth.py @@ -31,6 +31,7 @@ import anyplotlib as apl from anyplotlib.embed import esm_path, figure_state from anyplotlib.tests._png_utils import decode_png +from anyplotlib.tests.conftest import gpu3d_diag, wait_3d_settled _MOUNT_PAGE = """ @@ -67,12 +68,17 @@ def _render(fig, panel_id): pages.append(page) page.goto(tmp.as_uri()) page.wait_for_function("() => window._aplReady === true", timeout=20_000) - page.wait_for_timeout(1200) + # Device init is async and schedules the activation redraw itself, so + # wait for the panel's GPU decision to settle instead of sleeping a + # fixed amount (which races a slow runner — see wait_3d_settled). + wait_3d_settled(page) info = page.evaluate( """(pid) => { const p = window._api.api.panels.get(pid); return p ? { gpu: p._gpu, active: !!p._gpuActiveNow } : null; }""", panel_id) + if info is not None: + info["diag"] = gpu3d_diag(page).get(panel_id) url = page.evaluate( "() => window._api.exportPNG({scale: 1}).then(r => r.dataUrl)") px = decode_png(base64.b64decode(url.split(",", 1)[1])).astype(int) diff --git a/anyplotlib/tests/test_plot3d/test_texture.py b/anyplotlib/tests/test_plot3d/test_texture.py index 2567fee0..2737191d 100644 --- a/anyplotlib/tests/test_plot3d/test_texture.py +++ b/anyplotlib/tests/test_plot3d/test_texture.py @@ -28,6 +28,7 @@ from anyplotlib._utils import _encode_png from anyplotlib.embed import esm_path, figure_state from anyplotlib.tests._png_utils import decode_png +from anyplotlib.tests.conftest import gpu3d_diag, wait_3d_settled # --------------------------------------------------------------------------- @@ -340,8 +341,11 @@ def _render(fig, panel_id=None): page.goto(tmp.as_uri()) page.wait_for_function("() => window._aplReady === true", timeout=20_000) # The decode is async and schedules its own redraw; on the GPU - # path the device init adds a second async hop before activation. - page.wait_for_timeout(1200) + # path the device init adds a SECOND async hop before activation. + # Wait for both to land rather than sleeping a fixed amount — the two + # hops already cost ~950 ms on a fast box with a real adapter, so any + # constant is a race on a slower runner. + wait_3d_settled(page) info = None if panel_id is not None: info = page.evaluate( @@ -349,6 +353,10 @@ def _render(fig, panel_id=None): const p = window._api.api.panels.get(pid); return p ? { gpu: p._gpu, active: !!p._gpuActiveNow } : null; }""", panel_id) + # Carry draw3d's own diagnostic into the assertion messages, so a + # GPU failure that only reproduces in CI says why. + if info is not None: + info["diag"] = gpu3d_diag(page).get(panel_id) url = page.evaluate( "() => window._api.exportPNG({scale: 1}).then(r => r.dataUrl)") px = decode_png(base64.b64decode(url.split(",", 1)[1])).astype(int) @@ -370,7 +378,9 @@ def _cleanup(): def render_png(_pw_browser): """Mount a figure, wait for the async texture decode, return the pixels. - The default headless shell has no ``navigator.gpu``, so this always + The headless shell resolves no WebGPU adapter (newer Playwright builds do + expose ``navigator.gpu``, but ``requestAdapter()`` comes back null, so the + panel latches ``_gpu === 'unavailable'``), which means this always exercises the Canvas2D path. """ render, cleanup = _renderer(_pw_browser) @@ -541,12 +551,12 @@ class TestGpuSurface: def test_activates_above_the_threshold(self, gpu_render_png): fig, s = _gpu_globe("auto") # 9216 triangles > 2000 _, info = gpu_render_png(fig, s._id) - assert info["gpu"] == "active" and info["active"] + assert info["gpu"] == "active" and info["active"], info def test_gpu_false_forces_canvas(self, gpu_render_png): fig, s = _gpu_globe(False) _, info = gpu_render_png(fig, s._id) - assert not info["active"] + assert not info["active"], info def test_colormapped_surface_stays_on_canvas(self, gpu_render_png): """Only TEXTURED surfaces have a GPU path.""" @@ -554,13 +564,13 @@ def test_colormapped_surface_stays_on_canvas(self, gpu_render_png): fig, ax = apl.subplots(1, 1, figsize=(300, 300)) s = ax.plot_surface(X, Y, Z, bounds=((-1, 1),) * 3, gpu="always") _, info = gpu_render_png(fig, s._id) - assert not info["active"] + assert not info["active"], info def test_translucent_surface_stays_on_canvas(self, gpu_render_png): """alpha < 1 needs the overlap-free Canvas2D composite.""" fig, s = _gpu_globe("always", alpha=0.5) _, info = gpu_render_png(fig, s._id) - assert not info["active"] + assert not info["active"], info def test_shows_the_near_hemisphere(self, gpu_render_png): """The depth test must keep the NEAREST fragment. @@ -573,7 +583,7 @@ def test_shows_the_near_hemisphere(self, gpu_render_png): fig_g, s_g = _gpu_globe("always") canvas, _ = gpu_render_png(fig_c, s_c._id) gpu, info = gpu_render_png(fig_g, s_g._id) - assert info["active"] + assert info["active"], info def split(arr): px = _disc(arr, inset=8) @@ -595,7 +605,7 @@ def test_matches_canvas_pixels(self, gpu_render_png): fig_g, s_g = _gpu_globe("always", tex=tex) canvas, _ = gpu_render_png(fig_c, s_c._id) gpu, info = gpu_render_png(fig_g, s_g._id) - assert info["active"] + assert info["active"], info assert gpu.shape == canvas.shape diff = np.abs(gpu[..., :3] - canvas[..., :3]) assert diff.mean() < 12, f"mean abs diff {diff.mean():.1f}" @@ -604,7 +614,7 @@ def test_shading_lights_the_upper_left(self, gpu_render_png): flat = np.full((128, 256, 3), 200, np.uint8) fig, s = _gpu_globe("always", tex=flat, shade=True) arr, info = gpu_render_png(fig, s._id) - assert info["active"] + assert info["active"], info h, w = arr.shape[:2] cy, cx = h / 2, w / 2 yy, xx = np.mgrid[0:h, 0:w] @@ -621,6 +631,6 @@ def test_live_texture_swap_rebuilds_the_gpu_texture(self, gpu_render_png): s.set_texture(np.zeros((64, 128, 3), np.uint8) + np.array( [20, 200, 40], np.uint8)) arr, info = gpu_render_png(fig, s._id) - assert info["active"] + assert info["active"], info px = _disc(arr).mean(0) assert px[1] > px[0] + 40 and px[1] > px[2] + 40, f"got {px}"