From 874180da404eabc44e1e7b9d0f91fbea5ea2d44b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 14 Oct 2026 20:37:26 -0500 Subject: [PATCH 1/2] Turn 3-D 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, so a crystal, molecule or stage-mounted sample could not be turned to an arbitrary pose. Plot3D.set_rotation rotates the data about the centre of its bounds before the camera looks at it; draw3d composes camera and model once, so every draw path (Canvas2D, GPU matrix and normals, axes, highlight, sphere, planes) follows with no other change. set_rotation is a targeted push with its own flag, so it neither resends geometry nor snaps the camera back to Python's stale angles. set_drag_mode("rotate") turns a drag into a trackball on the model (Shift spins about the line of sight) and reports event.rotation on pointer_move and pointer_up; Figure dispatch writes it back so Plot3D.rotation follows the viewer. Also fixes elevation 0 being drawn from 30 degrees up: the renderer read it with `|| 30`. New example: a nickel Kikuchi band sphere whose detector pattern is recomputed from the rotation as you drag. --- AGENTS.md | 2 +- Examples/Interactive/plot_kikuchi_sphere.py | 188 +++++++++++++++++ anyplotlib/FIGURE_ESM.md | 155 +++++++------- anyplotlib/callbacks.py | 2 + anyplotlib/figure/_figure.py | 7 + anyplotlib/figure_esm.js | 94 ++++++++- anyplotlib/plot3d/_plot3d.py | 100 +++++++++ .../tests/test_plot3d/test_model_rotation.py | 194 ++++++++++++++++++ 8 files changed, 665 insertions(+), 77 deletions(-) create mode 100644 Examples/Interactive/plot_kikuchi_sphere.py create mode 100644 anyplotlib/tests/test_plot3d/test_model_rotation.py 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 From ae048a087250a86a3bd23922d677ce67f731702f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 14 Oct 2026 20:37:56 -0500 Subject: [PATCH 2/2] Add changelog fragments for PR 80 --- upcoming_changes/80.api_change.rst | 1 + upcoming_changes/80.new_feature.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 upcoming_changes/80.api_change.rst create mode 100644 upcoming_changes/80.new_feature.rst 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``.