diff --git a/AGENTS.md b/AGENTS.md
index 9fbfc497e..d11652226 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,7 +115,7 @@ 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 12,438 lines.
+the 2-D function table). Both were last verified at 12,520 lines.
Changelog entries: add a fragment file to `upcoming_changes/` (e.g.
`123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time.
diff --git a/Examples/Interactive/plot_kikuchi_sphere.py b/Examples/Interactive/plot_kikuchi_sphere.py
new file mode 100644
index 000000000..4037b3927
--- /dev/null
+++ b/Examples/Interactive/plot_kikuchi_sphere.py
@@ -0,0 +1,188 @@
+"""
+Turning a crystal — Kikuchi sphere and detector
+===============================================
+
+In electron backscatter diffraction (EBSD) every lattice plane of a crystal
+scatters electrons into a band on a sphere around the sample — a *Kikuchi
+band*, as wide as twice the plane's Bragg angle. The bands are fixed to the
+crystal, so the sphere turns with it. A flat detector, fixed in the
+microscope, only sees the patch of that sphere facing it; turn the crystal
+and a different patch, with a different set of bands, lands on the detector.
+
+* **Left** — the band sphere of nickel (face-centred cubic) at 20 kV,
+ wrapped onto a sphere with :meth:`~anyplotlib.Plot3D.set_texture`.
+ :meth:`~anyplotlib.Plot3D.set_drag_mode` ``("rotate")`` makes a drag turn
+ the *crystal* under a fixed camera, rather than walk the camera around it:
+ drag to roll it, **Shift**-drag to spin it about the line of sight.
+* **Right** — what a detector facing you records: the same sphere projected
+ from its centre onto a flat screen (a gnomonic projection), recomputed in
+ Python from :attr:`~anyplotlib.Plot3D.rotation` on every drag frame.
+
+The camera's azimuth and elevation can only aim it, which is two degrees of
+freedom. An orientation has three, which is why the crystal's orientation
+is a separate rotation matrix (:meth:`~anyplotlib.Plot3D.set_rotation`) that
+the camera then looks at.
+
+The band model is kinematic and deliberately small — each plane family draws
+a flat band weighted by a falling atomic scattering factor — so it shows
+where the bands are, not their real intensity profiles.
+"""
+import itertools
+
+import numpy as np
+
+import anyplotlib as apl
+
+# ── The crystal: nickel, fcc, a = 0.3524 nm, at 20 kV ───────────────────────
+LATTICE_NM = 0.3524
+VOLTAGE_V = 20e3
+
+
+def electron_wavelength_nm(volts):
+ """Relativistic electron wavelength."""
+ h, m0, e, c = 6.62607e-34, 9.10938e-31, 1.602177e-19, 2.997925e8
+ momentum = np.sqrt(2 * m0 * e * volts * (1 + e * volts / (2 * m0 * c**2)))
+ return h / momentum * 1e9
+
+
+WAVELENGTH_NM = electron_wavelength_nm(VOLTAGE_V)
+
+
+def fcc_planes(max_index=3):
+ """One normal per band — ±(hkl) is the same plane — for the families an
+ fcc lattice reflects: h, k, l all odd or all even."""
+ planes = []
+ for hkl in itertools.product(range(-max_index, max_index + 1), repeat=3):
+ h, k, l = hkl
+ if hkl == (0, 0, 0) or len({h % 2, k % 2, l % 2}) != 1:
+ continue
+ first_nonzero = next(v for v in hkl if v != 0)
+ if first_nonzero < 0: # keep one of ±(hkl)
+ continue
+ planes.append(hkl)
+ return np.array(planes, dtype=float)
+
+
+PLANES = fcc_planes()
+G_LENGTH = np.linalg.norm(PLANES, axis=1) / LATTICE_NM # nm⁻¹
+BRAGG = np.arcsin(WAVELENGTH_NM * G_LENGTH / 2) # rad
+WEIGHT = np.exp(-0.12 * np.sum(PLANES**2, axis=1)) # scattering falls with |g|
+NORMALS = PLANES / np.linalg.norm(PLANES, axis=1, keepdims=True)
+
+
+def band_intensity(directions):
+ """Kinematic band intensity for unit *directions* (…, 3) in the crystal
+ frame: inside a band means within its Bragg angle of the plane.
+
+ Bragg angles are a degree or two, so the sine of the angle from the
+ plane (a dot product) stands in for the angle itself, and the band edge
+ is a short linear ramp. Done in blocks so the (directions × planes)
+ table stays a few MB.
+ """
+ flat = directions.reshape(-1, 3).astype(np.float32)
+ normals = NORMALS.astype(np.float32)
+ half_width = np.sin(BRAGG).astype(np.float32)
+ edge = (0.3 * half_width).astype(np.float32)
+ weight = WEIGHT.astype(np.float32)
+ out = np.empty(len(flat), np.float32)
+ for start in range(0, len(flat), 32768):
+ sine_from_plane = np.abs(flat[start:start + 32768] @ normals.T)
+ inside = np.clip((half_width - sine_from_plane) / edge + 0.5, 0, 1)
+ out[start:start + 32768] = inside @ weight
+ return out.reshape(directions.shape[:-1])
+
+
+def to_grey(values):
+ low, high = np.percentile(values, [1, 99.5])
+ return np.clip((values - low) / (high - low), 0, 1)
+
+
+# ── The sphere: longitude along the columns, latitude down the rows ─────────
+# The texture is sampled on a finer copy of the same grid, so the image lines
+# up with set_texture's default parametric mapping.
+def sphere_grid(rows, cols):
+ longitude = np.linspace(0, 2 * np.pi, cols)
+ latitude = np.linspace(np.pi / 2, -np.pi / 2, rows)
+ LON, LAT = np.meshgrid(longitude, latitude)
+ return np.stack([np.cos(LAT) * np.cos(LON),
+ np.cos(LAT) * np.sin(LON),
+ np.sin(LAT)], axis=-1)
+
+
+grey = (to_grey(band_intensity(sphere_grid(512, 1024))) * 255).astype(np.uint8)
+texture = np.repeat(grey[..., None], 3, axis=-1) # set_texture wants RGB
+mesh = sphere_grid(49, 97) # coarse: the texture carries detail
+
+# ── The detector: a flat screen facing the camera ───────────────────────────
+# At azimuth 0 and elevation 0 the camera looks along +y, so the face of the
+# sphere toward it is −y, with +x to the right and +z up on screen. The
+# detector sits on that side; the pattern centre is straight ahead.
+DETECTOR_PX = 200
+HALF_FIELD = np.tan(np.radians(48)) # ±48° from the pattern centre
+u = np.linspace(-HALF_FIELD, HALF_FIELD, DETECTOR_PX)
+U, W = np.meshgrid(u, u[::-1]) # row 0 is the top of the screen
+rays = np.stack([U, -np.ones_like(U), W], axis=-1)
+rays /= np.linalg.norm(rays, axis=-1, keepdims=True) # microscope frame
+
+
+def detector_pattern(rotation):
+ """What the detector records with the crystal turned by *rotation*.
+
+ *rotation* maps crystal directions into the microscope frame, so a ray
+ arriving at the detector came from crystal direction ``rotationᵀ · ray``.
+ """
+ return to_grey(band_intensity(rays @ rotation))
+
+
+def facing_direction(rotation):
+ """The crystal direction at the pattern centre, as a short [uvw]."""
+ d = rotation.T @ np.array([0.0, -1.0, 0.0])
+ return "[" + " ".join(f"{v:+.2f}" for v in d) + "]"
+
+
+def bunge(phi1, Phi, phi2):
+ """Rotation from Bunge Euler angles in degrees (z-x-z)."""
+ a, b, c = np.radians([phi1, Phi, phi2])
+
+ def rz(t):
+ return np.array([[np.cos(t), -np.sin(t), 0],
+ [np.sin(t), np.cos(t), 0], [0, 0, 1]])
+
+ rx = np.array([[1, 0, 0], [0, np.cos(b), -np.sin(b)],
+ [0, np.sin(b), np.cos(b)]])
+ return rz(a) @ rx @ rz(c)
+
+
+start = bunge(20, 40, 15)
+
+# ── Figure ──────────────────────────────────────────────────────────────────
+fig, (ax_sphere, ax_detector) = apl.subplots(
+ 1, 2, figsize=(900, 450),
+ help="Drag the sphere to turn the crystal; Shift-drag spins it about\n"
+ "the line of sight. The detector shows the patch facing you.")
+
+sphere = ax_sphere.plot_surface(mesh[..., 0], mesh[..., 1], mesh[..., 2],
+ bounds=((-1, 1),) * 3,
+ azimuth=0, elevation=0, zoom=1.35)
+sphere.set_axis_off()
+sphere.set_texture(texture, shade=True, cull_backfaces=True)
+sphere.set_rotation(start)
+sphere.set_drag_mode("rotate")
+sphere.set_title("Kikuchi sphere · Ni, 20 kV")
+
+detector = ax_detector.imshow(detector_pattern(start), cmap="gray",
+ vmin=0, vmax=1)
+detector.set_title(f"Detector · centre {facing_direction(start)}")
+
+
+@sphere.add_event_handler("pointer_move")
+def follow_the_crystal(event):
+ # Every drag frame: ~40k rays against ~50 bands is a few milliseconds.
+ if event.rotation is None: # a wheel zoom, not a turn
+ return
+ rotation = sphere.rotation
+ detector.set_data(detector_pattern(rotation), clim=(0, 1))
+ detector.set_title(f"Detector · centre {facing_direction(rotation)}")
+
+
+fig # Interactive
diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md
index 6f73de6df..614ea24a8 100644
--- a/anyplotlib/FIGURE_ESM.md
+++ b/anyplotlib/FIGURE_ESM.md
@@ -53,28 +53,28 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| b64 array decode helpers | 141 |
| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 193 / 260 / 282 |
| **2D gutter geometry**: `_cbLabelW` / `_cbValueChars` / `_cbTickW` / `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 331 / 342 / 360 / 372 / 382 / 392 / 402 |
-| **Layout engine** `applyLayout` | 893 |
-| `_buildCanvasStack` | 976 |
-| `_createPanelDOM` | 1118 |
-| `_createInsetDOM` / `_applyAllInsetStates` | 1259 / 1653 |
-| `_resizePanelDOM` | 2366 |
-| **2D drawing**: `_imgFitRect` | 2537 |
-| `draw2d` | 2881 |
-| `drawScaleBar2d` / `drawColorbar2d` | 3076 / 3356 |
-| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3175 / 3198 / 3211 |
-| `_drawAxes2d` (ticks, labels, title) | 3468 |
-| `drawOverlay2d` / `drawMarkers2d` | 3623 / 3787 |
-| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2701 / 2725 / 2786 |
-| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 849 / 880 |
-| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4631 / 4716 |
-| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4755 / 4770 / 4791 |
-| **3D drawing**: `draw3d` | 5767 |
-| Event emission `_emitEvent` | 6604 |
-| 3D event handlers `_attachEvents3d` | 6661 |
-| **1D drawing**: `draw1d` | 6885 |
-| `_drawLine` (1D series + markers) | 7038 |
-| `drawOverlay1d` / `_rasterBitmap` / `drawMarkers1d` | 7331 / 7419 / 7436 |
-| Marker hit-test `_markerHitTest2d` | 7700 |
+| **Layout engine** `applyLayout` | 899|
+| `_buildCanvasStack` | 982|
+| `_createPanelDOM` | 1124|
+| `_createInsetDOM` / `_applyAllInsetStates` | 1265 / 1659|
+| `_resizePanelDOM` | 2372|
+| **2D drawing**: `_imgFitRect` | 2543|
+| `draw2d` | 2887|
+| `drawScaleBar2d` / `drawColorbar2d` | 3082 / 3362|
+| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3181 / 3204 / 3217|
+| `_drawAxes2d` (ticks, labels, title) | 3474|
+| `drawOverlay2d` / `drawMarkers2d` | 3629 / 3793|
+| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2707 / 2731 / 2792|
+| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 855 / 886|
+| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4682 / 4767|
+| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4806 / 4821 / 4842|
+| **3D drawing**: `draw3d` | 5818|
+| Event emission `_emitEvent` | 6657|
+| 3D event handlers `_attachEvents3d` | 6714|
+| **1D drawing**: `draw1d` | 6967|
+| `_drawLine` (1D series + markers) | 7120|
+| `drawOverlay1d` / `_rasterBitmap` / `drawMarkers1d` | 7413 / 7501 / 7518|
+| Marker hit-test `_markerHitTest2d` | 7782|
> **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'`
> branch that blits a single RGBA image across data-coord `extent` (the fast
@@ -85,22 +85,22 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
> bytes or the shape change) — never on the marker set, which is state the
> wheel and pan handlers serialise back. The shared `clip_path` block clips it
> to a curved sector.
-| Panel event dispatch `_attachPanelEvents` | 7957 |
-| 2D events `_attachEvents2d` | 8043 |
-| 1D events `_attachEvents1d` | 8429 |
-| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8704 / 8983 |
-| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8896 / 8910 / 8939 / 8974 |
-| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9108 / 9181 |
-| Shared-axis propagation `_getShareGroups` | 9252 |
-| Figure resize `_applyFigResizeDOM` | 9316 |
-| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9510 / 9573 / 9949 |
-| Generic redraw `_redrawPanel` | 10139 |
-| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10302 / 10498 / 10557 |
-| Native-resolution render `_withNativeSize` | 10278 |
-| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10591 / 10713 / 10892 |
-| Export registry `registerExportAction` | 10769 |
-| **Embedding API**: `createLocalModel` / `mount` | 11283 / 11339 |
-| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11594 / 11981 |
+| Panel event dispatch `_attachPanelEvents` | 8039|
+| 2D events `_attachEvents2d` | 8125|
+| 1D events `_attachEvents1d` | 8511|
+| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8786 / 9065|
+| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8978 / 8992 / 9021 / 9056|
+| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9190 / 9263|
+| Shared-axis propagation `_getShareGroups` | 9334|
+| Figure resize `_applyFigResizeDOM` | 9398|
+| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9592 / 9655 / 10031|
+| Generic redraw `_redrawPanel` | 10221|
+| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10384 / 10580 / 10639|
+| Native-resolution render `_withNativeSize` | 10360|
+| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10673 / 10795 / 10974|
+| Export registry `registerExportAction` | 10851|
+| **Embedding API**: `createLocalModel` / `mount` | 11365 / 11421 |
+| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11676 / 12063 |
> **`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`
@@ -285,12 +285,12 @@ st.colorbar_label_size (label font sizes; optional)
| Function | Line | Purpose |
|----------|------|---------|
| **`_imgFitRect(iw,ih,cw,ch)`** / `_cbFitRect(st,imgW,imgH)` | **2537 / 2546** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this. `_cbFitRect` is its vertical extent in whole px — the colorbar strip spans the letterboxed IMAGE, not the whole image area (`p._cbH`) |
-| `draw2d(p)` | 2881 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
-| `drawScaleBar2d(p)` | 3076 | Physical scale bar |
-| `_rawBand(st)` / `_displayFrac(st, val)` / `_buildLut32(st)` | 2596 / 2606 / 2614 | The quantisation band the u8 bytes were encoded over, the ONE rule mapping a value through the display window and scale mode to a colormap fraction (shared by the LUT and the colorbar strip, so the strip's colours are the image's), then the 256-entry LUT built from both. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black |
-| `drawColorbar2d(p)` | 3356 | Gradient strip coloured through the display window (`_displayFrac` — saturated beyond it, like the image) + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtRange`; kept inside the strip by the glyphs' measured extent, held apart on a tiny range, the maximum alone at its own place on a strip too short for both; omitted when the layout dropped the gutter, `p._cbTickW == 0`) + rotated label centred right after the values' measured width (never past the reserved gutter) |
-| `_drawAxes2d(p)` | 3468 | Ticks placed through `_imgToCanvas2d` (pixel-centre axes, letterbox-aware; edge labels nudged inward both axes), axis labels + title via `_drawTex` |
-| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3623 / 3787 | Widgets / marker groups |
+| `draw2d(p)` | 2887 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
+| `drawScaleBar2d(p)` | 3082 | Physical scale bar |
+| `_rawBand(st)` / `_displayFrac(st, val)` / `_buildLut32(st)` | 2602 / 2612 / 2620 | The quantisation band the u8 bytes were encoded over, the ONE rule mapping a value through the display window and scale mode to a colormap fraction (shared by the LUT and the colorbar strip, so the strip's colours are the image's), then the 256-entry LUT built from both. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black |
+| `drawColorbar2d(p)` | 3362 | Gradient strip coloured through the display window (`_displayFrac` — saturated beyond it, like the image) + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtRange`; kept inside the strip by the glyphs' measured extent, held apart on a tiny range, the maximum alone at its own place on a strip too short for both; omitted when the layout dropped the gutter, `p._cbTickW == 0`) + rotated label centred right after the values' measured width (never past the reserved gutter) |
+| `_drawAxes2d(p)` | 3474 | Ticks placed through `_imgToCanvas2d` (pixel-centre axes, letterbox-aware; edge labels nudged inward both axes), axis labels + title via `_drawTex` |
+| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3629 / 3793 | 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
@@ -457,13 +457,28 @@ comparable to the base image's `_buildLut32` blit).
---
-## 3D drawing (line 5605)
+## 3D drawing (line 5818)
Orthographic projection; geometry b64-decoded and cached. `draw3d` sorts
triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`).
- **Camera** (`_rot3`): turntable with matplotlib azim/elev semantics —
azimuth spins about the DATA z-axis, elevation tilts toward the viewer.
Faces unit vector v when `el = asin(vz)`, `az = atan2(vx, -vy)`.
+ Read with `??`, never `||`: elevation 0 is a real view (it used to draw
+ from 30° up).
+- **Model rotation** (`Plot3D.set_rotation`, `st.rotation`, row-major 3×3):
+ turns the normalised data about its centre *before* the camera, so draw3d
+ builds `R = _rot3(az, el) · _modelRot(st.rotation)` once and every path —
+ Canvas2D geometry, axes, highlight, sphere, planes, and the GPU matrix and
+ normals — inherits it with no other change. The voxel projection and sprite
+ caches key on it (`rotKey`). `_preserveView` keeps the JS-side rotation
+ unless the push carries `_rotation_from_python`, a flag of its own so a
+ `set_rotation` never snaps the camera back to Python's stale angles and a
+ `set_view` never undoes a drag. `drag_mode: 'rotate'` turns a left drag
+ into a trackball on the model (`M' = Cᵀ·Q·C·M`, re-orthonormalised each
+ frame by `_orthonormal3`; Shift spins about the line of sight) and adds
+ `rotation` to the `pointer_move` / `pointer_up` payloads, which
+ `Figure._dispatch_event` writes back into `Plot3D._state`.
- **Scatter colours**: `st.point_colors_b64` (uint8 RGB triplets) gives
per-point colours; empty string falls back to `st.color`.
- **Highlight**: `st.highlight = {x,y,z,color,size}` draws an emphasised
@@ -662,13 +677,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws
| Function | Line | Purpose |
|----------|------|---------|
-| `_cssScale` | 10174 | inverse of `_applyScale`'s `transform:scale()` |
-| `_panelBox` | 10185 | the element whose rect bounds one panel |
-| `_neutralizeView` / `_restoreView` | 10194 / 10219 | transient whole-extent view |
-| `_nativeGeom` / `_nativeGuard` | 10234 / 10253 | native size + why-not message |
-| `_withNativeSize` | 10278 | resize → redraw → run → restore |
-| `_compositeCanvas` | 10302 | the compositor (`_drawEl` / `_drawPanel` …) |
-| `exportCanvas` / `exportPNG` | 10498 / 10557 | orchestrator / data-URL wrapper |
+| `_cssScale` | 10256 | inverse of `_applyScale`'s `transform:scale()` |
+| `_panelBox` | 10267 | the element whose rect bounds one panel |
+| `_neutralizeView` / `_restoreView` | 10276 / 10301 | transient whole-extent view |
+| `_nativeGeom` / `_nativeGuard` | 10316 / 10335 | native size + why-not message |
+| `_withNativeSize` | 10360 | resize → redraw → run → restore |
+| `_compositeCanvas` | 10384 | the compositor (`_drawEl` / `_drawPanel` …) |
+| `exportCanvas` / `exportPNG` | 10580 / 10639 | orchestrator / data-URL wrapper |
**The whole pipeline is ONE synchronous task** — theme swap, view reset, native
resize, composite, restore — so the browser never paints an intermediate state
@@ -764,14 +779,14 @@ leaders that cross into the panel included. Pinned by
| Function | Line | Purpose |
|----------|------|---------|
-| `_toast` | 10591 | transient bottom-centre message |
-| `_copyCanvas` | 10626 | clipboard write + feature detection |
-| `_showPngPreview` | 10650 | framed-document download fallback |
-| `_hostSavesPng` / `_canPickFile` | 10702 / 10708 | the parent saves PNGs itself / a system Save dialog exists |
-| `_downloadCanvas` | 10713 | ``, the preview, or the host |
-| `registerExportAction` | 10769 | downstream extension point |
-| `_menuRows` / `_openMenu` | 10823 / 10892 | menu model / DOM |
-| `_panelAtPoint` | 11004 | hit test (insets first — they sit on top) |
+| `_toast` | 10673 | transient bottom-centre message |
+| `_copyCanvas` | 10708 | clipboard write + feature detection |
+| `_showPngPreview` | 10732 | framed-document download fallback |
+| `_hostSavesPng` / `_canPickFile` | 10784 / 10790 | the parent saves PNGs itself / a system Save dialog exists |
+| `_downloadCanvas` | 10795 | ``, the preview, or the host |
+| `registerExportAction` | 10851 | downstream extension point |
+| `_menuRows` / `_openMenu` | 10905 / 10974 | menu model / DOM |
+| `_panelAtPoint` | 11086 | hit test (insets first — they sit on top) |
- **An `exportBtn` badge (⤓, beside the help badge) opens the same menu on an
ordinary left click.** It is a `role="button"` with `tabIndex=0` and
@@ -865,16 +880,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind.
| Function | Line | Purpose |
|----------|------|---------|
-| `decodeBlocks` | 11594 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
-| `dense` | 11620 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
-| `ragged` | 11685 | the same three, over a row-pointer block (`offsets` + one array per column) |
-| `maskFromWidget` | 11768 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
-| `rasterDisks` | 11808 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
-| `robustLevels` / `toU8` | 11837 / 11878 | the percentile window and the 8-bit code map, one implementation |
-| `panelAxis` | 11956 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
-| `installTouchShim` / `reportEmbedHeight` | 11893 / 11912 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
-| `encodeBase64` / `typedArrayBytes` | 11932 / 11940 | a 3-D cloud's geometry channel is base64, not the binary side table |
-| `mountNavigated` | 11981 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
+| `decodeBlocks` | 11676 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
+| `dense` | 11702 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
+| `ragged` | 11767 | the same three, over a row-pointer block (`offsets` + one array per column) |
+| `maskFromWidget` | 11850 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
+| `rasterDisks` | 11890 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
+| `robustLevels` / `toU8` | 11919 / 11960 | the percentile window and the 8-bit code map, one implementation |
+| `panelAxis` | 12038 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
+| `installTouchShim` / `reportEmbedHeight` | 11975 / 11994 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
+| `encodeBase64` / `typedArrayBytes` | 12014 / 12022 | a 3-D cloud's geometry channel is base64, not the binary side table |
+| `mountNavigated` | 12063 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
`mountNavigated(el, page, opts)` is **async** — the blob decode is a `fetch` of
a `data:` URL — so a host `await`s it. `page` is `{state, blocks, bindings,
diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py
index 7976db781..2c6cc5188 100644
--- a/anyplotlib/callbacks.py
+++ b/anyplotlib/callbacks.py
@@ -109,6 +109,8 @@ class Event:
# Plot3D._state.
azimuth: float | None = None
elevation: float | None = None
+ # Model rotation (Plot3D "rotate" drag) — row-major 3×3, flattened to 9.
+ rotation: list | 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 15908d7fe..d837bd7cd 100644
--- a/anyplotlib/figure/_figure.py
+++ b/anyplotlib/figure/_figure.py
@@ -662,6 +662,11 @@ def _dispatch_event(self, raw: str) -> None:
plot._answer_value_probe(msg)
return
+ # A "rotate" drag edits the model rotation in the viewer; record it so
+ # Plot3D.rotation answers with what the reader is looking at.
+ if msg.get("rotation") is not None and hasattr(plot, "_set_rotation_from_js"):
+ plot._set_rotation_from_js(msg["rotation"])
+
source = None
if widget_id and hasattr(plot, "_widgets"):
widget = plot._widgets.get(widget_id)
@@ -701,6 +706,7 @@ def _dispatch_event(self, raw: str) -> None:
display_height=msg.get("display_height"),
azimuth=msg.get("azimuth"),
elevation=msg.get("elevation"),
+ rotation=msg.get("rotation"),
key=msg.get("key"),
last_widget_id=msg.get("last_widget_id"),
target=msg.get("target"),
@@ -845,6 +851,7 @@ def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None:
"show_colorbar", # 2-D 'c'
"view_x0", "view_x1", # 1-D drag / 'r'
"azimuth", "elevation", # 3-D orbit
+ "rotation", # 3-D "rotate" drag
})
def _reconcile_js_view_state(self) -> None:
diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js
index 5708ddbc6..65a76c432 100644
--- a/anyplotlib/figure_esm.js
+++ b/anyplotlib/figure_esm.js
@@ -430,6 +430,12 @@ function render({ model, el, onResize, onReadout }) {
newState.elevation = p2.state.elevation;
newState.zoom = p2.state.zoom;
}
+ // The model rotation has its own flag: set_rotation must not snap the
+ // camera back to Python's last-known angles, and set_view must not undo
+ // a "rotate" drag.
+ if (p2.kind === '3d' && !newState._rotation_from_python) {
+ newState.rotation = p2.state.rotation;
+ }
}
// Geometry channel: heavy keys (vertices/image/colormap) travel in a
@@ -3948,6 +3954,51 @@ function render({ model, el, onResize, onReadout }) {
];
}
+ // Model rotation (Plot3D.set_rotation): row-major 3×3 in st.rotation that
+ // turns the normalised data about its centre BEFORE the camera looks at
+ // it, so every draw path uses R = camera · model. Identity when unset.
+ function _modelRot(flat) {
+ if (!flat || flat.length !== 9) return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
+ return [[flat[0], flat[1], flat[2]],
+ [flat[3], flat[4], flat[5]],
+ [flat[6], flat[7], flat[8]]];
+ }
+
+ function _mul3(A, B) {
+ const C = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
+ for (let i = 0; i < 3; i++)
+ for (let j = 0; j < 3; j++)
+ C[i][j] = A[i][0]*B[0][j] + A[i][1]*B[1][j] + A[i][2]*B[2][j];
+ return C;
+ }
+
+ function _transpose3(A) {
+ return [[A[0][0], A[1][0], A[2][0]],
+ [A[0][1], A[1][1], A[2][1]],
+ [A[0][2], A[1][2], A[2][2]]];
+ }
+
+ // Rotation by `angle` radians about unit axis `k` (Rodrigues).
+ function _axisAngle3(k, angle) {
+ const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
+ const [x, y, z] = k;
+ return [[t*x*x + c, t*x*y - s*z, t*x*z + s*y],
+ [t*x*y + s*z, t*y*y + c, t*y*z - s*x],
+ [t*x*z - s*y, t*y*z + s*x, t*z*z + c ]];
+ }
+
+ // Gram–Schmidt on the rows. A drag composes hundreds of small rotations,
+ // and without this their round-off slowly shears and scales the model.
+ function _orthonormal3(M) {
+ const unit = (v) => { const n = Math.hypot(v[0], v[1], v[2]) || 1;
+ return [v[0]/n, v[1]/n, v[2]/n]; };
+ const a = unit(M[0]);
+ const d = a[0]*M[1][0] + a[1]*M[1][1] + a[2]*M[1][2];
+ const b = unit([M[1][0] - d*a[0], M[1][1] - d*a[1], M[1][2] - d*a[2]]);
+ const c = [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]];
+ return [a, b, c];
+ }
+
function _applyRot(R, v) {
return [
R[0][0]*v[0] + R[0][1]*v[1] + R[0][2]*v[2],
@@ -5817,7 +5868,7 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const geom = st.geom_type || 'surface';
const bnds = st.data_bounds || {};
const az = st.azimuth || 0;
- const el = st.elevation || 30;
+ const el = st.elevation ?? 30; // ?? not ||: 0° is a real elevation
const zoom = st.zoom || 1.0;
const color = st.color || '#4fc3f7';
const ptSize = st.point_size || 4;
@@ -5836,7 +5887,9 @@ fn fs(in : VsOut) -> @location(0) vec4 {
];
}
- const R = _rot3(az, el);
+ // Camera · model: set_rotation turns the data, the camera then looks at it.
+ const R = _mul3(_rot3(az, el), _modelRot(st.rotation));
+ const rotKey = (st.rotation || []).join(",");
const cx = pw / 2, cy = ph / 2;
const scale = zoom * Math.min(pw, ph) * 0.32;
@@ -6320,7 +6373,7 @@ fn fs(in : VsOut) -> @location(0) vec4 {
// don't move the camera — e.g. plane-widget drags — skip projection
// and depth-sort entirely and only re-blit.
const nV = verts.length;
- const projKey = `${p._voxGen || 0}|${az}|${el}|${zoom}|${pw}x${ph}|${vsz}`;
+ const projKey = `${p._voxGen || 0}|${az}|${el}|${rotKey}|${zoom}|${pw}x${ph}|${vsz}`;
if (p._voxProjKey !== projKey || p._voxProjN !== nV) {
p._voxProjKey = projKey;
p._voxProjN = nV;
@@ -6360,7 +6413,7 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const sw = Math.max(2, Math.ceil(mxX - mnX) + 2);
const sh = Math.max(2, Math.ceil(mxY - mnY) + 2);
const ox = -mnX + 1, oy = -mnY + 1;
- const sprKey = `${az}|${el}|${zoom}|${vsz}|${pw}x${ph}`;
+ const sprKey = `${az}|${el}|${rotKey}|${zoom}|${vsz}|${pw}x${ph}`;
if (p._voxSprKey !== sprKey) {
p._voxSprKey = sprKey;
p._voxSprites = new Map();
@@ -6707,8 +6760,9 @@ fn fs(in : VsOut) -> @location(0) vec4 {
return;
}
}
- dragStart = { mx: _d3mx, my: _d3my,
- az: p.state.azimuth, el: p.state.elevation };
+ dragStart = { mx: _d3mx, my: _d3my, lastX: _d3mx, lastY: _d3my,
+ az: p.state.azimuth, el: p.state.elevation,
+ rotate: p.state.drag_mode === 'rotate' };
overlayCanvas.style.cursor = 'grabbing';
// Do NOT call e.preventDefault() — suppresses click → dblclick cascade.
});
@@ -6735,6 +6789,32 @@ fn fs(in : VsOut) -> @location(0) vec4 {
}
if (!dragStart) return;
const {mx:_d3mx2, my:_d3my2} = _clientPos(e, overlayCanvas, p.pw, p.ph);
+ if (dragStart.rotate) {
+ // Trackball: turn the MODEL under a fixed camera. The step since the
+ // last frame becomes a rotation in view space (x' right, y' into the
+ // screen, z' up) — rightward motion about z', downward about x', so
+ // the surface under the cursor follows it; Shift spins about y', the
+ // line of sight. Conjugating by the camera C carries that step into
+ // the data frame: C·M' = Q·C·M ⇒ M' = Cᵀ·Q·C·M.
+ const sx = _d3mx2 - dragStart.lastX, sy = _d3my2 - dragStart.lastY;
+ dragStart.lastX = _d3mx2; dragStart.lastY = _d3my2;
+ const step = Math.hypot(sx, sy);
+ if (step > 0) {
+ const axis = e.shiftKey ? [0, 1, 0] : [sy / step, 0, sx / step];
+ const angle = (e.shiftKey ? sx : step) * Math.PI / 360; // 0.5°/px
+ const C = _rot3(p.state.azimuth || 0, p.state.elevation ?? 30);
+ const M = _orthonormal3(_mul3(_transpose3(C),
+ _mul3(_axisAngle3(axis, angle), _mul3(C, _modelRot(p.state.rotation)))));
+ p.state.rotation = [].concat(M[0], M[1], M[2]);
+ draw3d(p);
+ _writeState();
+ }
+ _emitEvent(p.id, 'pointer_move', null,
+ { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom,
+ rotation: p.state.rotation, ..._pointerFields(e) });
+ e.preventDefault();
+ return;
+ }
const dx = _d3mx2 - dragStart.mx;
const dy = _d3my2 - dragStart.my;
// DIRECT MANIPULATION: the surface under the cursor follows the cursor,
@@ -6768,11 +6848,13 @@ fn fs(in : VsOut) -> @location(0) vec4 {
return;
}
if (!dragStart) return;
+ const rotated = dragStart.rotate;
dragStart = null;
overlayCanvas.style.cursor = 'grab';
_writeState();
_emitEvent(p.id, 'pointer_up', null,
{ azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom,
+ ...(rotated ? { rotation: p.state.rotation } : {}),
..._pointerFields(e), button: e.button });
_scheduleCommit();
});
diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py
index 327710050..0afa96b33 100644
--- a/anyplotlib/plot3d/_plot3d.py
+++ b/anyplotlib/plot3d/_plot3d.py
@@ -133,6 +133,27 @@ def _geometry_state(geom_type: str, x, y, z, bounds=None) -> dict:
}
+_IDENTITY_ROTATION = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
+
+
+def _rotation_to_wire(matrix) -> list:
+ """Validate a proper rotation matrix and flatten it row-major.
+
+ A matrix that is not orthonormal would shear or scale the geometry, and
+ one with determinant −1 would mirror it (turning a right-handed crystal
+ into its enantiomorph), so both are rejected rather than silently drawn.
+ """
+ m = np.asarray(matrix, dtype=float)
+ if m.shape != (3, 3) or not np.all(np.isfinite(m)):
+ raise ValueError(f"rotation must be a finite 3×3 matrix, got shape {m.shape}")
+ if not np.allclose(m @ m.T, np.eye(3), atol=1e-6):
+ raise ValueError("rotation must be orthonormal (R @ R.T == I)")
+ if np.linalg.det(m) < 0:
+ raise ValueError("rotation must have determinant +1; "
+ "a determinant of -1 is a reflection")
+ return [float(v) for v in m.ravel()]
+
+
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.
@@ -306,10 +327,16 @@ def __init__(self, geom_type: str,
"azimuth": float(azimuth),
"elevation": float(elevation),
"zoom": float(zoom),
+ # Model rotation (row-major 3×3) applied to the data before the
+ # camera — see set_rotation. "rotate" drags edit it; "orbit"
+ # drags move the camera.
+ "rotation": list(_IDENTITY_ROTATION),
+ "drag_mode": "orbit",
"_default_azimuth": float(azimuth),
"_default_elevation": float(elevation),
"_default_zoom": float(zoom),
"_view_from_python": False,
+ "_rotation_from_python": False,
"pointer_settled_ms": 0,
"pointer_settled_delta": 4,
}
@@ -441,6 +468,79 @@ def set_view(self, azimuth: float | None = None,
self._push_fields(**fields)
self._state["_view_from_python"] = False
+ def set_rotation(self, matrix=None) -> None:
+ """Orient the data with a rotation matrix, independent of the camera.
+
+ The camera's azimuth and elevation can only aim it — two degrees of
+ freedom. An orientation has three: a crystal, a molecule, or a
+ sample on a stage turns about every axis. *matrix* rotates the data
+ about the centre of its bounds, and the camera then looks at the
+ result, so ``set_rotation`` says how the object is turned and
+ :meth:`set_view` says where you stand to look at it.
+
+ Like :meth:`set_view`, this is a targeted push: rotating a large
+ surface or point cloud never re-sends its geometry or texture.
+
+ Parameters
+ ----------
+ matrix : (3, 3) array-like, optional
+ A proper rotation (orthonormal, determinant +1). Column ``j`` is
+ where the data's ``j``-th axis points after rotating, so
+ ``matrix @ v`` maps a data-frame direction into the rotated frame.
+ ``None`` restores the identity.
+
+ Raises
+ ------
+ ValueError
+ If *matrix* is not 3×3, not orthonormal, or a reflection.
+
+ See Also
+ --------
+ rotation : The current matrix, including drags made in the viewer.
+ set_drag_mode : Let the viewer edit the rotation by dragging.
+ """
+ wire = (list(_IDENTITY_ROTATION) if matrix is None
+ else _rotation_to_wire(matrix))
+ self._push_fields(rotation=wire, _rotation_from_python=True)
+ self._state["_rotation_from_python"] = False
+
+ @property
+ def rotation(self) -> np.ndarray:
+ """The current model rotation as a ``(3, 3)`` array.
+
+ Follows drags made in ``"rotate"`` mode (see :meth:`set_drag_mode`)
+ as well as :meth:`set_rotation`.
+ """
+ return np.asarray(self._state["rotation"], dtype=float).reshape(3, 3)
+
+ def _set_rotation_from_js(self, wire) -> None:
+ """Internal: record a rotation the viewer's drag produced."""
+ try:
+ self._state["rotation"] = [float(v) for v in wire]
+ except (TypeError, ValueError):
+ pass
+
+ def set_drag_mode(self, mode: str) -> None:
+ """Choose what a left-button drag on the panel does.
+
+ Parameters
+ ----------
+ mode : ``"orbit"`` | ``"rotate"``
+ ``"orbit"`` (the default) moves the camera around the data.
+ ``"rotate"`` turns the data under a fixed camera instead, editing
+ :attr:`rotation`: horizontal and vertical motion roll the surface
+ under the cursor like a trackball, and a drag with **Shift** held
+ spins it about the line of sight — the third degree of freedom an
+ orbit cannot reach. Every drag frame fires ``pointer_move`` and
+ the release fires ``pointer_up``, both with ``event.rotation``
+ set, so keep the ``pointer_move`` handler fast and do expensive
+ work (re-simulating a pattern, say) on ``pointer_up``.
+ """
+ mode = str(mode).lower()
+ if mode not in ("orbit", "rotate"):
+ raise ValueError(f"drag mode must be 'orbit' or 'rotate', got {mode!r}")
+ self._push_fields(drag_mode=mode)
+
def set_zoom(self, zoom: float) -> None:
self._push_fields(zoom=float(zoom), _view_from_python=True)
self._state["_view_from_python"] = False
diff --git a/anyplotlib/tests/test_plot3d/test_model_rotation.py b/anyplotlib/tests/test_plot3d/test_model_rotation.py
new file mode 100644
index 000000000..42f243acd
--- /dev/null
+++ b/anyplotlib/tests/test_plot3d/test_model_rotation.py
@@ -0,0 +1,194 @@
+"""
+tests/test_plot3d/test_model_rotation.py
+========================================
+
+Model rotation on 3-D panels (``Plot3D.set_rotation`` / ``set_drag_mode``) —
+see ``Examples/Interactive/plot_kikuchi_sphere.py``.
+
+Covers:
+ * validation: shape, orthonormality, and reflections are rejected
+ * the push is targeted (no geometry) and flagged so it never moves the camera
+ * a viewer's "rotate" drag reaches ``Plot3D.rotation`` and ``Event.rotation``
+ * Playwright: the rotation turns the drawing, and a drag edits the rotation
+ while leaving the camera where it was
+"""
+from __future__ import annotations
+
+import json
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.tests.test_interactive._event_test_utils import (
+ _collect_events, _get_events,
+)
+
+
+def _rotation_about(axis, degrees):
+ """Right-handed rotation by *degrees* about unit *axis* (Rodrigues)."""
+ k = np.asarray(axis, float) / np.linalg.norm(axis)
+ K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
+ a = np.radians(degrees)
+ return np.eye(3) + np.sin(a) * K + (1 - np.cos(a)) * K @ K
+
+
+def _one_point(**kwargs):
+ """A single red point on the +x axis of an origin-centred unit box."""
+ fig, ax = apl.subplots(1, 1, figsize=(320, 320))
+ plot = ax.scatter3d([1.0], [0.0], [0.0], colors=["#ff0000"], point_size=10,
+ bounds=((-1, 1),) * 3, azimuth=0.0, elevation=0.0,
+ **kwargs)
+ plot.set_axis_off()
+ return fig, plot
+
+
+def _dispatch(fig, plot, event_type, **fields):
+ payload = {"source": "js", "panel_id": plot._id, "event_type": event_type}
+ payload.update(fields)
+ fig._dispatch_event(json.dumps(payload))
+
+
+# ---------------------------------------------------------------------------
+# Python API
+# ---------------------------------------------------------------------------
+
+class TestSetRotation:
+ def test_default_is_identity(self):
+ _, plot = _one_point()
+ np.testing.assert_array_equal(plot.rotation, np.eye(3))
+ assert plot._state["drag_mode"] == "orbit"
+
+ def test_round_trips(self):
+ _, plot = _one_point()
+ R = _rotation_about([1, 2, 3], 40)
+ plot.set_rotation(R)
+ np.testing.assert_allclose(plot.rotation, R)
+
+ def test_none_restores_identity(self):
+ _, plot = _one_point()
+ plot.set_rotation(_rotation_about([0, 0, 1], 30))
+ plot.set_rotation(None)
+ np.testing.assert_array_equal(plot.rotation, np.eye(3))
+
+ @pytest.mark.parametrize("bad", [
+ np.eye(2), # wrong shape
+ 2 * np.eye(3), # scales
+ [[1, 0.2, 0], [0, 1, 0], [0, 0, 1]], # shears
+ np.diag([1.0, 1.0, -1.0]), # reflection
+ np.full((3, 3), np.nan),
+ ])
+ def test_rejects_anything_but_a_proper_rotation(self, bad):
+ _, plot = _one_point()
+ with pytest.raises(ValueError):
+ plot.set_rotation(bad)
+
+ def test_push_carries_no_geometry_and_leaves_the_camera(self):
+ fig, plot = _one_point()
+ plot.set_rotation(_rotation_about([0, 1, 0], 90))
+ light = json.loads(getattr(fig, f"panel_{plot._id}_json"))
+ assert "vertices_b64" not in light
+ assert light["_rotation_from_python"] is True
+ assert light["_view_from_python"] is False
+ # The flags must not persist past the push they belong to.
+ assert plot._state["_rotation_from_python"] is False
+
+ def test_drag_mode_validation(self):
+ _, plot = _one_point()
+ plot.set_drag_mode("rotate")
+ assert plot._state["drag_mode"] == "rotate"
+ with pytest.raises(ValueError):
+ plot.set_drag_mode("spin")
+
+
+class TestRotationFromViewer:
+ def test_drag_updates_rotation_and_event(self):
+ fig, plot = _one_point()
+ seen = []
+ plot.add_event_handler("pointer_up")(lambda e: seen.append(e.rotation))
+ R = _rotation_about([1, 0, 0], 25)
+ _dispatch(fig, plot, "pointer_up", rotation=list(R.ravel()))
+ np.testing.assert_allclose(plot.rotation, R)
+ np.testing.assert_allclose(np.reshape(seen[0], (3, 3)), R)
+
+ def test_export_keeps_the_viewers_rotation(self):
+ fig, plot = _one_point()
+ R = _rotation_about([0, 0, 1], 60)
+ live = json.loads(getattr(fig, f"panel_{plot._id}_json"))
+ live["rotation"] = list(R.ravel())
+ setattr(fig, f"panel_{plot._id}_json", json.dumps(live))
+ fig._reconcile_js_view_state()
+ np.testing.assert_allclose(plot.rotation, R)
+
+
+# ---------------------------------------------------------------------------
+# Rendering + interaction (Playwright)
+# ---------------------------------------------------------------------------
+
+_RED_CENTROID = """() => {
+ const c = [...document.querySelectorAll('canvas')].find(
+ x => x.style.position === 'relative' && x.style.display !== 'none');
+ const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
+ let sx = 0, sy = 0, n = 0;
+ for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) {
+ const i = (y * c.width + x) * 4;
+ if (d[i] > 200 && d[i+1] < 60 && d[i+2] < 60) { sx += x; sy += y; n++; }
+ }
+ return n ? { x: sx / n / c.width, y: sy / n / c.height } : null;
+}"""
+
+
+class TestRendering:
+ def test_rotation_moves_the_point(self, interact_page):
+ """At azimuth 0, elevation 0 the camera shows +x to the right and +z
+ up; turning +x onto +z must carry the point from the right to the top."""
+ _, before = _one_point()
+ right = interact_page(before._fig).evaluate(_RED_CENTROID)
+
+ _, after = _one_point()
+ after.set_rotation(_rotation_about([0, 1, 0], -90)) # x → z
+ top = interact_page(after._fig).evaluate(_RED_CENTROID)
+
+ assert right["x"] > 0.6 and abs(right["y"] - 0.5) < 0.1, right
+ assert top["y"] < 0.4 and abs(top["x"] - 0.5) < 0.1, top
+
+ def test_point_turned_toward_the_camera_lands_dead_centre(self, interact_page):
+ """At elevation 0 the camera faces −y. This also pins elevation 0
+ itself: it was read with ``|| 30`` and so drawn from 30° up."""
+ _, plot = _one_point()
+ plot.set_rotation(_rotation_about([0, 0, 1], -90)) # x → −y
+ centre = interact_page(plot._fig).evaluate(_RED_CENTROID)
+ assert abs(centre["x"] - 0.5) < 0.03 and abs(centre["y"] - 0.5) < 0.03, centre
+
+ def test_rotate_drag_turns_the_model_not_the_camera(self, interact_page):
+ fig, plot = _one_point()
+ plot.set_drag_mode("rotate")
+ page = interact_page(fig)
+ _collect_events(page)
+
+ box = page.evaluate("""() => {
+ const c = [...document.querySelectorAll('canvas')].find(
+ x => x.style.position === 'relative' && x.style.display !== 'none');
+ const r = c.getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ }""")
+ page.mouse.move(box["x"], box["y"])
+ page.mouse.down()
+ page.mouse.move(box["x"], box["y"] - 60, steps=10) # drag up
+ page.mouse.up()
+
+ state = page.evaluate(
+ f"() => JSON.parse(window._aplModel.get('panel_{plot._id}_json'))")
+ assert state["azimuth"] == 0 and state["elevation"] == 0
+ R = np.reshape(state["rotation"], (3, 3))
+ np.testing.assert_allclose(R @ R.T, np.eye(3), atol=1e-9)
+ assert np.linalg.det(R) == pytest.approx(1.0)
+ # Dragging up rolls the front of the sphere upward: about the screen's
+ # x axis, which is the data's x axis at this camera — so +x stays put
+ # and the point does not move, while -y (the side facing the camera)
+ # comes up and +y goes down.
+ np.testing.assert_allclose(R[:, 0], [1, 0, 0], atol=1e-9)
+ assert R[2, 1] < -0.3
+
+ ups = _get_events(page, "pointer_up")
+ assert ups and len(ups[-1]["rotation"]) == 9
diff --git a/upcoming_changes/80.api_change.rst b/upcoming_changes/80.api_change.rst
new file mode 100644
index 000000000..bf060fed6
--- /dev/null
+++ b/upcoming_changes/80.api_change.rst
@@ -0,0 +1 @@
+Changed 3-D panels created with ``elevation=0`` to render at 0 degrees; they were previously drawn as if the elevation were 30 degrees.
diff --git a/upcoming_changes/80.new_feature.rst b/upcoming_changes/80.new_feature.rst
new file mode 100644
index 000000000..f1df1c12a
--- /dev/null
+++ b/upcoming_changes/80.new_feature.rst
@@ -0,0 +1 @@
+Added :meth:`~anyplotlib.Plot3D.set_rotation` and :meth:`~anyplotlib.Plot3D.set_drag_mode` to turn 3-D data to any orientation independently of the camera, by code or by dragging, with the result readable from :attr:`~anyplotlib.Plot3D.rotation` and ``event.rotation``.