diff --git a/AGENTS.md b/AGENTS.md
index d1165222..a132dc01 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 4037b392..c6f459bd 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 614ea24a..feb54594 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 8e14d32d..17bae7bb 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 65a76c43..1f2685b1 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 7ad80921..e30edb64 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 0afa96b3..a8e05fc6 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 00000000..621e7ea0
--- /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/81.new_feature.rst b/upcoming_changes/81.new_feature.rst
new file mode 100644
index 00000000..6212e810
--- /dev/null
+++ b/upcoming_changes/81.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.