fluxplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. fluxplot/__init__.py +115 -0
  2. fluxplot/_fieldmap.py +97 -0
  3. fluxplot/_mesh_reduce.py +54 -0
  4. fluxplot/_scene3d_size.py +95 -0
  5. fluxplot/_viewer/THIRD-PARTY.txt +23 -0
  6. fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
  7. fluxplot/_viewer/stamp.json +4 -0
  8. fluxplot/api.py +1196 -0
  9. fluxplot/autotag.py +164 -0
  10. fluxplot/base.mplstyle +0 -0
  11. fluxplot/brackets.py +242 -0
  12. fluxplot/canonical_json.py +23 -0
  13. fluxplot/capture.py +150 -0
  14. fluxplot/colorcheck.py +285 -0
  15. fluxplot/colors.py +727 -0
  16. fluxplot/colorscale.py +477 -0
  17. fluxplot/data.py +178 -0
  18. fluxplot/definitions/colormaps.json +1639 -0
  19. fluxplot/definitions/flexoki.tokens.json +2571 -0
  20. fluxplot/definitions/palettes.json +2547 -0
  21. fluxplot/descriptors.py +87 -0
  22. fluxplot/fields.py +611 -0
  23. fluxplot/fits.py +240 -0
  24. fluxplot/glb.py +84 -0
  25. fluxplot/ids.py +173 -0
  26. fluxplot/images.py +362 -0
  27. fluxplot/integrity.py +27 -0
  28. fluxplot/manifest.py +788 -0
  29. fluxplot/mesh3d.py +376 -0
  30. fluxplot/panels.py +284 -0
  31. fluxplot/postprocess.py +638 -0
  32. fluxplot/presets.py +66 -0
  33. fluxplot/provenance.py +177 -0
  34. fluxplot/raster.py +295 -0
  35. fluxplot/recipe.py +178 -0
  36. fluxplot/render.py +66 -0
  37. fluxplot/roles.py +147 -0
  38. fluxplot/scene3d.py +386 -0
  39. fluxplot/scene3d_manifest.py +112 -0
  40. fluxplot/scene3d_viewer.py +633 -0
  41. fluxplot/schemas/.gitkeep +0 -0
  42. fluxplot/schemas/manifest.schema.json +2479 -0
  43. fluxplot/schemas/recipe.schema.json +179 -0
  44. fluxplot/schemas/scene3d.schema.json +461 -0
  45. fluxplot/seaborn_adapters.py +323 -0
  46. fluxplot/signature_fluxplots/__init__.py +18 -0
  47. fluxplot/signature_fluxplots/_colour.py +412 -0
  48. fluxplot/signature_fluxplots/fluxbox.py +433 -0
  49. fluxplot/signature_fluxplots/glowbar.py +769 -0
  50. fluxplot/signature_fluxplots/hexmatrix.py +927 -0
  51. fluxplot/stats/__init__.py +63 -0
  52. fluxplot/stats/_common.py +196 -0
  53. fluxplot/stats/multi_group.py +443 -0
  54. fluxplot/stats/paired.py +209 -0
  55. fluxplot/stats/two_group.py +149 -0
  56. fluxplot/style.py +469 -0
  57. fluxplot/surface.py +487 -0
  58. fluxplot/surface3d.py +197 -0
  59. fluxplot/tagger.py +561 -0
  60. fluxplot/version.py +19 -0
  61. fluxplot-0.1.0.dist-info/METADATA +1199 -0
  62. fluxplot-0.1.0.dist-info/RECORD +65 -0
  63. fluxplot-0.1.0.dist-info/WHEEL +4 -0
  64. fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/surface.py ADDED
@@ -0,0 +1,487 @@
1
+ """Surface (brain) maps as first-class, part-addressable FluxPlot marks.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ FluxPlot's contract is that a plot's *science* — which value became which colour — stays **data,
6
+ not pixels**. Every 2D chart honours that: named marks in the SVG, a ``.fluxplot.json`` manifest,
7
+ a ``.recipe.json`` for provenance, all editable in the Flux interface.
8
+
9
+ **Surface maps** (a per-vertex scalar or label field painted on a triangulated mesh — the standard
10
+ way neuroimaging shows a cortical result, but equally any scalar field on any mesh) had no such
11
+ primitive. They were produced by external VTK-backed tools as flat raster PNGs, so the one thing a
12
+ reviewer most wants to correct — *this region should be that colour; this threshold is wrong* — was
13
+ the one thing baked irreversibly into pixels.
14
+
15
+ ``surface()`` closes that gap. It draws the mesh with matplotlib primitives only (numpy +
16
+ matplotlib; ``nibabel`` is optional and used only to read GIFTI files), so:
17
+
18
+ * **Categorical / label maps** are split into **one collection per category** — each becomes a
19
+ named, selectable, recolourable part (``atlas.frontal``, ``atlas.parietal``, …). Recolouring a
20
+ region, or hiding it, is a style edit on a named part, exactly like restyling a bar series. This
21
+ is the full part-addressability tier, not a raster with a legend.
22
+ * **Continuous maps** carry their complete value→colour mapping (colormap, vmin/vmax, the
23
+ percentile rule that produced them) in the manifest, with the colorbar as its own named part, so
24
+ range/threshold edits are declarative and re-render deterministically.
25
+ * The **medial wall / missing data** is its own named part, never conflated with a real value.
26
+
27
+ Rendering is vector (one ``PolyCollection`` per part). A typical cortical mesh is tens of thousands
28
+ of triangles per hemisphere, which is exactly the node count :mod:`fluxplot.raster` exists to tame:
29
+ the collections are handed to the standard rasterisation planner, which composites each *part* to a
30
+ single ``<image>`` **while preserving its gid**. So the file stays light and every part stays
31
+ addressable — the mesh is drawn honestly as geometry, and the id contract survives.
32
+
33
+ Correctness rules made first-class (each is a documented failure mode of naive surface plots):
34
+
35
+ * vertices with no data (a mesh's medial wall / non-surface region) are **missing** (NaN) → their
36
+ own grey part, never a data value;
37
+ * a value of **exactly 0 is real** and must stay distinguishable from missing — there is no
38
+ "zero is transparent" behaviour here;
39
+ * a **sentinel code** (e.g. −1 to blank one hemisphere) greys out via ``missing_below`` /
40
+ ``missing_values`` rather than becoming a spurious extra category;
41
+ * categorical colour is a **fixed id→colour map** with no silent remapping;
42
+ * back-facing geometry is culled, so a far-side face can never paint over the visible surface.
43
+
44
+ Example
45
+ -------
46
+ >>> import fluxplot as fp, matplotlib.pyplot as plt
47
+ >>> fig, ax = plt.subplots(figsize=(6.4, 1.6))
48
+ >>> fp.surface(ax, labels, series="atlas", # per-vertex integer labels
49
+ ... surfaces={"left": "lh.surf.gii", "right": "rh.surf.gii"},
50
+ ... kind="label",
51
+ ... categories={0: "frontal", 1: "parietal", 2: "temporal"},
52
+ ... palette={"frontal": "#4C78A8", "parietal": "#F58518",
53
+ ... "temporal": "#54A24B"})
54
+ >>> fp.save(fig, "plots/atlas.svg")
55
+
56
+ Meshes may also be passed as ``(vertices, faces)`` arrays, so the core has no I/O dependency;
57
+ ``nibabel`` is imported only when a GIFTI path is given.
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import warnings
62
+
63
+ import numpy as np
64
+
65
+ # Views are named by what the viewer sees. For each (hemisphere, view) we look down ±x and keep
66
+ # (y, z); the sign flip keeps anterior consistently to the same side of the page across views.
67
+ _VIEW_SPEC = {
68
+ ("left", "lateral"): (-1, +1),
69
+ ("left", "medial"): (+1, -1),
70
+ ("right", "lateral"): (+1, -1),
71
+ ("right", "medial"): (-1, +1),
72
+ }
73
+
74
+
75
+ def _load_surface(spec):
76
+ """``spec`` → ``(vertices[V,3], faces[F,3])``.
77
+
78
+ Accepts an already-loaded ``(vertices, faces)`` pair (keeps fluxplot dependency-free) or a path
79
+ to a GIFTI surface, which needs ``nibabel``.
80
+ """
81
+ if isinstance(spec, (tuple, list)) and len(spec) == 2:
82
+ v, f = spec
83
+ return np.asarray(v, dtype=float), np.asarray(f, dtype=int)
84
+ try:
85
+ import nibabel as nib
86
+ except ImportError as exc: # pragma: no cover - environment dependent
87
+ raise ImportError(
88
+ "reading a GIFTI surface needs nibabel (pip install nibabel); alternatively pass "
89
+ "surfaces={'left': (vertices, faces), ...} directly so fluxplot stays dependency-free"
90
+ ) from exc
91
+ g = nib.load(str(spec))
92
+ verts = g.agg_data("pointset")
93
+ faces = g.agg_data("triangle")
94
+ return np.asarray(verts, dtype=float), np.asarray(faces, dtype=int)
95
+
96
+
97
+ def _project(verts, hemi, view):
98
+ """Orthographic projection of ``verts`` for one (hemisphere, view), plus a depth per vertex.
99
+
100
+ Returns ``(xy[V,2], depth[V], sign_x)`` where larger depth is nearer the viewer and ``sign_x``
101
+ is the axis sign pointing at the camera (used for back-face culling).
102
+ """
103
+ try:
104
+ sign_x, sign_y = _VIEW_SPEC[(hemi, view)]
105
+ except KeyError:
106
+ raise ValueError(
107
+ f"unknown (hemisphere, view) = ({hemi!r}, {view!r}); "
108
+ f"expected one of {sorted(_VIEW_SPEC)}") from None
109
+ xy = np.column_stack([sign_y * verts[:, 1], verts[:, 2]])
110
+ depth = sign_x * verts[:, 0]
111
+ return xy, depth, sign_x
112
+
113
+
114
+ def _front_facing(verts, faces, sign_x):
115
+ """Boolean mask of faces whose outward normal points at the camera.
116
+
117
+ Back-face culling — not merely an optimisation, it is what makes per-region collections
118
+ *correct*. Splitting a map into one collection per category means matplotlib draws the
119
+ categories in sequence, so a painter's-algorithm depth sort inside each collection cannot stop a
120
+ far-side face of one category from painting over a near-side face of another.
121
+ This only establishes correct cross-category visibility for convex projections.
122
+ Front-facing faces can still overlap on folded/concave meshes; category layers
123
+ use per-part painter ordering and are not a general depth-buffer renderer.
124
+ """
125
+ tri = verts[faces]
126
+ normals = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
127
+ return sign_x * normals[:, 0] > 0
128
+
129
+
130
+ def _face_shading(verts, faces, sign_x, light, strength):
131
+ """Per-face diffuse intensity in (0, 1] from the surface normals.
132
+
133
+ A flat-coloured mesh carries no relief: without an illumination term every face of a fold is the
134
+ same colour, so sulci and gyri are invisible however unsmoothed the geometry is. Lambert shading
135
+ restores the form — a face turned away from the light darkens — and it is purely geometric, so it
136
+ changes lightness only, never which value mapped to which hue.
137
+
138
+ ``light`` is given in the VIEW frame (x toward the camera, y right, z up) and is flipped with the
139
+ camera so both hemispheres are lit from the same side of the page.
140
+ """
141
+ tri = verts[faces]
142
+ n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
143
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-12)
144
+ L = np.asarray(light, dtype=float)
145
+ L = L / max(np.linalg.norm(L), 1e-12)
146
+ ndotl = np.abs(n[:, 0] * L[0] * sign_x + n[:, 1] * L[1] + n[:, 2] * L[2])
147
+ return 1.0 - float(strength) * (1.0 - np.clip(ndotl, 0.0, 1.0))
148
+
149
+
150
+ def _shade_rgba(colours, intensity):
151
+ """Multiply RGB by a per-face intensity, leaving alpha alone."""
152
+ rgba = np.array(colours, dtype=float, copy=True)
153
+ if rgba.ndim == 1:
154
+ rgba = np.tile(rgba, (intensity.size, 1))
155
+ rgba[:, :3] *= intensity[:, None]
156
+ return np.clip(rgba, 0.0, 1.0)
157
+
158
+
159
+ def _face_values(values, faces):
160
+ """Per-face value = mean of its vertices; a face touching missing data is itself missing.
161
+
162
+ Propagating NaN (rather than averaging around it) keeps the medial-wall boundary crisp instead
163
+ of smearing a halo of interpolated colour across it.
164
+ """
165
+ # A plain mean already propagates NaN, which IS the rule we want: a face touching missing data
166
+ # is itself missing, so the medial-wall boundary stays crisp instead of smearing a halo of
167
+ # interpolated colour across it.
168
+ return values[faces].mean(axis=1)
169
+
170
+
171
+ def _face_labels(values, faces):
172
+ """Per-face label = the MAJORITY label of its three vertices; missing only if a vertex is.
173
+
174
+ A face straddling a boundary between two categories has to be drawn as one of them. Leaving it
175
+ unassigned instead would (a) draw it in the missing/no-data colour, conflating "on a border"
176
+ with "no data", and (b) etch a visible pale crack along every boundary — the artefact this rule
177
+ exists to avoid. Majority assignment is symmetric: each category gives up as many border faces
178
+ as it gains, so no block is systematically fattened. Three mutually distinct vertices (possible
179
+ only where three categories meet) fall back to the first vertex, which affects isolated faces.
180
+ """
181
+ tri = values[faces]
182
+ out = np.where(tri[:, 0] == tri[:, 1], tri[:, 0],
183
+ np.where(tri[:, 1] == tri[:, 2], tri[:, 1], tri[:, 0]))
184
+ out[np.isnan(tri).any(axis=1)] = np.nan
185
+ return out
186
+
187
+
188
+ from ._fieldmap import _normalise_missing, categorical_colors, category_name, continuous_mapping
189
+
190
+
191
+ def surface(ax, values, *, series, surfaces, kind="auto", categories=None, palette=None,
192
+ cmap=None, color_range=None, percentile=None, views=("lateral", "medial"),
193
+ hemispheres=("left", "right"), missing_below=None, missing_values=(),
194
+ missing_color="#D8D8D8", gap=0.06, edgecolor="none", linewidth=0.0,
195
+ shading=0.0, light=(0.35, -0.25, 0.90), antialiased=False, colorbar=False, cbar_label=None, cbar_ticks=None, legend=None,
196
+ legend_missing=False, legend_kw=None, label=None):
197
+ """Draw a per-vertex surface map as named, addressable parts.
198
+
199
+ Parameters
200
+ ----------
201
+ ax
202
+ Target axes. Every requested (hemisphere, view) is tiled left→right inside it; the axes is
203
+ set to equal aspect with its frame hidden, because a cortical projection has no meaningful
204
+ data axes.
205
+ values
206
+ Per-vertex array over the concatenated hemispheres (left then right), or per hemisphere via
207
+ a ``{"left": ..., "right": ...}`` mapping.
208
+ series
209
+ Addressable name for this map — parts are ``<series>.<category>`` (label maps) or
210
+ ``<series>.field`` (continuous), plus ``<series>.missing``.
211
+ surfaces
212
+ ``{"left": spec, "right": spec}`` where each spec is a GIFTI path or a ``(vertices, faces)``
213
+ pair.
214
+ kind
215
+ ``"label"``, ``"continuous"``, or ``"auto"`` (label when the data are integral and few-valued).
216
+ categories
217
+ ``{code: name}`` for label maps. Codes absent from the data are ignored; data codes absent
218
+ here get a ``category-<code>`` name so nothing is silently dropped (negative codes are
219
+ ``category-m<abs>``, e.g. ``category-m1``, so ``-1`` and ``+1`` never share an id).
220
+ palette
221
+ ``{name_or_code: colour}`` for label maps — a fixed mapping, never remapped.
222
+ cmap, color_range, percentile
223
+ Continuous styling. ``cmap`` is a Colormap or a name: matplotlib names first, then
224
+ fluxplot's collections (``"emerald"``, ``"crameri.batlow"``). ``percentile=(2, 98)``
225
+ clips to those percentiles of the finite data; ``color_range`` wins if both are given.
226
+ The resolved range is recorded in the manifest.
227
+ missing_below, missing_values
228
+ Sentinel handling (e.g. ``missing_below=0`` turns a −1 off-hemisphere sentinel into missing).
229
+ colorbar, cbar_label, cbar_ticks
230
+ Add a colorbar for continuous maps as its own addressable part. ``cbar_ticks`` pins the tick
231
+ positions (e.g. to keep them at round numbers) instead of leaving them to matplotlib.
232
+ shading, light
233
+ Diffuse shading strength in [0, 1] (0 = flat colour, the default). A flat-coloured mesh shows
234
+ no relief no matter how folded the geometry is — there is no illumination model — so sulci and
235
+ gyri are invisible even on an unsmoothed surface. With ``shading > 0`` each face is darkened by
236
+ ``1 - shading*(1 - n·L)``, where ``n`` is its unit normal and ``L`` the light direction (given
237
+ in the *view* frame: x toward the camera, y right, z up). ~0.4 gives readable folding without
238
+ distorting the colour mapping; the value→hue relation is untouched, only its lightness.
239
+ legend, legend_missing, legend_kw
240
+ Draw a category legend. Defaults to ``True`` for label maps (a categorical map without a key
241
+ is unreadable) and ``False`` for continuous ones (the colorbar *is* the key). The legend is a
242
+ real matplotlib legend, so it is auto-tagged like any other plot's: the manifest gains a
243
+ ``legend`` guide whose entries carry the swatch/label ids and resolve to the addressable
244
+ region part. ``legend_missing=True`` adds an entry for the missing/no-data part;
245
+ ``legend_kw`` overrides placement (default: horizontal, under the map, frameless).
246
+
247
+ Returns the list of matplotlib collections drawn (in draw order).
248
+ """
249
+ from matplotlib.collections import PolyCollection
250
+ from matplotlib.colors import to_rgba
251
+
252
+ from . import tagger as _tagger
253
+ from .descriptors import Mark
254
+
255
+ hemispheres = [h for h in hemispheres if h in surfaces]
256
+ if not hemispheres:
257
+ raise ValueError("no requested hemisphere present in `surfaces`")
258
+
259
+ # ---- per-hemisphere geometry + values -------------------------------------------------
260
+ geom, vals = {}, {}
261
+ offset = 0
262
+ for h in hemispheres:
263
+ v, f = _load_surface(surfaces[h])
264
+ geom[h] = (v, f)
265
+ if isinstance(values, dict):
266
+ hv = np.asarray(values[h], dtype=float)
267
+ else:
268
+ flat = np.asarray(values, dtype=float)
269
+ hv = flat[offset:offset + v.shape[0]]
270
+ offset += v.shape[0]
271
+ if hv.shape[0] != v.shape[0]:
272
+ raise ValueError(
273
+ f"{h}: {hv.shape[0]} values for {v.shape[0]} vertices — a surface map must carry "
274
+ f"exactly one value per vertex")
275
+ vals[h] = _normalise_missing(hv, missing_below, missing_values)
276
+
277
+ all_vals = np.concatenate([vals[h] for h in hemispheres])
278
+ finite = all_vals[np.isfinite(all_vals)]
279
+ # An all-missing map is legitimate, not an error: rendering one hemisphere of a
280
+ # single-hemisphere result gives a view whose every vertex is the sentinel. Draw the grey
281
+ # surface and skip the colour mapping. Only "auto" has to give up, since with no finite value
282
+ # there is nothing from which to infer label-vs-continuous.
283
+ if finite.size == 0:
284
+ if kind == "auto":
285
+ raise ValueError(
286
+ "every vertex is missing and kind='auto' cannot infer label vs continuous — "
287
+ "pass kind= explicitly if an all-missing map is intended")
288
+ warnings.warn("surface(): every vertex is missing; drawing the surface as no-data",
289
+ stacklevel=2)
290
+
291
+ if kind == "auto":
292
+ uniq = np.unique(finite)
293
+ kind = "label" if (uniq.size <= 32 and np.allclose(uniq, np.round(uniq))) else "continuous"
294
+ if kind not in ("label", "continuous"):
295
+ raise ValueError(f"kind must be 'label', 'continuous' or 'auto' (got {kind!r})")
296
+
297
+ # ---- lay the views out side by side in axes coordinates --------------------------------
298
+ panes = [(h, view) for h in hemispheres for view in views]
299
+ spans = []
300
+ for h, view in panes:
301
+ xy, _, _ = _project(geom[h][0], h, view)
302
+ spans.append((np.ptp(xy[:, 0]), np.ptp(xy[:, 1])))
303
+ unit_w = max(s[0] for s in spans)
304
+ unit_h = max(s[1] for s in spans)
305
+ pitch = unit_w * (1.0 + gap)
306
+
307
+ # ---- assemble faces, grouped by the part they belong to --------------------------------
308
+ # parts: name -> list of (polygon vertex arrays); continuous also collects per-face values.
309
+ parts, part_vals, parts_shade = {}, {}, {}
310
+ for i, (h, view) in enumerate(panes):
311
+ verts, faces = geom[h]
312
+ xy, depth, sign_x = _project(verts, h, view)
313
+ xy = xy.copy()
314
+ xy[:, 0] += i * pitch - xy[:, 0].mean()
315
+ xy[:, 1] -= xy[:, 1].mean()
316
+
317
+ front = _front_facing(verts, faces, sign_x)
318
+ vis = faces[front] # only the half facing the camera
319
+ shade_v = _face_shading(verts, vis, sign_x, light, shading) if shading else None
320
+ fv = _face_labels(vals[h], vis) if kind == "label" else _face_values(vals[h], vis)
321
+ order = np.argsort(depth[vis].mean(axis=1)) # painter's algorithm: far → near
322
+ polys = xy[vis[order]]
323
+ fv = fv[order]
324
+ shade_o = shade_v[order] if shade_v is not None else None
325
+
326
+ missing = ~np.isfinite(fv)
327
+ parts.setdefault("missing", []).append(polys[missing])
328
+ if shade_o is not None:
329
+ parts_shade.setdefault("missing", []).append(shade_o[missing])
330
+ if kind == "label":
331
+ for code in np.unique(fv[~missing]):
332
+ name = category_name(code, categories)
333
+ sel = fv == code
334
+ parts.setdefault(name, []).append(polys[sel])
335
+ if shade_o is not None:
336
+ parts_shade.setdefault(name, []).append(shade_o[sel])
337
+ else:
338
+ parts.setdefault("field", []).append(polys[~missing])
339
+ part_vals.setdefault("field", []).append(fv[~missing])
340
+ if shade_o is not None:
341
+ parts_shade.setdefault("field", []).append(shade_o[~missing])
342
+
343
+ reg = _tagger.registry_for(ax.figure)
344
+ drawn = []
345
+
346
+ def _add(name, polys_list, **kw):
347
+ polys = np.concatenate([p for p in polys_list if len(p)]) if any(
348
+ len(p) for p in polys_list) else np.empty((0, 3, 2))
349
+ if polys.shape[0] == 0:
350
+ return None
351
+ # edgecolor="face" + antialiasing is the combination that renders a triangulated field
352
+ # cleanly: each triangle draws its own border in its OWN colour, which both closes the
353
+ # hairline seams between neighbours (the reason one is tempted to disable antialiasing) and
354
+ # keeps the silhouette and any high-contrast internal boundary smooth. With antialiasing off
355
+ # the artefact is invisible on a smooth field but obvious wherever adjacent faces differ
356
+ # sharply — i.e. exactly on categorical maps and steep gradients.
357
+ coll = PolyCollection(list(polys), edgecolor=edgecolor, linewidth=linewidth,
358
+ antialiased=antialiased, **kw)
359
+ ax.add_collection(coll)
360
+ drawn.append(coll)
361
+ return coll
362
+
363
+ # ---- missing data: its own part, never a data value ------------------------------------
364
+ # Missing regions retain the established last-layer convention. This is an
365
+ # approximation for folded meshes, not a claim that all no-data faces are
366
+ # geometrically in front. The manifest records this rendering limitation.
367
+ parts_missing_colour = [None]
368
+ miss_shade = parts_shade.pop("missing", None)
369
+ miss_polys = parts.pop("missing", [])
370
+
371
+ style_common = {
372
+ "views": list(views), "hemispheres": list(hemispheres),
373
+ "missingColor": missing_color,
374
+ "rendering": {"projection": "orthographic", "occlusion": "per-part-painter",
375
+ "crossPartDepth": "convex-only", "missingLayer": "last"},
376
+ "missingRule": {"below": missing_below, "values": [float(m) for m in missing_values],
377
+ "zeroIsData": True},
378
+ }
379
+
380
+ if kind == "label":
381
+ # One collection per category → each block is independently selectable and recolourable.
382
+ resolved = categorical_colors(parts, palette, categories)
383
+ for name in sorted(parts):
384
+ colour = resolved[name]
385
+ # The artist carries the category name as its matplotlib label, so a plain ax.legend()
386
+ # builds a real legend that the scaffold auto-tagger names like any other plot's.
387
+ if shading:
388
+ sh = np.concatenate(parts_shade[name]) if parts_shade.get(name) else None
389
+ fc = _shade_rgba(to_rgba(colour), sh) if sh is not None and sh.size else colour
390
+ else:
391
+ fc = colour
392
+ coll = _add(name, parts[name], facecolor=fc, label=name)
393
+ if coll is None:
394
+ continue
395
+ # NB the Mark's `label` stays the series-level one: a region name is the identity of a
396
+ # PART, and letting it become the series label would make the series masquerade as its
397
+ # own first category (and would hijack the legend↔series join below).
398
+ reg.add(Mark(role="surface-region", series=series, name=name, kind="surface",
399
+ label=label,
400
+ artists=[coll],
401
+ data={"surface": dict(style_common, kind="label", part=name,
402
+ color=colour)}))
403
+ summary = {"kind": "label", "palette": resolved, **style_common}
404
+ else:
405
+ import matplotlib as mpl
406
+ cmap_obj, norm = continuous_mapping(finite, cmap, color_range, percentile)
407
+ vmin, vmax = norm.vmin, norm.vmax
408
+ fv = np.concatenate(part_vals["field"])
409
+ fcol = cmap_obj(norm(fv))
410
+ if shading and parts_shade.get("field"):
411
+ fcol = _shade_rgba(fcol, np.concatenate(parts_shade["field"]))
412
+ coll = _add("field", parts["field"], facecolor=fcol)
413
+ summary = {"kind": "continuous", "cmap": getattr(cmap_obj, "name", str(cmap)),
414
+ "vmin": vmin, "vmax": vmax,
415
+ "percentile": list(percentile) if percentile else None, **style_common}
416
+ if coll is not None:
417
+ # Face colors include geometric shading. A collection scalar array
418
+ # would overwrite them on every draw; the key owns a separate mappable.
419
+ coll.set_cmap(cmap_obj)
420
+ coll.set_norm(norm)
421
+ reg.add(Mark(role="surface-field", series=series, name="field", kind="surface",
422
+ label=label, artists=[coll],
423
+ data={"surface": dict(summary, part="field")}))
424
+ if colorbar and coll is not None:
425
+ mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap_obj)
426
+ mappable.set_array(fv)
427
+ cb = ax.figure.colorbar(mappable, ax=ax, fraction=0.03, pad=0.02)
428
+ # A rasterized ramp avoids seams. The raster draw scope preserves its
429
+ # identity independently of the surface or other embedded images.
430
+ cb.ax._fluxplot_owner_axes = ax
431
+ if cb.solids is not None:
432
+ cb.solids.set_edgecolor("face")
433
+ if cbar_ticks is not None:
434
+ cb.set_ticks(list(cbar_ticks))
435
+ if cbar_label:
436
+ cb.set_label(cbar_label)
437
+ reg.add(Mark(role="surface-colorbar", series=series, name="colorbar", kind="surface",
438
+ artists=[cb.solids] if cb.solids is not None else [],
439
+ data={"surface": {"part": "colorbar", "vmin": vmin, "vmax": vmax,
440
+ "cmap": summary["cmap"], "label": cbar_label,
441
+ "editable": ["vmin", "vmax", "cmap"]}}))
442
+
443
+ # One summary mark carrying the whole value→colour contract, so the mapping round-trips even if
444
+ # a downstream editor only reads the series-level entry.
445
+ reg.add(Mark(role="surface", series=series, kind="surface", label=label, axes=ax,
446
+ data={"surface": dict(summary, nVertices=int(all_vals.size),
447
+ nMissing=int((~np.isfinite(all_vals)).sum()),
448
+ panes=[f"{h}-{v}" for h, v in panes])}))
449
+
450
+ miss_fc = missing_color
451
+ if shading and miss_shade:
452
+ miss_fc = _shade_rgba(to_rgba(missing_color), np.concatenate(miss_shade))
453
+ coll = _add("missing", miss_polys, facecolor=miss_fc)
454
+ if coll is not None:
455
+ parts_missing_colour[0] = missing_color
456
+ reg.add(Mark(role="surface-missing", series=series, name="missing", kind="surface",
457
+ artists=[coll],
458
+ data={"surface": {"part": "missing", "color": missing_color,
459
+ "meaning": "medial wall / non-cortex / sentinel — not a value"}}))
460
+
461
+ # ---- category legend --------------------------------------------------------------------
462
+ # A categorical map without a key is unreadable, so label maps get one by default; a continuous
463
+ # map does not (its colorbar IS the key). This is a real matplotlib legend, so `autotag_scaffold`
464
+ # names it exactly as it does for a line or bar plot: the manifest gains a `legend` guide whose
465
+ # entries carry the swatch/label ids, and each entry resolves to the addressable region part.
466
+ if legend is None:
467
+ legend = (kind == "label")
468
+ if legend:
469
+ from matplotlib.patches import Patch
470
+ handles = [h for h in drawn if h.get_label() and not h.get_label().startswith("_")]
471
+ if legend_missing and parts_missing_colour[0] is not None:
472
+ handles.append(Patch(facecolor=parts_missing_colour[0], label="no data"))
473
+ if handles:
474
+ kw = {"loc": "lower center", "bbox_to_anchor": (0.5, -0.16),
475
+ "ncol": min(len(handles), 4), "frameon": False,
476
+ "handlelength": 1.1, "handleheight": 1.1, "borderpad": 0.0,
477
+ "columnspacing": 1.4, "handletextpad": 0.5}
478
+ kw.update(legend_kw or {})
479
+ ax.legend(handles=handles, **kw)
480
+
481
+ ax.set_xlim(-unit_w * 0.55, (len(panes) - 1) * pitch + unit_w * 0.55) # panes centred on i*pitch
482
+ ax.set_ylim(-unit_h * 0.55, unit_h * 0.55)
483
+ ax.set_aspect("equal")
484
+ ax.set_axis_off()
485
+ if len(drawn) == 0:
486
+ warnings.warn("surface(): nothing was drawn", stacklevel=2)
487
+ return drawn