From 1c7d180d58f121d42f1cc28e31467fb52d0b4fe7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 14 Oct 2026 20:44:07 -0500 Subject: [PATCH 1/2] Draw regions and paths on 3-D panels Plot3D.add_patch draws a filled region (or, with closed=False, a path) over the geometry. Edges follow great circles about the data centre by default, which is exactly the shape a flat detector's straight edge makes on a sphere, so four corners give a detector's footprint. frame="world" keeps a patch fixed while set_rotation turns the data under it; frame="data" turns it with the data. The renderer splits each patch at the plane through the centre facing the camera and draws the back half dimmer and dashed. Where a geodesic patch is cut there, the fill follows the sphere's silhouette instead of a chord. Labels anchor on the patch's surface, with a halo chosen to contrast with the label colour. The Kikuchi sphere example now shows both frames: the detector footprint (world) and the (111) trace (data). --- AGENTS.md | 2 +- Examples/Interactive/plot_kikuchi_sphere.py | 22 ++ anyplotlib/FIGURE_ESM.md | 116 ++++++----- anyplotlib/__init__.py | 4 +- anyplotlib/figure_esm.js | 165 ++++++++++++++- anyplotlib/plot3d/__init__.py | 4 +- anyplotlib/plot3d/_plot3d.py | 193 ++++++++++++++++++ anyplotlib/tests/test_plot3d/test_patches.py | 184 +++++++++++++++++ .../+plot3d-patches.new_feature.rst | 1 + 9 files changed, 634 insertions(+), 57 deletions(-) create mode 100644 anyplotlib/tests/test_plot3d/test_patches.py create mode 100644 upcoming_changes/+plot3d-patches.new_feature.rst diff --git a/AGENTS.md b/AGENTS.md index d11652226..a132dc01f 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,520 lines. +the 2-D function table). Both were last verified at 12,683 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 index 4037b3927..c6f459bd9 100644 --- a/Examples/Interactive/plot_kikuchi_sphere.py +++ b/Examples/Interactive/plot_kikuchi_sphere.py @@ -14,6 +14,11 @@ :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. + Two :meth:`~anyplotlib.Plot3D.add_patch` overlays show the two frames a + patch can live in: the pink outline is the detector's footprint, fixed in + the microscope (``frame="world"``) while the crystal turns under it; the + yellow trace is the (111) plane, fixed to the crystal (``frame="data"``) + and turning with it. * **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. @@ -170,6 +175,23 @@ def rz(t): sphere.set_drag_mode("rotate") sphere.set_title("Kikuchi sphere · Ni, 20 kV") +# The detector's footprint: its four corners, seen from the sphere's centre. +# A flat detector's straight edges project onto great circles, which is what +# add_patch draws between corners by default. +corners = np.array([[sx * HALF_FIELD, -1.0, sz * HALF_FIELD] + for sx, sz in ((-1, -1), (1, -1), (1, 1), (-1, 1))]) +corners /= np.linalg.norm(corners, axis=1, keepdims=True) +sphere.add_patch(corners, frame="world", facecolor="#f38ba8", alpha=0.12, + linewidth=2, label="detector") + +# The (111) plane's trace: a great circle through four points 90° apart. +normal = np.ones(3) / np.sqrt(3) +in_plane = np.cross(normal, [0.0, 0.0, 1.0]) +in_plane /= np.linalg.norm(in_plane) +across = np.cross(normal, in_plane) +sphere.add_patch([in_plane, across, -in_plane, -across], frame="data", + edgecolor="#f9e2af", alpha=0, linewidth=1.5, label="(111)") + detector = ax_detector.imshow(detector_pattern(start), cmap="gray", vmin=0, vmax=1) detector.set_title(f"Detector · centre {facing_direction(start)}") diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 614ea24a8..feb545944 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -66,15 +66,15 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `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| +| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4840 / 4925| +| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4964 / 4979 / 5000| +| **3D drawing**: `draw3d` | 5976| +| Event emission `_emitEvent` | 6820| +| 3D event handlers `_attachEvents3d` | 6877| +| **1D drawing**: `draw1d` | 7130| +| `_drawLine` (1D series + markers) | 7283| +| `drawOverlay1d` / `_rasterBitmap` / `drawMarkers1d` | 7576 / 7664 / 7681| +| Marker hit-test `_markerHitTest2d` | 7945| > **`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` | 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 | +| Panel event dispatch `_attachPanelEvents` | 8202| +| 2D events `_attachEvents2d` | 8288| +| 1D events `_attachEvents1d` | 8674| +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8949 / 9228| +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 9141 / 9155 / 9184 / 9219| +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9353 / 9426| +| Shared-axis propagation `_getShareGroups` | 9497| +| Figure resize `_applyFigResizeDOM` | 9561| +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9755 / 9818 / 10194| +| Generic redraw `_redrawPanel` | 10384| +| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10547 / 10743 / 10802| +| Native-resolution render `_withNativeSize` | 10523| +| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10836 / 10958 / 11137| +| Export registry `registerExportAction` | 11014| +| **Embedding API**: `createLocalModel` / `mount` | 11528 / 11584 | +| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11839 / 12226 | > **`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` @@ -457,7 +457,7 @@ comparable to the base image's `_buildLut32` blit). --- -## 3D drawing (line 5818) +## 3D drawing (line 5976) Orthographic projection; geometry b64-decoded and cached. `draw3d` sorts triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). @@ -479,6 +479,20 @@ triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). 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`. +- **Patches** (`Plot3D.add_patch`, `st.patches`, `_drawPatches3d`): filled + regions / open paths drawn over the geometry and under the highlight. + Python subdivides geodesic edges into ≤2° great-circle steps, so the JS + only sees a dense boundary. `frame: 'world'` patches use the camera alone + (`Rcam`) and stay fixed while `set_rotation` turns the data; `'data'` + patches use `R`. Each patch is split at the view-space plane y' = 0 + through the centre: `_patchClipPolygon` (Sutherland–Hodgman) gives the fill + for each side, and for a geodesic patch an exit cut followed by an entry + cut is a run along the silhouette, so the chord is replaced by the + silhouette arc. `_patchClipRuns` gives the outline runs — the patch's own + edges only, never the cut. The back side draws at `back_alpha`, dashed. + A label anchors on the patch's surface (centroid pushed out to the + boundary radius), or, for a ring round the centre whose centroid IS the + centre, on its front-most point. - **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 @@ -677,13 +691,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws | Function | Line | Purpose | |----------|------|---------| -| `_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 | +| `_cssScale` | 10419 | inverse of `_applyScale`'s `transform:scale()` | +| `_panelBox` | 10430 | the element whose rect bounds one panel | +| `_neutralizeView` / `_restoreView` | 10439 / 10464 | transient whole-extent view | +| `_nativeGeom` / `_nativeGuard` | 10479 / 10498 | native size + why-not message | +| `_withNativeSize` | 10523 | resize → redraw → run → restore | +| `_compositeCanvas` | 10547 | the compositor (`_drawEl` / `_drawPanel` …) | +| `exportCanvas` / `exportPNG` | 10743 / 10802 | 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 @@ -779,14 +793,14 @@ leaders that cross into the panel included. Pinned by | Function | Line | Purpose | |----------|------|---------| -| `_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) | +| `_toast` | 10836 | transient bottom-centre message | +| `_copyCanvas` | 10871 | clipboard write + feature detection | +| `_showPngPreview` | 10895 | framed-document download fallback | +| `_hostSavesPng` / `_canPickFile` | 10947 / 10953 | the parent saves PNGs itself / a system Save dialog exists | +| `_downloadCanvas` | 10958 | ``, the preview, or the host | +| `registerExportAction` | 11014 | downstream extension point | +| `_menuRows` / `_openMenu` | 11068 / 11137 | menu model / DOM | +| `_panelAtPoint` | 11249 | 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 @@ -880,16 +894,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind. | Function | Line | Purpose | |----------|------|---------| -| `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` | +| `decodeBlocks` | 11839 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry | +| `dense` | 11865 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes | +| `ragged` | 11930 | the same three, over a row-pointer block (`offsets` + one array per column) | +| `maskFromWidget` | 12013 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) | +| `rasterDisks` | 12053 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel | +| `robustLevels` / `toU8` | 12082 / 12123 | the percentile window and the 8-bit code map, one implementation | +| `panelAxis` | 12201 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) | +| `installTouchShim` / `reportEmbedHeight` | 12138 / 12157 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` | +| `encodeBase64` / `typedArrayBytes` | 12177 / 12185 | a 3-D cloud's geometry channel is base64, not the binary side table | +| `mountNavigated` | 12226 | 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/__init__.py b/anyplotlib/__init__.py index 8e14d32d6..17bae7bb2 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -10,7 +10,7 @@ from anyplotlib.plot1d import Plot1D, PlotBar from anyplotlib.plot1d._plot1d import Line1D from anyplotlib.plot2d import Plot2D, PlotMesh -from anyplotlib.plot3d import Plot3D +from anyplotlib.plot3d import Patch3D, Plot3D from anyplotlib.plotxy import PlotXY from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib import embed @@ -41,7 +41,7 @@ def get_color_cycle() -> list[str]: __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", - "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", + "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "Patch3D", "PlotBar", "Line1D", "CallbackRegistry", "Event", "MarkerRegistry", "MarkerGroup", "KeyOverlay", diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 65a76c432..1f2685b1b 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -4012,6 +4012,164 @@ function render({ model, el, onResize, onReadout }) { return [cx + rv[0] * scale, cy - rv[2] * scale]; } + // ── 3D patches (Plot3D.add_patch) ──────────────────────────────────────── + // A patch arrives as a dense boundary (Python subdivides geodesic edges). + // In view space (x' right, y' into the screen, z' up) it is split at the + // plane y' = 0 through the data centre: the half behind is drawn at + // back_alpha. Where a geodesic patch is cut by that plane the cut lies on + // the sphere's silhouette, so the fill follows the silhouette arc instead + // of a chord across the disc. + + const _PATCH_ARC_STEP = Math.PI / 90; // 2°, matching the Python edges + + function _patchInside(v, back) { return back ? v[1] > 0 : v[1] <= 0; } + + function _patchCut(a, b) { + const t = a[1] / (a[1] - b[1]); + return [a[0] + t * (b[0] - a[0]), 0, a[2] + t * (b[2] - a[2])]; + } + + // The fill polygon of one side (Sutherland–Hodgman against y' = 0). + function _patchClipPolygon(pts, back, geodesic) { + const out = []; + const n = pts.length; + for (let i = 0; i < n; i++) { + const a = pts[i], b = pts[(i + 1) % n]; + const ia = _patchInside(a, back), ib = _patchInside(b, back); + if (ib) { + if (!ia) out.push({ v: _patchCut(a, b), cut: true }); + out.push({ v: b, cut: false }); + } else if (ia) { + out.push({ v: _patchCut(a, b), cut: true }); + } + } + if (!geodesic || out.length < 2) return out.map(o => o.v); + // An exit cut followed by an entry cut is a stretch of boundary that runs + // along the silhouette: replace the chord with the arc. + const filled = []; + for (let k = 0; k < out.length; k++) { + const p = out[k], q = out[(k + 1) % out.length]; + filled.push(p.v); + if (!(p.cut && q.cut)) continue; + const r = (Math.hypot(p.v[0], p.v[2]) + Math.hypot(q.v[0], q.v[2])) / 2; + const t0 = Math.atan2(p.v[2], p.v[0]); + let dt = Math.atan2(q.v[2], q.v[0]) - t0; + if (dt > Math.PI) dt -= 2 * Math.PI; + if (dt <= -Math.PI) dt += 2 * Math.PI; + const steps = Math.ceil(Math.abs(dt) / _PATCH_ARC_STEP); + for (let s = 1; s < steps; s++) { + const t = t0 + dt * s / steps; + filled.push([r * Math.cos(t), 0, r * Math.sin(t)]); + } + } + return filled; + } + + // The outline runs of one side: the patch's own edges only, never the cut. + function _patchClipRuns(pts, back) { + const runs = []; + let cur = null; + for (let i = 0; i + 1 < pts.length; i++) { + const a = pts[i], b = pts[i + 1]; + const ia = _patchInside(a, back), ib = _patchInside(b, back); + if (ia && ib) { + if (!cur) { cur = [a]; runs.push(cur); } + cur.push(b); + } else if (ia) { + if (!cur) { cur = [a]; runs.push(cur); } + cur.push(_patchCut(a, b)); cur = null; + } else if (ib) { + cur = [_patchCut(a, b), b]; runs.push(cur); + } else { + cur = null; + } + } + return runs; + } + + function _drawPatches3d(ctx, patches, R, Rcam, norm, cx, cy, scale) { + for (const pa of patches) { + const flat = pa.points || []; + const rot = pa.frame === 'world' ? Rcam : R; + const pts = []; + for (let i = 0; i + 2 < flat.length; i += 3) + pts.push(_applyRot(rot, norm([flat[i], flat[i + 1], flat[i + 2]]))); + if (pts.length < 2) continue; + const closed = pa.closed !== false; + const loop = closed ? pts.concat([pts[0]]) : pts; + const edge = pa.edgecolor || pa.facecolor || '#4fc3f7'; + ctx.save(); + for (const back of [true, false]) { + const dim = back ? (pa.back_alpha ?? 0.3) : 1; + if (dim <= 0) continue; + if (closed && (pa.alpha ?? 0.3) > 0) { + const poly = _patchClipPolygon(pts, back, !!pa.geodesic); + if (poly.length >= 3) { + ctx.globalAlpha = (pa.alpha ?? 0.3) * dim; + ctx.fillStyle = pa.facecolor || '#4fc3f7'; + ctx.beginPath(); + poly.forEach((v, k) => { + const [sx, sy] = _project3(v, cx, cy, scale); + if (k === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); + }); + ctx.closePath(); + ctx.fill(); + } + } + if ((pa.linewidth ?? 1.5) > 0) { + ctx.globalAlpha = dim; + ctx.strokeStyle = edge; + ctx.lineWidth = pa.linewidth ?? 1.5; + ctx.lineJoin = 'round'; + ctx.setLineDash(back ? [4, 3] : []); + for (const run of _patchClipRuns(loop, back)) { + ctx.beginPath(); + run.forEach((v, k) => { + const [sx, sy] = _project3(v, cx, cy, scale); + if (k === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); + }); + ctx.stroke(); + } + } + } + if (pa.label) { + // Anchor on the patch's own surface: its centroid pushed out to the + // boundary's radius. A ring round the centre (a great circle) has + // its centroid AT the centre, so it uses its front-most point. + const c = [0, 0, 0]; + let radius = 0, front = pts[0]; + for (const v of pts) { + c[0] += v[0]; c[1] += v[1]; c[2] += v[2]; + radius += Math.hypot(v[0], v[1], v[2]); + if (v[1] < front[1]) front = v; + } + radius /= pts.length; + const cLen = Math.hypot(c[0], c[1], c[2]) / pts.length; + const anchor = cLen > 0.25 * radius + ? c.map(x => x / pts.length * radius / cLen) : front; + if (anchor[1] <= 0) { + const [lx, ly] = _project3(anchor, cx, cy, scale); + ctx.globalAlpha = 1; + ctx.setLineDash([]); + ctx.font = '600 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 3; + // Halo in whichever of black or white contrasts with the label, so + // it reads on a dark texture and a light page alike. + const hex = /^#([0-9a-f]{6})$/i.exec(edge); + const n = hex ? parseInt(hex[1], 16) : 0x808080; + const luma = 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); + ctx.strokeStyle = luma > 140 ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.85)"; + ctx.strokeText(pa.label, lx, ly); + ctx.fillStyle = edge; + ctx.fillText(pa.label, lx, ly); + } + } + ctx.restore(); + } + } + function _colourFromLut(lut, t) { // t in [0,1] → CSS colour from 256-entry [[r,g,b],...] lut const i = Math.max(0, Math.min(255, Math.round(t * 255))); @@ -5888,7 +6046,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { } // Camera · model: set_rotation turns the data, the camera then looks at it. - const R = _mul3(_rot3(az, el), _modelRot(st.rotation)); + const Rcam = _rot3(az, el); + const R = _mul3(Rcam, _modelRot(st.rotation)); const rotKey = (st.rotation || []).join(","); const cx = pw / 2, cy = ph / 2; const scale = zoom * Math.min(pw, ph) * 0.32; @@ -6623,6 +6782,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { } } + // ── Patches (add_patch): over the geometry, under the highlight ────── + if (st.patches && st.patches.length) + _drawPatches3d(ctx, st.patches, R, Rcam, norm, cx, cy, scale); + // ── Highlight point (drawn last, always on top) ─────────────────────── // st.highlight = {x, y, z, color, size} in data coordinates. A filled // dot with a contrasting ring; semi-transparent when on the far side of diff --git a/anyplotlib/plot3d/__init__.py b/anyplotlib/plot3d/__init__.py index 7ad80921a..e30edb647 100644 --- a/anyplotlib/plot3d/__init__.py +++ b/anyplotlib/plot3d/__init__.py @@ -1,4 +1,4 @@ """anyplotlib.plot3d — 3-D surface, scatter, and line plot panel.""" -from anyplotlib.plot3d._plot3d import Plot3D +from anyplotlib.plot3d._plot3d import Patch3D, Plot3D -__all__ = ["Plot3D"] +__all__ = ["Patch3D", "Plot3D"] diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 0afa96b33..a8e05fc6f 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -154,6 +154,68 @@ def _rotation_to_wire(matrix) -> list: return [float(v) for v in m.ravel()] +#: Largest angle one segment of a geodesic patch edge spans. At 2° a great +#: circle on a 400 px sphere deviates from its chord by under 0.1 px. +_GEODESIC_STEP_DEG = 2.0 + + +def _geodesic_path(points: np.ndarray, centre: np.ndarray, + closed: bool) -> np.ndarray: + """Join consecutive *points* by great-circle arcs about *centre*. + + The radius is interpolated linearly along each arc, so points on one + sphere stay on it and points at different radii blend smoothly. + """ + offsets = points - centre + radii = np.linalg.norm(offsets, axis=1) + if np.any(radii < 1e-12): + raise ValueError("a geodesic patch cannot have a point at the centre; " + "pass geodesic=False for straight edges") + units = offsets / radii[:, None] + n = len(points) + pieces = [] + for i in range(n if closed else n - 1): + j = (i + 1) % n + angle = float(np.arccos(np.clip(units[i] @ units[j], -1.0, 1.0))) + if angle > np.radians(179.9): + raise ValueError( + f"points {i} and {j} are on opposite sides of the centre, so " + "no single great circle joins them; add a point in between") + steps = max(1, int(np.ceil(np.degrees(angle) / _GEODESIC_STEP_DEG))) + t = np.arange(steps) / steps + if angle < 1e-9: + arc = np.repeat(units[i][None], steps, axis=0) + else: + arc = (np.sin((1 - t) * angle)[:, None] * units[i] + + np.sin(t * angle)[:, None] * units[j]) / np.sin(angle) + pieces.append(arc * (radii[i] + (radii[j] - radii[i]) * t)[:, None]) + if not closed: + pieces.append(offsets[-1:]) + return np.vstack(pieces) + centre + + +class Patch3D: + """A region or path drawn on a 3-D panel; returned by :meth:`Plot3D.add_patch`. + + Change it in place with :meth:`set` and take it off with :meth:`remove`. + """ + + def __init__(self, plot: "Plot3D", name: str): + self._plot = plot + self.name = name + + def set(self, **kwargs) -> None: + """Change any argument :meth:`Plot3D.add_patch` takes, by name.""" + self._plot._update_patch(self.name, kwargs) + + def remove(self) -> None: + """Take the patch off its panel.""" + self._plot.remove_patch(self.name) + + def __repr__(self) -> str: + return f"Patch3D(name={self.name!r})" + + 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,6 +368,8 @@ def __init__(self, geom_type: str, "highlight": None, # Reference sphere: {"radius","color","alpha","wireframe"} or None "sphere": None, + # Regions and paths (add_patch), drawn over the geometry. + "patches": [], # WebGPU activation policy: 'auto' (GPU above a point threshold), # 'always', or 'off'. Falls back to Canvas2D whenever the GPU is # unavailable; query the result via the gpu_active property. @@ -342,6 +406,8 @@ def __init__(self, geom_type: str, } self.callbacks = CallbackRegistry() self._widgets: dict = {} + #: name → (Patch3D, the arguments it was made with) + self._patches: dict = {} if texture is not None: self.set_texture(texture) @@ -813,6 +879,133 @@ def clear_sphere(self) -> None: self._state["sphere"] = None self._push() + # ------------------------------------------------------------------ + # Patches: regions and paths drawn over the geometry + # ------------------------------------------------------------------ + def add_patch(self, points, *, frame: str = "data", closed: bool = True, + geodesic: bool = True, facecolor: str = "#4fc3f7", + edgecolor: str | None = None, alpha: float = 0.3, + back_alpha: float = 0.3, linewidth: float = 1.5, + label: str = "", name: str | None = None) -> Patch3D: + """Draw a region (or, with ``closed=False``, a path) on this panel. + + Made for spheres: the region a detector sees on a master pattern, a + grain's spread of orientations on a pole sphere, the trace of a band. + By default each edge follows the great circle between its two corners + — the shortest way round the sphere, and exactly the shape a flat + detector's straight edge makes when projected onto it — so four + corners are enough for a detector's footprint. + + The part of the patch behind the centre of the data is drawn dimmer + (``back_alpha``), and where a geodesic patch wraps behind the sphere + its outline follows the sphere's edge rather than cutting across it. + + Parameters + ---------- + points : (N, 3) array-like + Corners (or path points) in data coordinates. + frame : ``"data"`` | ``"world"`` + ``"data"`` (default) turns the patch with the data when + :meth:`set_rotation` changes — a feature of the object. + ``"world"`` keeps it fixed while the data turns under it — a + detector, a beam, anything that belongs to the instrument. Both + move with the camera. + closed : bool + Join the last point back to the first and fill the inside. + ``False`` draws an open path with no fill. + geodesic : bool + Join points by great-circle arcs about the centre of the data + bounds (default). ``False`` joins them with straight lines. + facecolor, edgecolor : str + Fill and outline colours; ``edgecolor`` defaults to ``facecolor``. + alpha : float + Opacity of the fill, 0–1. The outline is drawn opaque. + back_alpha : float + Opacity multiplier for the part behind the centre, 0–1. + linewidth : float + Outline width in pixels; 0 hides the outline. + label : str + Text drawn at the patch's centre while it faces the camera. + name : str, optional + Key in :attr:`patches`. Re-using a name replaces that patch. + + Returns + ------- + Patch3D + Handle with :meth:`~Patch3D.set` and :meth:`~Patch3D.remove`. + """ + if name is None: + name = f"patch{len(self._patches)}" + while name in self._patches: + name += "_" + args = dict(points=points, frame=frame, closed=closed, + geodesic=geodesic, facecolor=facecolor, edgecolor=edgecolor, + alpha=alpha, back_alpha=back_alpha, linewidth=linewidth, + label=label) + wire = self._patch_wire(name, args) # validate before storing + handle = Patch3D(self, name) + self._patches[name] = (handle, args, wire) + self._push_patches() + return handle + + def remove_patch(self, patch) -> None: + """Take off a patch, given its handle or its name.""" + name = patch.name if isinstance(patch, Patch3D) else patch + if name not in self._patches: + raise KeyError(name) + del self._patches[name] + self._push_patches() + + @property + def patches(self) -> dict: + """The panel's patches by name, as :class:`Patch3D` handles.""" + return {name: entry[0] for name, entry in self._patches.items()} + + def _update_patch(self, name: str, changes: dict) -> None: + handle, args, _ = self._patches[name] + unknown = set(changes) - set(args) + if unknown: + raise TypeError(f"unknown patch argument(s): {sorted(unknown)}") + args = {**args, **changes} + self._patches[name] = (handle, args, self._patch_wire(name, args)) + self._push_patches() + + def _push_patches(self) -> None: + self._push_fields(patches=[wire for _, _, wire in self._patches.values()]) + + def _patch_wire(self, name: str, args: dict) -> dict: + points = np.asarray(args["points"], dtype=float) + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError(f"patch points must be (N, 3), got {points.shape}") + closed = bool(args["closed"]) + if len(points) < (3 if closed else 2): + raise ValueError("a closed patch needs at least 3 points, " + "an open path at least 2") + if not np.all(np.isfinite(points)): + raise ValueError("patch points must be finite") + frame = str(args["frame"]).lower() + if frame not in ("data", "world"): + raise ValueError(f"frame must be 'data' or 'world', got {frame!r}") + if args["geodesic"]: + b = self._state["data_bounds"] + centre = np.array([(b["xmin"] + b["xmax"]) / 2, + (b["ymin"] + b["ymax"]) / 2, + (b["zmin"] + b["zmax"]) / 2]) + points = _geodesic_path(points, centre, closed) + return { + "id": name, + "points": [round(float(v), 6) for v in points.ravel()], + "frame": frame, + "closed": closed, + "geodesic": bool(args["geodesic"]), + "facecolor": str(args["facecolor"]), + "edgecolor": str(args["edgecolor"] or args["facecolor"]), + "alpha": float(np.clip(args["alpha"], 0.0, 1.0)), + "back_alpha": float(np.clip(args["back_alpha"], 0.0, 1.0)), + "linewidth": float(args["linewidth"]), + "label": str(args["label"]), + } + def __repr__(self) -> str: geom = self._state.get("geom_type", "?") n = self._state.get("vertices_count", 0) diff --git a/anyplotlib/tests/test_plot3d/test_patches.py b/anyplotlib/tests/test_plot3d/test_patches.py new file mode 100644 index 000000000..621e7ea0a --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_patches.py @@ -0,0 +1,184 @@ +""" +tests/test_plot3d/test_patches.py +================================= + +Regions and paths on 3-D panels (``Plot3D.add_patch``) — see +``Examples/Interactive/plot_kikuchi_sphere.py``. + +Covers: + * geodesic densification: edges become great-circle arcs about the centre + * validation: shape, frame, antipodal and centre-sitting points + * the handle: live ``set`` and ``remove`` through a light push + * Playwright: a "data" patch turns with set_rotation and a "world" patch + does not; the part behind the centre is drawn dimmer than the front +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _unit_sphere_panel(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + plot = ax.scatter3d([0.0], [0.0], [0.0], point_size=0.1, + bounds=((-1, 1),) * 3, azimuth=0.0, elevation=0.0) + plot.set_axis_off() + return fig, plot + + +def _square(half_angle_deg=20, centre=(0.0, -1.0, 0.0)): + """Four corners of a square cap facing *centre* on the unit sphere.""" + t = np.tan(np.radians(half_angle_deg)) + c = np.asarray(centre, float) + # Two directions perpendicular to the centre direction. + u = np.cross(c, [0, 0, 1]) if abs(c[2]) < 0.9 else np.cross(c, [1, 0, 0]) + u /= np.linalg.norm(u) + v = np.cross(c, u) + corners = [c + t * (su * u + sv * v) + for su, sv in ((-1, -1), (1, -1), (1, 1), (-1, 1))] + return np.array([p / np.linalg.norm(p) for p in corners]) + + +def _wire(plot): + return plot._state["patches"] + + +# --------------------------------------------------------------------------- +# Python API +# --------------------------------------------------------------------------- + +class TestGeodesic: + def test_edges_stay_on_the_sphere(self): + _, plot = _unit_sphere_panel() + plot.add_patch(_square(40)) + pts = np.reshape(_wire(plot)[0]["points"], (-1, 3)) + np.testing.assert_allclose(np.linalg.norm(pts, axis=1), 1.0, atol=1e-6) + assert len(pts) > 4 * 10, "edges were not subdivided" + + def test_steps_are_at_most_two_degrees(self): + _, plot = _unit_sphere_panel() + plot.add_patch(_square(40)) + pts = np.reshape(_wire(plot)[0]["points"], (-1, 3)) + closed = np.vstack([pts, pts[:1]]) + cosines = np.einsum("ij,ij->i", closed[:-1], closed[1:]) + assert np.degrees(np.arccos(np.clip(cosines, -1, 1))).max() <= 2.0 + 1e-6 + + def test_straight_edges_when_not_geodesic(self): + _, plot = _unit_sphere_panel() + plot.add_patch(_square(40), geodesic=False) + assert len(_wire(plot)[0]["points"]) == 4 * 3 + + def test_open_path_keeps_its_last_point(self): + _, plot = _unit_sphere_panel() + path = np.array([[1.0, 0, 0], [0, 1.0, 0]]) + plot.add_patch(path, closed=False) + pts = np.reshape(_wire(plot)[0]["points"], (-1, 3)) + np.testing.assert_allclose(pts[0], path[0]) + np.testing.assert_allclose(pts[-1], path[1]) + + def test_antipodal_edge_is_rejected(self): + _, plot = _unit_sphere_panel() + with pytest.raises(ValueError, match="opposite"): + plot.add_patch([[1, 0, 0], [-1, 0, 0], [0, 1, 0]]) + + def test_point_at_the_centre_is_rejected(self): + _, plot = _unit_sphere_panel() + with pytest.raises(ValueError, match="centre"): + plot.add_patch([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) + + +class TestHandle: + def test_defaults_and_validation(self): + _, plot = _unit_sphere_panel() + patch = plot.add_patch(_square(), label="detector") + wire = _wire(plot)[0] + assert wire["frame"] == "data" and wire["closed"] is True + assert wire["label"] == "detector" and patch.name == wire["id"] + with pytest.raises(ValueError): + plot.add_patch(_square(), frame="screen") + with pytest.raises(ValueError): + plot.add_patch(np.zeros((4, 2))) + with pytest.raises(ValueError): + plot.add_patch([[1, 0, 0], [0, 1, 0]]) # closed needs 3 + + def test_set_and_remove_are_light_pushes(self): + fig, plot = _unit_sphere_panel() + patch = plot.add_patch(_square(), name="det") + patch.set(facecolor="#00ff00", frame="world", points=_square(10)) + light = json.loads(getattr(fig, f"panel_{plot._id}_json")) + assert "vertices_b64" not in light + (wire,) = light["patches"] + assert wire["facecolor"] == "#00ff00" and wire["frame"] == "world" + assert plot.patches["det"] is patch + patch.remove() + assert _wire(plot) == [] and "det" not in plot.patches + + def test_duplicate_name_replaces(self): + _, plot = _unit_sphere_panel() + plot.add_patch(_square(), name="det") + plot.add_patch(_square(10), name="det") + assert len(_wire(plot)) == 1 + + +# --------------------------------------------------------------------------- +# Rendering (Playwright) +# --------------------------------------------------------------------------- + +_GREEN = """() => { + 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, peak = 0; + for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) { + const i = (y * c.width + x) * 4; + const g = d[i+1] - Math.max(d[i], d[i+2]); + if (g > 25) { sx += x; sy += y; n++; peak = Math.max(peak, g); } + } + return n ? { x: sx / n / c.width, y: sy / n / c.height, n, peak } : null; +}""" + + +def _rotation_about_z(degrees): + a = np.radians(degrees) + return np.array([[np.cos(a), -np.sin(a), 0], [np.sin(a), np.cos(a), 0], + [0, 0, 1]]) + + +class TestRendering: + @pytest.mark.parametrize("frame, moves", [("data", True), ("world", False)]) + def test_frame_decides_whether_the_patch_turns(self, interact_page, + frame, moves): + """A cap facing the camera, then the model turned 60° about z: a + "data" patch swings off to the side, a "world" patch stays put.""" + _, still = _unit_sphere_panel() + still.add_patch(_square(15), frame=frame, facecolor="#00ff00", + edgecolor="#00ff00", alpha=0.6) + before = interact_page(still._fig).evaluate(_GREEN) + + _, turned = _unit_sphere_panel() + turned.add_patch(_square(15), frame=frame, facecolor="#00ff00", + edgecolor="#00ff00", alpha=0.6) + turned.set_rotation(_rotation_about_z(60)) + after = interact_page(turned._fig).evaluate(_GREEN) + + assert abs(before["x"] - 0.5) < 0.03, before + if moves: + assert after["x"] > 0.6, after + else: + assert abs(after["x"] - before["x"]) < 0.01, (before, after) + + def test_far_side_is_dimmer(self, interact_page): + _, front = _unit_sphere_panel() + front.add_patch(_square(15), facecolor="#00ff00", edgecolor="#00ff00", + alpha=0.6) + _, back = _unit_sphere_panel() + back.add_patch(_square(15, centre=(0, 1, 0)), facecolor="#00ff00", + edgecolor="#00ff00", alpha=0.6) + near = interact_page(front._fig).evaluate(_GREEN) + far = interact_page(back._fig).evaluate(_GREEN) + assert far is not None, "the far-side patch was not drawn at all" + assert far["peak"] < 0.7 * near["peak"], (near, far) diff --git a/upcoming_changes/+plot3d-patches.new_feature.rst b/upcoming_changes/+plot3d-patches.new_feature.rst new file mode 100644 index 000000000..6212e8105 --- /dev/null +++ b/upcoming_changes/+plot3d-patches.new_feature.rst @@ -0,0 +1 @@ +Added :meth:`~anyplotlib.Plot3D.add_patch` to draw filled regions and paths on 3-D panels, with edges along great circles, the far side dimmed, and a choice of turning with the data or staying fixed while it rotates. From d0c8bd49603491f658eb0f91a75f541f621e6abc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 14 Oct 2026 20:44:16 -0500 Subject: [PATCH 2/2] Name the changelog fragment for PR 81 --- .../{+plot3d-patches.new_feature.rst => 81.new_feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename upcoming_changes/{+plot3d-patches.new_feature.rst => 81.new_feature.rst} (100%) diff --git a/upcoming_changes/+plot3d-patches.new_feature.rst b/upcoming_changes/81.new_feature.rst similarity index 100% rename from upcoming_changes/+plot3d-patches.new_feature.rst rename to upcoming_changes/81.new_feature.rst