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.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|