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 ed0a6f59..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`). @@ -95,8 +95,31 @@ 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. + +**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 @@ -106,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/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 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..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 @@ -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..f4516490 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,9 +4873,28 @@ 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 = +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 + // 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. // // 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; @@ -4513,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([ @@ -4646,6 +5044,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 +5102,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 +5145,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 +5160,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 +5238,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 +5339,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 +5356,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 +5808,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 +6037,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, @@ -6714,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. @@ -6776,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)); @@ -7324,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; @@ -7548,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 @@ -8988,7 +9610,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/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 c9f3d1dd..0ba10e79 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,20 @@ 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. _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"): @@ -220,6 +273,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 +316,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 +482,143 @@ 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, 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)) + 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, self._texture_flip_v) + 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. 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 + -------- + 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") + 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=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._texture_flip_v = False + 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/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_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/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..62e4f059 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_gpu_depth.py @@ -0,0 +1,139 @@ +""" +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 +from anyplotlib.tests.conftest import gpu3d_diag, wait_3d_settled + + +_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) + # 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) + 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..2737191d --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_texture.py @@ -0,0 +1,636 @@ +""" +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 +from anyplotlib.tests.conftest import gpu3d_diag, wait_3d_settled + + +# --------------------------------------------------------------------------- +# 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. + # 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( + """(pid) => { + 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) + 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 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) + 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 _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 = _drawn_mask(arr) + 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 = _drawn_mask(arr) + 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"], 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"], info + + 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"], 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"], info + + 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"], info + + 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"], info + 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"], info + 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 = _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() + 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"], info + 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}