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/mesh3d.py ADDED
@@ -0,0 +1,376 @@
1
+ """Duck-typed mesh input, stable part identity and checked topology correspondence."""
2
+ from __future__ import annotations
3
+ from collections.abc import Mapping
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+ import warnings
7
+ import numpy as np
8
+ from matplotlib.colors import to_hex, to_rgba
9
+ from .scene3d import Scene3D, MeshPart, _number, _positive_int
10
+ from .ids import series_id, slugify
11
+ from .descriptors import Mark
12
+ from .tagger import Registry, registry_for
13
+
14
+
15
+ def load_mesh(mesh):
16
+ """Read arrays, vertices/faces objects, PyVista PolyData, GIFTI, or trimesh files."""
17
+ if isinstance(mesh,MeshPart): v,f=mesh.vertices,mesh.faces
18
+ elif hasattr(mesh,'points') and hasattr(mesh,'faces'):
19
+ v,f=mesh.points,np.asarray(mesh.faces)
20
+ if f.ndim==1:
21
+ def polygons(flat):
22
+ result=[]; offset=0
23
+ while offset<len(flat):
24
+ count=int(flat[offset]); poly=flat[offset+1:offset+1+count]
25
+ if count<3 or count!=flat[offset] or len(poly)!=count:
26
+ raise ValueError('malformed PolyData polygon faces')
27
+ result.append(poly); offset+=count+1
28
+ return result
29
+ faces=polygons(f)
30
+ if any(len(poly)!=3 for poly in faces):
31
+ # A triangle fan is incorrect for concave polygons. Delegate to the
32
+ # adapter's triangulator, which returns a new mesh in PyVista.
33
+ if not callable(getattr(mesh,'triangulate',None)):
34
+ raise ValueError('PolyData polygons must be triangulated; call mesh.triangulate() first')
35
+ triangle_mesh=mesh.triangulate()
36
+ v=triangle_mesh.points; faces=polygons(np.asarray(triangle_mesh.faces))
37
+ if any(len(poly)!=3 for poly in faces):
38
+ raise ValueError('PolyData triangulate() did not produce triangle faces')
39
+ f=faces
40
+ elif hasattr(mesh,'vertices') and hasattr(mesh,'faces'): v,f=mesh.vertices,mesh.faces
41
+ elif isinstance(mesh,(tuple,list)) and len(mesh)==2: v,f=mesh
42
+ elif isinstance(mesh,(str,Path)):
43
+ if str(mesh).lower().endswith('.gii'):
44
+ from .surface import _load_surface
45
+ v,f=_load_surface(mesh)
46
+ else:
47
+ try: import trimesh
48
+ except ImportError as exc: raise ImportError('mesh file input needs fluxplot[mesh] or trimesh; arrays need no optional package') from exc
49
+ loaded=trimesh.load(str(mesh),force='mesh',process=False)
50
+ if not hasattr(loaded,'vertices'): raise ValueError(f'no triangle mesh in {mesh}')
51
+ v,f=loaded.vertices,loaded.faces
52
+ elif _is_trimesh_scene(mesh):
53
+ raise TypeError('a trimesh.Scene holds several meshes; pass it as the whole mesh argument '
54
+ 'of fp.mesh3d (one named part per scene node), not as a single shape')
55
+ else: raise TypeError('mesh must be (vertices, faces), a mesh object, or a mesh file path')
56
+ v=np.asarray(v,dtype=float)
57
+ raw=np.asarray(f)
58
+ if v.ndim!=2 or v.shape[1]!=3 or not len(v) or not np.isfinite(v).all(): raise ValueError('vertices must be a nonempty finite N×3 array')
59
+ if raw.ndim!=2 or raw.shape[1]!=3 or not len(raw): raise ValueError('faces must be a nonempty M×3 triangle array')
60
+ if not np.issubdtype(raw.dtype,np.integer) and (not np.isfinite(raw).all() or not np.equal(raw,np.floor(raw)).all()): raise ValueError('face indices must be integers')
61
+ if raw.min()<0 or raw.max()>=len(v): raise ValueError('face index outside vertex array')
62
+ return v.copy(),raw.astype(np.int64,copy=True)
63
+
64
+
65
+ def _is_trimesh_scene(obj):
66
+ graph = getattr(obj, 'graph', None)
67
+ return isinstance(getattr(obj, 'geometry', None), Mapping) and hasattr(graph, 'nodes_geometry')
68
+
69
+
70
+ def _trimesh_scene_parts(scene):
71
+ """``{node name: (vertices, faces)}`` with each node's scene transform applied."""
72
+ parts = {}
73
+ for node in scene.graph.nodes_geometry:
74
+ transform, geometry_name = scene.graph[node]
75
+ geometry = scene.geometry[geometry_name]
76
+ if not (hasattr(geometry, 'vertices') and hasattr(geometry, 'faces')) or not len(geometry.faces):
77
+ continue # point clouds and paths have no triangles to draw
78
+ transform = np.asarray(transform, dtype=float)
79
+ vertices = np.asarray(geometry.vertices, dtype=float) @ transform[:3, :3].T + transform[:3, 3]
80
+ parts[str(node)] = (vertices, np.asarray(geometry.faces))
81
+ if not parts:
82
+ raise ValueError('the trimesh.Scene contains no triangle meshes')
83
+ return parts
84
+
85
+
86
+ def _named_meshes(mesh):
87
+ """Return ``(meshes, named)``: a mapping of part name to mesh spec."""
88
+ if isinstance(mesh, Mapping):
89
+ return dict(mesh), True
90
+ if _is_trimesh_scene(mesh):
91
+ return _trimesh_scene_parts(mesh), True
92
+ return {'mesh': mesh}, False
93
+
94
+
95
+ def _states(states, sequence):
96
+ if states is None: return {}
97
+ if isinstance(states,Mapping):
98
+ result={str(k):v for k,v in states.items()}
99
+ if any(not k for k in result) or len(result)!=len(states): raise ValueError('state names must be nonempty and unique')
100
+ return result
101
+ if not sequence: raise ValueError('a list of states requires sequence=True (or pass a dict of named states)')
102
+ return {f'frame-{i+1}':v for i,v in enumerate(states)}
103
+
104
+
105
+ def _is_bare_vertices(shape):
106
+ """True for an N×3 vertex array; a 2-item tuple/list is a ``(vertices, faces)`` pair."""
107
+ if isinstance(shape, (tuple, list)) and len(shape) == 2:
108
+ return False
109
+ if not isinstance(shape, (np.ndarray, tuple, list)):
110
+ return False
111
+ try:
112
+ array = np.asarray(shape, dtype=float)
113
+ except (TypeError, ValueError):
114
+ return False
115
+ return array.ndim == 2 and array.shape[1] == 3
116
+
117
+
118
+ def _state_for_part(shape, state, part, named):
119
+ """Pick one part's shape out of a named-part state mapping, with a clear error."""
120
+ if not (named and isinstance(shape, Mapping)):
121
+ return shape
122
+ if part not in shape:
123
+ have = ', '.join(map(str, shape)) or 'no parts'
124
+ raise ValueError(f'shape state {state!r} has no shape for part {part!r} (it has {have}); '
125
+ 'give every named part a shape in each state')
126
+ return shape[part]
127
+
128
+
129
+ def _state_vertices(shape, vertices, faces, state, part_id):
130
+ """Vertices of one shape state. Bare N×3 arrays reuse the base faces."""
131
+ if _is_bare_vertices(shape):
132
+ state_vertices = np.asarray(shape, dtype=float)
133
+ if not np.isfinite(state_vertices).all():
134
+ raise ValueError(f'shape state {state!r} of {part_id}: vertices must be finite')
135
+ state_faces = faces
136
+ else:
137
+ state_vertices, state_faces = load_mesh(shape)
138
+ if len(state_vertices) != len(vertices):
139
+ raise ValueError(f'shape state {state!r} of {part_id} has {len(state_vertices):,} vertices but its '
140
+ f'base mesh has {len(vertices):,}; each state must be the same mesh moved '
141
+ '(same vertex count and faces), shared topology required')
142
+ if state_faces.shape != faces.shape or not np.array_equal(state_faces, faces):
143
+ raise ValueError(f'shape state {state!r} of {part_id} has the base vertex count but different '
144
+ 'face indices; shared topology required (pass just its N×3 vertex array to reuse '
145
+ 'the base faces)')
146
+ return state_vertices
147
+
148
+
149
+ def _same_topology(vertices, faces, ref_vertices, ref_faces, label):
150
+ if len(vertices)!=len(ref_vertices):
151
+ raise ValueError(f'{label}: this mesh has {len(vertices):,} vertices but the reference has '
152
+ f'{len(ref_vertices):,}; shared topology required')
153
+ if faces.shape!=ref_faces.shape or not np.array_equal(faces,ref_faces):
154
+ raise ValueError(f'{label}: same vertex count as the reference but the face indices differ; '
155
+ 'shared topology required')
156
+
157
+
158
+ def _match_part(parts, part_id, what):
159
+ found = next((p for p in parts if p.id == part_id), None)
160
+ if found is None:
161
+ have = ', '.join(p.id for p in parts) or 'no parts'
162
+ raise ValueError(f'{part_id}: the {what} has no part with this id (it has {have}); use the '
163
+ 'same series= and part names as the reference so Flux can pair them by name')
164
+ return found
165
+
166
+
167
+ def _reference(ref, part_id, *, single):
168
+ """The reference MeshPart (or raw mesh) that ``part_id`` must share topology with."""
169
+ if ref is None: return None
170
+ if isinstance(ref, Scene3D):
171
+ return _match_part(ref.parts, part_id, 'reference scene')
172
+ if isinstance(ref, MeshPart): return ref
173
+ if isinstance(ref, (list, tuple)) and ref and all(isinstance(p, MeshPart) for p in ref):
174
+ # The list returned by fp.mesh3d / fp.surface3d; pair by part id.
175
+ return _match_part(ref, part_id, 'reference part list')
176
+ if not single and isinstance(ref, Mapping):
177
+ for key,value in ref.items():
178
+ if part_id.endswith('.'+slugify(key)): return value
179
+ raise ValueError(f'{part_id}: reference mapping has no matching part')
180
+ return ref
181
+
182
+
183
+ def _prepare_part(part_id, mesh, color, states=None, *, reference=None, max_faces=None,
184
+ values=None, colors=None):
185
+ v,f=load_mesh(mesh)
186
+ state_arrays={name:_state_vertices(shape,v,f,name,part_id) for name,shape in (states or {}).items()}
187
+ if reference is not None:
188
+ if isinstance(reference,MeshPart):
189
+ if reference.source_count!=len(v):
190
+ raise ValueError(f'{part_id}: this mesh has {len(v):,} vertices but its reference '
191
+ f'{reference.id} has {reference.source_count:,}; share_topology_with '
192
+ 'needs the same mesh topology')
193
+ if not np.array_equal(reference.source_faces,f):
194
+ raise ValueError(f'{part_id}: same vertex count as its reference {reference.id} but the '
195
+ 'face indices differ; share_topology_with needs identical faces')
196
+ else:
197
+ rv,rf=load_mesh(reference); _same_topology(v,f,rv,rf,part_id)
198
+ used=np.unique(f)
199
+ faces=np.searchsorted(used,f)
200
+ part=MeshPart(part_id,v[used],faces,to_hex(color,keep_alpha=to_rgba(color)[3]!=1),
201
+ {name:a[used] for name,a in state_arrays.items()},
202
+ None if values is None else np.asarray(values,dtype=float)[used].copy(),
203
+ None if colors is None else np.asarray(colors,dtype=float)[used].copy(),
204
+ source_faces=f,source_count=len(v),source_indices=used,compact_faces=faces.copy())
205
+ if isinstance(reference,MeshPart) and reference.collapses is not None:
206
+ from ._mesh_reduce import reduce_part
207
+ reduce_part(part,collapses=reference.collapses)
208
+ elif max_faces is not None and len(part.faces)>max_faces:
209
+ if reference is not None: raise ValueError('shared reference was not decimated; apply max_faces to the reference first')
210
+ from ._mesh_reduce import reduce_part
211
+ reduce_part(part,max_faces=max_faces)
212
+ if isinstance(reference,MeshPart): _same_topology(part.vertices,part.faces,reference.vertices,reference.faces,part_id)
213
+ return part
214
+
215
+
216
+ def _publish(scene, parts, descriptors, alloc, *, legend=False, sequence=False, morph_group=None):
217
+ reg=registry_for(scene)
218
+ candidate=Registry(); candidate.marks=list(reg.marks); candidate._series_slugs=dict(reg._series_slugs)
219
+ for mark in descriptors: candidate.add(mark)
220
+ if sequence and scene.parts and not scene.sequence: raise ValueError('cannot mix named states and a sequence in one scene')
221
+ if scene.sequence or sequence:
222
+ existing=scene.state_names
223
+ for p in parts:
224
+ if existing and list(p.states)!=existing: raise ValueError('all sequence parts must have the same ordered frame names')
225
+ existing=list(p.states)
226
+ if morph_group is not None and scene.morph_group not in (None,str(morph_group)): raise ValueError('one scene cannot belong to two morph groups')
227
+ reg.marks=candidate.marks; reg._series_slugs=candidate._series_slugs
228
+ scene.parts.extend(parts); scene._alloc=alloc
229
+ scene._max_call_parts=max(scene._max_call_parts,len(parts))
230
+ if legend: scene._legend_entries.extend(p.id for p in parts)
231
+ scene.sequence=scene.sequence or sequence
232
+ if morph_group is not None: scene.morph_group=str(morph_group)
233
+ scene._resolve_pending_view(strict=False)
234
+ return parts
235
+
236
+
237
+ def _check_max_faces(max_faces):
238
+ return None if max_faces is None else _positive_int(max_faces, 'max_faces')
239
+
240
+
241
+ def _check_alpha(alpha):
242
+ return None if alpha is None else _number(alpha, 'alpha', 0, 1)
243
+
244
+
245
+ def _with_alpha(color, alpha):
246
+ return color if alpha is None else to_rgba(color, alpha)
247
+
248
+
249
+ def _warn_unknown_palette(palette, known, *, hint=''):
250
+ if not palette:
251
+ return
252
+ unknown = [key for key in palette if key not in known]
253
+ if unknown:
254
+ names = ', '.join(repr(k) for k in known) or 'none'
255
+ warnings.warn(f'palette keys {unknown!r} match no part and are ignored (parts: {names}){hint}',
256
+ stacklevel=3)
257
+
258
+
259
+ def mesh3d(scene, mesh, *, series, color=None, palette=None, alpha=None, legend=False, label=None,
260
+ states=None, sequence=False, share_topology_with=None, morph_group=None, max_faces=None):
261
+ """Add one mesh, or a named mapping of meshes, as addressable parts of a 3D scene.
262
+
263
+ Parameters
264
+ ----------
265
+ scene
266
+ The :func:`fp.scene3d <fluxplot.scene3d>` to add to.
267
+ mesh
268
+ One mesh, or ``{name: mesh}`` for named parts (``"soma"``, ``"axon"``, …), each
269
+ of which Flux can recolour, fade or hide separately. A mesh is a
270
+ ``(vertices, faces)`` pair, an object with ``.vertices``/``.faces`` (trimesh,
271
+ meshparty, cloudvolume), PyVista ``PolyData``, a GIFTI path (needs ``nibabel``)
272
+ or a ``.ply/.obj/.stl/.off`` path (needs ``trimesh``). A ``trimesh.Scene``
273
+ becomes one named part per node.
274
+ series
275
+ Name of this series; parts are ``<series>.mesh`` or ``<series>.<name>``. Ids are
276
+ stable across reruns, so edits made in Flux survive regeneration.
277
+ color, palette
278
+ ``color`` colours every part; ``palette={name: color}`` colours named parts. Parts
279
+ without either take successive colours of the house cycle, so a legend can tell
280
+ them apart.
281
+ alpha
282
+ Opacity in [0, 1] for every part of this call (overrides any alpha in the
283
+ colours). Flux can still change it per part.
284
+ legend
285
+ Add these parts to the scene legend.
286
+ label
287
+ Display label. For a single mesh it labels the part (e.g. in the legend). For a
288
+ named mapping it names the whole series, as in :func:`fp.surface
289
+ <fluxplot.surface>`; each part keeps its mapping key as its label.
290
+ states
291
+ Other shapes of the same mesh, shown as weight sliders in Flux:
292
+ ``{"inflated": inflated}``. A state is the base mesh moved, so it may be a bare
293
+ N×3 vertex array or a mesh with identical faces (checked). For named parts give
294
+ ``{state: {part: shape}}``.
295
+ sequence
296
+ With a list of states (``states=frames[1:]``), mark a time sequence: frame 0 is
297
+ this mesh and each state is the next frame (``sc.view(frame=3)``).
298
+ share_topology_with
299
+ A reference scene, the list of parts a previous ``mesh3d`` call returned, or a
300
+ mesh with the same faces. The faces are checked, and a decimated reference's
301
+ exact collapses are replayed, so the two stay morphable in Flux. Build the
302
+ reference first (with any ``max_faces``), use the same ``series`` and part
303
+ names, then pass it here.
304
+ morph_group
305
+ A label (e.g. ``"cortex"``) suggesting morph partners in Flux.
306
+ max_faces
307
+ Optional triangle budget for this call, apportioned over its parts (needs the
308
+ ``fluxplot[mesh]`` extra). Save warnings recommend a value for large scenes.
309
+
310
+ Returns
311
+ -------
312
+ list of MeshPart
313
+ The new parts, in order.
314
+
315
+ Examples
316
+ --------
317
+ >>> sc = fp.scene3d(units="nm", scalebar=10_000)
318
+ >>> fp.mesh3d(sc, {"soma": soma, "axon": axon}, series="neuron", legend=True,
319
+ ... palette={"soma": fp.colors.red400, "axon": fp.colors.blue400})
320
+ >>> fp.mesh3d(sc, pial, series="cortex", states={"inflated": inflated_vertices})
321
+ """
322
+ import matplotlib as mpl
323
+ if not isinstance(scene,Scene3D): raise TypeError('first argument must be fp.scene3d()')
324
+ max_faces=_check_max_faces(max_faces)
325
+ alpha=_check_alpha(alpha)
326
+ meshes,named=_named_meshes(mesh)
327
+ if not meshes: raise ValueError('mesh mapping is empty')
328
+ if max_faces is not None and max_faces<len(meshes):
329
+ raise ValueError('max_faces must allow at least one triangle per named part')
330
+ _warn_unknown_palette(palette, list(meshes))
331
+ shapes=_states(states,sequence)
332
+ cycle=mpl.rcParams['axes.prop_cycle'].by_key()['color']
333
+ alloc=deepcopy(scene._alloc); parts=[]; marks=[]
334
+ loaded={name:load_mesh(spec) for name,spec in meshes.items()}
335
+ from ._mesh_reduce import face_budgets
336
+ budgets=face_budgets([len(f) for v,f in loaded.values()],max_faces)
337
+ for index,((name,(v,f)),budget) in enumerate(zip(loaded.items(),budgets)):
338
+ pid=alloc.take(series_id(series,slugify(name)))
339
+ # Each uncoloured part advances the house cycle, deterministically by scene order.
340
+ default=cycle[(len(scene.parts)+index)%len(cycle)]
341
+ pcolor=_with_alpha((palette or {}).get(name,color or default),alpha)
342
+ partstates={state:_state_for_part(shape,state,name,named) for state,shape in shapes.items()}
343
+ ref=_reference(share_topology_with,pid,single=not named)
344
+ part=_prepare_part(pid,(v,f),pcolor,partstates,reference=ref,max_faces=budget)
345
+ parts.append(part)
346
+ part_label=str(name) if named or label is None else str(label)
347
+ series_label=part_label if label is None else str(label)
348
+ spec={'id':pid,'role':'mesh','kind':'mesh','node':pid,'series':str(series),'label':part_label,'color':part.color}
349
+ marks.append(Mark(role='mesh',series=str(series),name=str(name),kind='mesh',label=series_label,gid=pid,
350
+ data={'scene3d':spec}))
351
+ return _publish(scene,parts,marks,alloc,legend=legend,sequence=sequence,morph_group=morph_group)
352
+
353
+
354
+ class MorphResult(dict):
355
+ """Mapping with ``ok``, ``pairs`` and optional ``reason``; truth tests use ``ok``."""
356
+ def __bool__(self): return self['ok']
357
+
358
+
359
+ def can_morph(a,b):
360
+ """Check ordered faces and vertex counts, pairing named parts as Flux does."""
361
+ def parts(value):
362
+ if isinstance(value,Scene3D): return value.parts
363
+ if isinstance(value,MeshPart): return [value]
364
+ if isinstance(value,list) and value and isinstance(value[0],MeshPart): return value
365
+ v,f=load_mesh(value); used=np.unique(f)
366
+ return [MeshPart('',v[used],np.searchsorted(used,f),'#4385BE')]
367
+ aa,bb=parts(a),parts(b)
368
+ if len(aa)!=len(bb): return MorphResult(ok=False,pairs=[],reason=f'{len(aa)} vs {len(bb)} parts')
369
+ byname=all(p.id for p in aa+bb); lookup={p.id:p for p in bb}; pairs=[]
370
+ for i,ap in enumerate(aa):
371
+ bp=lookup.get(ap.id) if byname else bb[i]
372
+ if bp is None: return MorphResult(ok=False,pairs=[],reason=f'{ap.id}: missing partner')
373
+ if len(ap.vertices)!=len(bp.vertices): return MorphResult(ok=False,pairs=[],reason=f'{ap.id or i}: {len(ap.vertices)} vs {len(bp.vertices)} vertices')
374
+ if not np.array_equal(ap.faces,bp.faces): return MorphResult(ok=False,pairs=[],reason=f'{ap.id or i}: face indices differ')
375
+ pairs.append({'nodeA':ap.id,'nodeB':bp.id})
376
+ return MorphResult(ok=True,pairs=pairs)
fluxplot/panels.py ADDED
@@ -0,0 +1,284 @@
1
+ """Panel ownership and ID namespacing, shared by every save pipeline stage."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field, replace
4
+ from types import SimpleNamespace
5
+ from . import ids
6
+ from .autotag import is_colorbar_axes
7
+
8
+
9
+ def all_axes(fig):
10
+ out, seen = [], set()
11
+ def visit(ax):
12
+ if id(ax) in seen:
13
+ return
14
+ seen.add(id(ax)); out.append(ax)
15
+ for child in ax.child_axes:
16
+ visit(child)
17
+ for ax in fig.axes:
18
+ visit(ax)
19
+ return out
20
+
21
+
22
+ def panel(ax, name):
23
+ """Give an axes a durable panel identity, independent of subplot layout/order."""
24
+ name = str(name).strip()
25
+ if not name:
26
+ raise ValueError('panel name cannot be empty')
27
+ slug = ids.slugify(name)
28
+ for other in all_axes(ax.figure):
29
+ old = getattr(other, '_fluxplot_panel_name', None)
30
+ if other is not ax and old is not None and ids.slugify(old) == slug:
31
+ raise ValueError(f'panel name {name!r} collides with {old!r}')
32
+ ax._fluxplot_panel_name = name
33
+ return ax
34
+
35
+
36
+ def _letters(n):
37
+ out = ''
38
+ while n >= 0:
39
+ out = chr(97 + n % 26) + out
40
+ n = n // 26 - 1
41
+ return out
42
+
43
+
44
+ @dataclass
45
+ class Panel:
46
+ axes: object
47
+ id: str | None
48
+ label: str
49
+ index: int
50
+ #: secondary axes sharing this panel's frame: ``[(axes, "y2" | "x2")]`` (twinx / twiny)
51
+ twins: list = field(default_factory=list)
52
+ #: the panel this one is an inset of (``ax.inset_axes``), or ``None``
53
+ parent: object = None
54
+
55
+ @property
56
+ def all_axes(self):
57
+ return [self.axes] + [t for t, _ in self.twins]
58
+
59
+ @property
60
+ def prefix(self):
61
+ return self.id + '.' if self.id else ''
62
+
63
+ @property
64
+ def svg_id(self):
65
+ return self.prefix + 'plot-area'
66
+
67
+
68
+ class ScopedAllocator:
69
+ def __init__(self, allocator, prefix):
70
+ self.allocator, self.prefix = allocator, prefix
71
+
72
+ def take(self, candidate):
73
+ return self.allocator.take(candidate if self.prefix and candidate.startswith(self.prefix) else self.prefix + candidate)
74
+
75
+
76
+ def is_secondary_axis(ax) -> bool:
77
+ from matplotlib.axes._secondary_axes import SecondaryAxis
78
+ return isinstance(ax, SecondaryAxis)
79
+
80
+
81
+ def twin_of(primary, other):
82
+ """``"y2"`` when ``other`` is ``primary``'s twinx (same frame, shared x), ``"x2"`` for a
83
+ twiny — or for a ``secondary_xaxis`` child (``"y2"`` for ``secondary_yaxis``) — else ``None``."""
84
+ if is_secondary_axis(other):
85
+ if other in primary.child_axes:
86
+ return 'x2' if getattr(other, '_orientation', 'x') == 'x' else 'y2'
87
+ return None
88
+ pa, pb = primary.get_position().bounds, other.get_position().bounds
89
+ if any(abs(u - v) > 1e-9 for u, v in zip(pa, pb)):
90
+ return None
91
+ if primary.get_shared_x_axes().joined(primary, other):
92
+ return 'y2'
93
+ if primary.get_shared_y_axes().joined(primary, other):
94
+ return 'x2'
95
+ return None
96
+
97
+
98
+ def plan(fig):
99
+ candidates = [ax for ax in all_axes(fig) if not is_colorbar_axes(ax)]
100
+ # a twin (ax.twinx() / twiny()) is the same panel seen through a second value axis, not a
101
+ # panel of its own: it joins the axes it was made from (created earlier, so met first)
102
+ axes, twins = [], {}
103
+ for ax in candidates:
104
+ named = getattr(ax, '_fluxplot_panel_name', None) is not None # fp.panel(twin, …): its own panel
105
+ pair = None if named else next(((p, w) for p in axes if (w := twin_of(p, ax)) is not None), None)
106
+ if pair is not None:
107
+ twins.setdefault(id(pair[0]), []).append((ax, pair[1]))
108
+ else:
109
+ axes.append(ax)
110
+ axes.sort(key=lambda a: (-round(a.get_position().y1, 8), round(a.get_position().x0, 8)))
111
+ multi = len(axes) > 1 or any(getattr(a, '_fluxplot_panel_name', None) for a in axes)
112
+ reserved = {ids.slugify(a._fluxplot_panel_name) for a in axes if getattr(a, '_fluxplot_panel_name', None)}
113
+ result, n = [], 0
114
+ for index, ax in enumerate(axes):
115
+ label = getattr(ax, '_fluxplot_panel_name', None)
116
+ if label is None:
117
+ while _letters(n) in reserved:
118
+ n += 1
119
+ label = _letters(n); n += 1
120
+ result.append(Panel(ax, 'panel.' + ids.slugify(label) if multi else None, label, index,
121
+ twins=twins.get(id(ax), [])))
122
+ by_axes = {id(p.axes): p for p in result}
123
+ for pnl in result: # an inset axes is a panel inside another panel
124
+ for host in axes:
125
+ if host is not pnl.axes and pnl.axes in host.child_axes and id(host) in by_axes:
126
+ pnl.parent = by_axes[id(host)]
127
+ return result
128
+
129
+
130
+ def local_mark(mark, prefix):
131
+ def local(gid):
132
+ return gid[len(prefix):] if gid and prefix and gid.startswith(prefix) else gid
133
+ data = dict(mark.data)
134
+ if data.get('field_members'):
135
+ data['field_members'] = [local(g) for g in data['field_members']]
136
+ if data.get('label_gid'):
137
+ data['label_gid'] = local(data['label_gid'])
138
+ if data.get('hexmatrix', {}).get('bins'):
139
+ payload = dict(data['hexmatrix'])
140
+ payload['bins'] = [{**b, 'svgId': local(b['svgId'])} if b.get('svgId') else b for b in payload['bins']]
141
+ data['hexmatrix'] = payload
142
+ return replace(mark, gid=local(mark.gid), member_gids=[local(g) for g in mark.member_gids], data=data)
143
+
144
+
145
+ def namespace(man, prefix):
146
+ """Prefix semantic references only; data, display names, roles and paths stay literal."""
147
+ if not prefix:
148
+ return man
149
+ def ref(value):
150
+ return prefix + value
151
+ def walk(node):
152
+ for key in ('id', 'ref', 'svgId'):
153
+ if node.get(key):
154
+ node[key] = ref(node[key])
155
+ if 'members' in node:
156
+ node['members'] = [ref(v) for v in node['members']]
157
+ for c in node.get('children', []):
158
+ walk(c)
159
+ for axis in man['axes']:
160
+ axis['id'], axis['svgId'] = ref(axis['id']), ref(axis['svgId'])
161
+ for s in man['series']:
162
+ s['id'] = ref(s['id'])
163
+ s['svg'] = {k: [ref(g) for g in v] if isinstance(v, list) else ref(v) for k, v in s['svg'].items()}
164
+ for c in s.get('components', []):
165
+ walk(c)
166
+ for p in s.get('points', []):
167
+ p['svgId'] = ref(p['svgId'])
168
+ for part in s.get('surface', {}).get('parts', []):
169
+ if part.get('ref'): part['ref'] = ref(part['ref'])
170
+ for b in s.get('hexmatrix', {}).get('bins', []):
171
+ if b.get('svgId'): b['svgId'] = ref(b['svgId'])
172
+ for g in man.get('guides', []):
173
+ walk(g)
174
+ if g.get('mappable'): g['mappable'] = ref(g['mappable'])
175
+ for part in g.get('parts', []): walk(part)
176
+ for entry in g.get('entries', []):
177
+ for key in ('series', 'part', 'swatch', 'label'):
178
+ if entry.get(key): entry[key] = ref(entry[key])
179
+ for o in man.get('overlays', []):
180
+ walk(o)
181
+ for sc in man.get('colorScales', []):
182
+ # the scale id is the recipe key and stays literal; what it colours is namespaced
183
+ sc['mappables'] = [ref(v) for v in sc['mappables']]
184
+ sc['colorbars'] = [ref(v) for v in sc['colorbars']]
185
+ walk(man['parts'])
186
+ if man.get('idAliases'):
187
+ man['idAliases'] = {ref(k): ref(v) for k, v in man['idAliases'].items()}
188
+ # The one legacy role token has figure-wide meaning and is retained once.
189
+ man['build']['order'] = [ref(v) if v != 'gridlines' else v for v in man['build']['order']]
190
+ return man
191
+
192
+
193
+ def manifest(fig, reg, guides_by_panel, panels, axes_capture, present, rasterized, extra_scales_by_panel=None,
194
+ figure_guides=(), **kwargs):
195
+ from .manifest import figure_scope
196
+ out = _panel_manifest(fig, reg, guides_by_panel, panels, axes_capture, present, rasterized,
197
+ extra_scales_by_panel, **kwargs)
198
+ if figure_guides:
199
+ block, guide_entries, overlay_entries, children, first, last, aliases = figure_scope(figure_guides, reg, present, rasterized)
200
+ if block:
201
+ out['figure'] = block
202
+ out['guides'] = out['guides'] + guide_entries
203
+ out['overlays'] = out['overlays'] + overlay_entries
204
+ ground = [c for c in children if c.get('role') == 'background']
205
+ rest = [c for c in children if c.get('role') != 'background']
206
+ out['parts']['children'] = ground + out['parts']['children'] + rest
207
+ if aliases:
208
+ out['idAliases'] = {**(out.get('idAliases') or {}), **aliases}
209
+ order = out['build']['order']
210
+ # figure titles reveal with the axes (phase 0, first); figure annotations and extras last
211
+ out['build']['order'] = [v for v in first if v not in order] + order + [v for v in last if v not in order]
212
+ return out
213
+
214
+
215
+ def _panel_manifest(fig, reg, guides_by_panel, panels, axes_capture, present, rasterized, extra_scales_by_panel=None, **kwargs):
216
+ from .manifest import build_manifest
217
+ documents = []
218
+ extra_scales_by_panel = extra_scales_by_panel or [[] for _ in panels]
219
+ for panel, guides, capture, extra in zip(panels, guides_by_panel, axes_capture, extra_scales_by_panel):
220
+ prefix = panel.prefix
221
+ marks = [local_mark(m, prefix) for m in reg.marks if getattr(m, '_panel_axes', m.axes) is panel.axes]
222
+ local_scales = [{**sc, 'gid': sc['gid'][len(prefix):] if prefix and sc['gid'].startswith(prefix) else sc['gid']}
223
+ for sc in extra]
224
+ local_guides = []
225
+ for g in guides:
226
+ def local(v):
227
+ return v[len(prefix):] if v and prefix and v.startswith(prefix) else v
228
+ gd = dict(g.data)
229
+ if 'mappable' in gd: gd['mappable'] = local(gd['mappable'])
230
+ if 'parts' in gd:
231
+ gd['parts'] = [{**v, 'svgId': local(v['svgId'])} for v in gd['parts']]
232
+ local_guides.append(replace(g, gid=local(g.gid), data=gd))
233
+ kept = {g[len(prefix):] if prefix else g for g in present if not prefix or g.startswith(prefix)}
234
+ rasters = {g[len(prefix):] if prefix else g for g in rasterized if not prefix or g.startswith(prefix)}
235
+ doc = build_manifest(fig, SimpleNamespace(marks=marks), local_guides, [capture],
236
+ present=kept, rasterized=rasters, extra_scales=local_scales, **kwargs)
237
+ namespace(doc, prefix)
238
+ if panel.id:
239
+ for s in doc['series']:
240
+ s['panelId'] = panel.id
241
+ doc['axes'][0]['panelId'] = panel.id
242
+ documents.append(doc)
243
+ if not documents:
244
+ return build_manifest(fig, reg, [], [], present=present, rasterized=rasterized, **kwargs)
245
+ out = documents[0]
246
+ if panels[0].id is None:
247
+ return out
248
+ parts, descriptions = [], []
249
+ for p, d in zip(panels, documents):
250
+ parts.append({'id': p.id, 'role': 'panel', 'label': p.label, 'kind': 'container',
251
+ 'children': d['parts']['children']})
252
+ desc = {'id': p.id, 'svgId': p.svg_id, 'label': p.label, 'index': p.index}
253
+ if p.parent is not None and p.parent.id:
254
+ desc['insetOf'] = p.parent.id
255
+ descriptions.append(desc)
256
+ out['panels'] = descriptions
257
+ out['parts'] = {'id': 'figure', 'role': 'figure', 'kind': 'container', 'children': parts}
258
+ for key in ('axes', 'series', 'guides', 'overlays'):
259
+ out[key] = [entry for d in documents for entry in d[key]]
260
+ # one colour scale may span panels (fp.color_scale): merge by id, union what it colours
261
+ merged: dict = {}
262
+ for d in documents:
263
+ for sc in d.get('colorScales', []):
264
+ have = merged.get(sc['id'])
265
+ if have is None:
266
+ merged[sc['id']] = sc
267
+ continue
268
+ have['mappables'] += [v for v in sc['mappables'] if v not in have['mappables']]
269
+ have['colorbars'] += [v for v in sc['colorbars'] if v not in have['colorbars']]
270
+ have['label'] = have.get('label') or sc.get('label')
271
+ if sc['recolor'] != 'live':
272
+ have['recolor'] = sc['recolor']
273
+ if merged:
274
+ out['colorScales'] = list(merged.values())
275
+ else:
276
+ out.pop('colorScales', None)
277
+ aliases = {k: v for d in documents for k, v in (d.get('idAliases') or {}).items()}
278
+ if aliases:
279
+ out['idAliases'] = aliases
280
+ else:
281
+ out.pop('idAliases', None)
282
+ out['build'] = {'order': list(dict.fromkeys(v for d in documents for v in d['build']['order'])),
283
+ 'presets': {k: v for d in documents for k, v in d['build']['presets'].items()}}
284
+ return out