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/scene3d.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Mesh scenes sharing FluxPlot's semantic registry, styles and save contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import math
|
|
7
|
+
import numbers
|
|
8
|
+
import warnings
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .ids import IdAllocator
|
|
12
|
+
from .tagger import registry_for
|
|
13
|
+
|
|
14
|
+
SCENE3D_SPEC_VERSION = '0.1.0'
|
|
15
|
+
|
|
16
|
+
#: Default notebook preview budget. Only the private notebook copy is simplified
|
|
17
|
+
#: (with the optional ``fluxplot[mesh]`` extra); ``fp.save`` keeps full resolution.
|
|
18
|
+
PREVIEW_MAX_FACES = 100_000
|
|
19
|
+
|
|
20
|
+
_VIEW_RANGES = {'elevation': (-90, 90), 'zoom': (.02, 50), 'fov': (5, 120)}
|
|
21
|
+
_VIEW_NUMBERS = ('azimuth', 'elevation', 'roll', 'zoom', 'panX', 'panY', 'fov')
|
|
22
|
+
_VIEW_KEYS = (*_VIEW_NUMBERS, 'projection', 'states', 'frame')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def up_matrix(up):
|
|
26
|
+
"""Proper rotation from a signed data-up axis to glTF +Y; no winding reflection."""
|
|
27
|
+
matrices = {
|
|
28
|
+
'y': [[1,0,0],[0,1,0],[0,0,1]],
|
|
29
|
+
'-y': [[1,0,0],[0,-1,0],[0,0,-1]],
|
|
30
|
+
'z': [[1,0,0],[0,0,1],[0,-1,0]],
|
|
31
|
+
'-z': [[1,0,0],[0,0,-1],[0,1,0]],
|
|
32
|
+
'x': [[0,-1,0],[1,0,0],[0,0,1]],
|
|
33
|
+
'-x': [[0,1,0],[-1,0,0],[0,0,1]],
|
|
34
|
+
}
|
|
35
|
+
if up not in matrices:
|
|
36
|
+
raise ValueError("up must be 'x', '-x', 'y', '-y', 'z', or '-z'")
|
|
37
|
+
result = np.eye(4); result[:3,:3] = matrices[up]
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _number(value, name, lo=None, hi=None):
|
|
42
|
+
try: value = float(value)
|
|
43
|
+
except (TypeError, ValueError): raise ValueError(f'{name} must be a finite number') from None
|
|
44
|
+
if not math.isfinite(value) or (lo is not None and value < lo) or (hi is not None and value > hi):
|
|
45
|
+
raise ValueError(f'{name} must be finite' + (f' in [{lo}, {hi}]' if lo is not None else ''))
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _positive_int(value, name):
|
|
50
|
+
"""Accept Python and numpy integers (not bools, not floats) that are at least one."""
|
|
51
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Integral) or value < 1:
|
|
52
|
+
raise ValueError(f'{name} must be a positive integer (got {value!r})')
|
|
53
|
+
return int(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clean_float(value):
|
|
57
|
+
"""Round away binary noise from computed values (0.30000000000000004 -> 0.3)."""
|
|
58
|
+
value = float(f'{float(value):.12g}')
|
|
59
|
+
return 0.0 if value == 0 else value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Metric length ladder for scale-bar labels; 'um' and 'micron' are accepted spellings.
|
|
63
|
+
_SI_LENGTHS = {'pm': -12, 'nm': -9, 'µm': -6, 'um': -6, 'μm': -6, 'micron': -6, 'microns': -6,
|
|
64
|
+
'mm': -3, 'cm': -2, 'm': 0, 'km': 3}
|
|
65
|
+
_SI_LADDER = [('pm', -12), ('nm', -9), ('µm', -6), ('mm', -3), ('m', 0), ('km', 3)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def scalebar_text(length, units):
|
|
69
|
+
"""Friendly scale-bar label: ``10000 nm`` -> ``10 µm``, ``1e6 nm`` -> ``1 mm``.
|
|
70
|
+
|
|
71
|
+
Metric lengths move to the unit that keeps the number in [1, 1000); a value that
|
|
72
|
+
already reads well in its own unit (``5 cm``, ``250 nm``) is left alone. Other
|
|
73
|
+
units (``voxels``, ``a.u.``) are printed as given.
|
|
74
|
+
"""
|
|
75
|
+
units = str(units or '')
|
|
76
|
+
exponent = _SI_LENGTHS.get(units)
|
|
77
|
+
value = float(length)
|
|
78
|
+
if exponent is not None and not 1 <= abs(value) < 1000:
|
|
79
|
+
metres = _clean_float(value * 10.0 ** exponent)
|
|
80
|
+
for unit, power in reversed(_SI_LADDER):
|
|
81
|
+
# Tolerate binary noise at the threshold so 1000 nm reads 1 µm, not 1000 nm.
|
|
82
|
+
if abs(metres) >= 10.0 ** power * (1 - 1e-9) or unit == 'pm':
|
|
83
|
+
units, value = unit, metres / 10.0 ** power
|
|
84
|
+
break
|
|
85
|
+
number = f'{_clean_float(value):g}'
|
|
86
|
+
return f'{number} {units}' if units else number
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(eq=False)
|
|
90
|
+
class MeshPart:
|
|
91
|
+
"""One addressable mesh; arrays are copied on construction and retain vertex correspondence."""
|
|
92
|
+
id: str
|
|
93
|
+
vertices: np.ndarray
|
|
94
|
+
faces: np.ndarray
|
|
95
|
+
color: str
|
|
96
|
+
states: dict = field(default_factory=dict)
|
|
97
|
+
values: np.ndarray | None = None
|
|
98
|
+
colors: np.ndarray | None = None
|
|
99
|
+
source_faces: np.ndarray | None = None
|
|
100
|
+
source_count: int = 0
|
|
101
|
+
source_indices: np.ndarray | None = None
|
|
102
|
+
collapses: np.ndarray | None = None
|
|
103
|
+
# Collapse replay works on the compacted pre-decimation topology.
|
|
104
|
+
compact_faces: np.ndarray | None = None
|
|
105
|
+
|
|
106
|
+
def __repr__(self):
|
|
107
|
+
states = f', states={list(self.states)}' if self.states else ''
|
|
108
|
+
return (f'MeshPart({self.id!r}, {len(self.vertices):,} vertices, '
|
|
109
|
+
f'{len(self.faces):,} triangles, color={self.color!r}{states})')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Scene3D:
|
|
113
|
+
"""A scientific 3D scene: meshes with named parts, value fields and shape states.
|
|
114
|
+
|
|
115
|
+
Create one with :func:`fp.scene3d <scene3d>` (same parameters), add meshes with
|
|
116
|
+
:func:`fp.mesh3d <fluxplot.mesh3d>` / :func:`fp.surface3d <fluxplot.surface3d>`,
|
|
117
|
+
choose the saved angle with :meth:`view`, and write it with ``fp.save``.
|
|
118
|
+
"""
|
|
119
|
+
def __init__(self, *, figsize=(3.5, 3), units='', up='y', title=None, axes='none',
|
|
120
|
+
scalebar=None, scalebar_label=None, view=None, lighting='studio',
|
|
121
|
+
preview_max_faces=PREVIEW_MAX_FACES):
|
|
122
|
+
import matplotlib as mpl
|
|
123
|
+
from matplotlib.colors import to_hex
|
|
124
|
+
from matplotlib.font_manager import FontProperties
|
|
125
|
+
try:
|
|
126
|
+
size = [float(x) for x in figsize]
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
size = []
|
|
129
|
+
if len(size) != 2 or not all(math.isfinite(x) and x > 0 for x in size):
|
|
130
|
+
raise ValueError('figsize must contain two positive finite inch dimensions')
|
|
131
|
+
if axes not in ('none', 'box', 'triad'):
|
|
132
|
+
raise ValueError("axes must be 'none', 'box', or 'triad'")
|
|
133
|
+
if lighting not in ('studio', 'unlit'):
|
|
134
|
+
raise ValueError("lighting must be 'studio' or 'unlit'")
|
|
135
|
+
self.figsize = tuple(size)
|
|
136
|
+
self.units = str(units)
|
|
137
|
+
self.up = up
|
|
138
|
+
self.title = title
|
|
139
|
+
self.axes = axes
|
|
140
|
+
self.to_world = up_matrix(up)
|
|
141
|
+
self.scalebar = None
|
|
142
|
+
if scalebar is not None:
|
|
143
|
+
self.scalebar = _number(scalebar, 'scalebar')
|
|
144
|
+
if self.scalebar <= 0:
|
|
145
|
+
raise ValueError(f'scalebar length must be positive (got {scalebar!r}); '
|
|
146
|
+
'it is a length in your data units, e.g. scalebar=10_000 with units="nm"')
|
|
147
|
+
self.scalebar_label = None if scalebar_label is None else str(scalebar_label)
|
|
148
|
+
self.preview_max_faces = _positive_int(preview_max_faces, 'preview_max_faces')
|
|
149
|
+
self.lighting = lighting
|
|
150
|
+
self.parts: list[MeshPart] = []
|
|
151
|
+
self._max_call_parts = 1
|
|
152
|
+
self._alloc = IdAllocator()
|
|
153
|
+
self._view = dict(azimuth=30., elevation=20., roll=0., zoom=.9, panX=0., panY=0.,
|
|
154
|
+
projection='orthographic', fov=30.)
|
|
155
|
+
# Shape-state weights (or a frame) requested before any mesh exists; applied
|
|
156
|
+
# as soon as the scene has those states, and required by save/display time.
|
|
157
|
+
self._pending_view = None
|
|
158
|
+
self._legend_entries: list[str] = []
|
|
159
|
+
self._axis_specs = {}
|
|
160
|
+
self.sequence = False
|
|
161
|
+
self.morph_group = None
|
|
162
|
+
self.style = dict(font=FontProperties().get_name(), fontSizePt=float(mpl.rcParams['font.size']),
|
|
163
|
+
titleSizePt=FontProperties(size=mpl.rcParams['axes.titlesize']).get_size_in_points(),
|
|
164
|
+
ink=to_hex(mpl.rcParams['text.color']), muted=to_hex(mpl.rcParams['axes.labelcolor']),
|
|
165
|
+
lineWidthPt=float(mpl.rcParams['axes.linewidth']))
|
|
166
|
+
registry_for(self)
|
|
167
|
+
if view is not None:
|
|
168
|
+
self.view(view)
|
|
169
|
+
|
|
170
|
+
# ---- view ------------------------------------------------------------------------------
|
|
171
|
+
def view(self, mapping=None, /, *, view=None, azimuth=None, elevation=None, roll=None,
|
|
172
|
+
zoom=None, panX=None, panY=None, projection=None, fov=None, states=None, frame=None):
|
|
173
|
+
"""Set the saved Home view; return this scene so a notebook cell can end with it.
|
|
174
|
+
|
|
175
|
+
Accepts keywords, a mapping, or both (keywords win), so every Copy view form
|
|
176
|
+
pastes back unchanged::
|
|
177
|
+
|
|
178
|
+
sc.view(azimuth=30, elevation=15, zoom=0.9)
|
|
179
|
+
sc.view({"azimuth": 30, "elevation": 15, "zoom": 0.9})
|
|
180
|
+
sc.view(view=dict(azimuth=30, elevation=15, zoom=0.9))
|
|
181
|
+
sc.view(frame=3) # sequences: frame 0 is the base mesh
|
|
182
|
+
sc.view(states={"inflated": 0.5}) # named shape states
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
azimuth, elevation, roll
|
|
187
|
+
Camera angles in degrees; azimuth is unwrapped, elevation is in [-90, 90].
|
|
188
|
+
zoom
|
|
189
|
+
Magnification in [0.02, 50]; 1 fits the scene's bounding sphere.
|
|
190
|
+
panX, panY
|
|
191
|
+
Pan in bounding-sphere radii.
|
|
192
|
+
projection, fov
|
|
193
|
+
``"orthographic"`` (default; scale bars need it) or ``"perspective"`` with a
|
|
194
|
+
vertical field of view ``fov`` in [5, 120] degrees.
|
|
195
|
+
states
|
|
196
|
+
``{state_name: weight}`` default weights of named shape states. Weights are
|
|
197
|
+
stored unclamped; 0 is the base shape and 1 the full state.
|
|
198
|
+
frame
|
|
199
|
+
For ``sequence=True`` scenes: frame 0 is the base, 1..N the sequence
|
|
200
|
+
targets; a fractional frame blends its two neighbours.
|
|
201
|
+
"""
|
|
202
|
+
params = {}
|
|
203
|
+
for source, what in ((mapping, 'view mapping'), (view, 'view=')):
|
|
204
|
+
if source is None:
|
|
205
|
+
continue
|
|
206
|
+
if not isinstance(source, Mapping):
|
|
207
|
+
raise TypeError(f'{what} must be a mapping such as dict(azimuth=30, elevation=15)')
|
|
208
|
+
unknown = sorted(str(k) for k in source if k not in _VIEW_KEYS)
|
|
209
|
+
if unknown:
|
|
210
|
+
raise ValueError(f'unknown view keys {unknown}; valid keys are {", ".join(_VIEW_KEYS)}')
|
|
211
|
+
params.update(source)
|
|
212
|
+
explicit = dict(azimuth=azimuth, elevation=elevation, roll=roll, zoom=zoom, panX=panX,
|
|
213
|
+
panY=panY, projection=projection, fov=fov, states=states, frame=frame)
|
|
214
|
+
params.update({k: v for k, v in explicit.items() if v is not None})
|
|
215
|
+
|
|
216
|
+
next_view = dict(self._view)
|
|
217
|
+
for key in _VIEW_NUMBERS:
|
|
218
|
+
if params.get(key) is not None:
|
|
219
|
+
next_view[key] = _number(params[key], key, *_VIEW_RANGES.get(key, (None, None)))
|
|
220
|
+
if params.get('projection') is not None:
|
|
221
|
+
if params['projection'] not in ('orthographic', 'perspective'):
|
|
222
|
+
raise ValueError("projection must be 'orthographic' or 'perspective'")
|
|
223
|
+
next_view['projection'] = params['projection']
|
|
224
|
+
|
|
225
|
+
states, frame = params.get('states'), params.get('frame')
|
|
226
|
+
if states is not None and frame is not None:
|
|
227
|
+
raise ValueError('pass states or frame, not both')
|
|
228
|
+
pending = None
|
|
229
|
+
if states is not None or frame is not None:
|
|
230
|
+
if states is not None and not isinstance(states, Mapping):
|
|
231
|
+
raise TypeError('states must be a mapping of state name to weight')
|
|
232
|
+
if not self.parts:
|
|
233
|
+
# Nothing to validate against yet (e.g. fp.scene3d(view=...)); check the
|
|
234
|
+
# numbers now and resolve the names when meshes with states arrive.
|
|
235
|
+
if states is not None:
|
|
236
|
+
pending = {'states': {str(k): _number(v, f'state {k}') for k, v in states.items()}}
|
|
237
|
+
else:
|
|
238
|
+
pending = {'frame': _number(frame, 'frame', 0)}
|
|
239
|
+
else:
|
|
240
|
+
next_view['states'] = self._state_weights(states, frame)
|
|
241
|
+
self._view = next_view
|
|
242
|
+
if states is not None or frame is not None:
|
|
243
|
+
self._pending_view = pending
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def _state_weights(self, states, frame):
|
|
247
|
+
names = self.state_names
|
|
248
|
+
if states is not None:
|
|
249
|
+
unknown = sorted(set(map(str, states)) - set(names))
|
|
250
|
+
if unknown:
|
|
251
|
+
have = ', '.join(names) if names else 'no shape states'
|
|
252
|
+
raise ValueError(f'unknown shape states {unknown}; this scene has {have}')
|
|
253
|
+
return {str(k): _number(v, f'state {k}') for k, v in states.items()}
|
|
254
|
+
if not self.sequence:
|
|
255
|
+
raise ValueError('frame= needs a sequence scene (mesh3d(..., states=[...], sequence=True)); '
|
|
256
|
+
'use states={...} for named shape states')
|
|
257
|
+
frame = _number(frame, 'frame', 0, len(names))
|
|
258
|
+
low = int(frame); frac = frame - low; weights = {}
|
|
259
|
+
if low > 0: weights[names[low-1]] = 1 - frac
|
|
260
|
+
if frac > 0: weights[names[low]] = frac
|
|
261
|
+
return weights
|
|
262
|
+
|
|
263
|
+
def _resolve_pending_view(self, *, strict):
|
|
264
|
+
"""Apply a view requested before its shape states existed (see ``view``)."""
|
|
265
|
+
pending = self._pending_view
|
|
266
|
+
if pending is None:
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
weights = self._state_weights(pending.get('states'), pending.get('frame'))
|
|
270
|
+
except ValueError as exc:
|
|
271
|
+
if strict:
|
|
272
|
+
raise ValueError(f'the view set before adding meshes cannot be applied: {exc}') from None
|
|
273
|
+
return
|
|
274
|
+
self._view = {**self._view, 'states': weights}
|
|
275
|
+
self._pending_view = None
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def state_names(self):
|
|
279
|
+
return list(dict.fromkeys(name for p in self.parts for name in p.states))
|
|
280
|
+
|
|
281
|
+
# ---- furniture -------------------------------------------------------------------------
|
|
282
|
+
def axis(self, which, *, lim=None, ticks=None, label=None):
|
|
283
|
+
"""Override the authored data-unit ticks, limits or label of x/y/z (box axes)."""
|
|
284
|
+
if which not in ('x', 'y', 'z'): raise ValueError("axis must be 'x', 'y' or 'z'")
|
|
285
|
+
if self.axes == 'none':
|
|
286
|
+
warnings.warn("sc.axis() has no visible effect with axes='none'; create the scene "
|
|
287
|
+
"with fp.scene3d(axes='box') to draw ticks and labels", stacklevel=2)
|
|
288
|
+
spec = dict(self._axis_specs.get(which, {}))
|
|
289
|
+
if lim is not None:
|
|
290
|
+
if len(lim) != 2: raise ValueError('lim must be a pair')
|
|
291
|
+
spec['lim'] = [_number(x, 'limit') for x in lim]
|
|
292
|
+
if spec['lim'][1] <= spec['lim'][0]: raise ValueError('axis limits must increase')
|
|
293
|
+
if ticks is not None: spec['ticks'] = [_number(x, 'tick') for x in ticks]
|
|
294
|
+
if label is not None: spec['label'] = str(label)
|
|
295
|
+
self._axis_specs[which] = spec
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
# ---- display ---------------------------------------------------------------------------
|
|
299
|
+
def __repr__(self):
|
|
300
|
+
width, height = self.figsize
|
|
301
|
+
head = f'Scene3D({width:g}×{height:g} in'
|
|
302
|
+
if self.units: head += f", units={self.units!r}"
|
|
303
|
+
if self.title: head += f', title={str(self.title)!r}'
|
|
304
|
+
if not self.parts:
|
|
305
|
+
return head + ', empty: add a mesh with fp.mesh3d(sc, ...) or fp.surface3d(sc, ...))'
|
|
306
|
+
triangles = sum(len(p.faces) for p in self.parts)
|
|
307
|
+
ids = [p.id for p in self.parts]
|
|
308
|
+
shown = ', '.join(ids[:6]) + (f', … (+{len(ids) - 6})' if len(ids) > 6 else '')
|
|
309
|
+
text = f'{head}, {len(ids)} part{"s" if len(ids) != 1 else ""} [{shown}], {triangles:,} triangles'
|
|
310
|
+
if self.state_names:
|
|
311
|
+
kind = 'frames' if self.sequence else 'states'
|
|
312
|
+
text += f', {len(self.state_names)} {kind}'
|
|
313
|
+
if self.axes != 'none': text += f', axes={self.axes!r}'
|
|
314
|
+
if self.scalebar is not None: text += f', scalebar={scalebar_text(self.scalebar, self.units)!r}'
|
|
315
|
+
return text + ')'
|
|
316
|
+
|
|
317
|
+
def _repr_mimebundle_(self, include=None, exclude=None):
|
|
318
|
+
from .scene3d_viewer import mimebundle, mimebundle_metadata
|
|
319
|
+
bundle = mimebundle(self)
|
|
320
|
+
data = {k: v for k, v in bundle.items()
|
|
321
|
+
if (include is None or k in include) and (exclude is None or k not in exclude)}
|
|
322
|
+
return data, mimebundle_metadata(self, data)
|
|
323
|
+
|
|
324
|
+
def show(self, *, static=False):
|
|
325
|
+
"""Display in a notebook; ``static=True`` shows only the PNG still."""
|
|
326
|
+
from IPython.display import display
|
|
327
|
+
from .scene3d_viewer import mimebundle, mimebundle_metadata
|
|
328
|
+
bundle = mimebundle(self, static=static)
|
|
329
|
+
return display(bundle, metadata=mimebundle_metadata(self, bundle), raw=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def scene3d(*, figsize=(3.5, 3), units='', up='y', title=None, axes='none', scalebar=None,
|
|
333
|
+
scalebar_label=None, view=None, lighting='studio', preview_max_faces=PREVIEW_MAX_FACES):
|
|
334
|
+
"""Create a 3D scene; add meshes with :func:`mesh3d` / :func:`surface3d`, save with ``fp.save``.
|
|
335
|
+
|
|
336
|
+
``fp.save(sc, "plots/name")`` writes ``name.glb`` + ``name.fluxplot.json`` +
|
|
337
|
+
``name.recipe.json``. Flux imports it at its physical size and can re-angle,
|
|
338
|
+
restyle, recolour and animate it without rerunning Python.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
figsize
|
|
343
|
+
``(width, height)`` in inches: the default physical size in Flux.
|
|
344
|
+
units
|
|
345
|
+
Data length unit (``"nm"``, ``"µm"``, ``"mm"``, …) used by axis labels and the
|
|
346
|
+
scale bar. Coordinates are never rescaled.
|
|
347
|
+
up
|
|
348
|
+
Which data axis points up: ``"x"``, ``"-x"``, ``"y"`` (default), ``"-y"``, ``"z"``
|
|
349
|
+
or ``"-z"``. ``"z"`` suits most neuroimaging/connectomics data, ``"-y"`` image
|
|
350
|
+
coordinates. Only the stored orientation changes; axes and ticks keep your units.
|
|
351
|
+
title
|
|
352
|
+
Optional title above the scene.
|
|
353
|
+
axes
|
|
354
|
+
``"none"`` (default), ``"box"`` (back panes, grid, ticks in data units; tune with
|
|
355
|
+
:meth:`Scene3D.axis`) or ``"triad"`` (a small x/y/z orientation gizmo).
|
|
356
|
+
scalebar
|
|
357
|
+
Scale-bar length in data units (e.g. ``10_000`` with ``units="nm"``); shown in
|
|
358
|
+
orthographic views. Its label is simplified to a friendly metric unit
|
|
359
|
+
(``10 µm``).
|
|
360
|
+
scalebar_label
|
|
361
|
+
Replace the automatic scale-bar label text.
|
|
362
|
+
view
|
|
363
|
+
Initial saved view, the same mapping :meth:`Scene3D.view` accepts, e.g. the
|
|
364
|
+
notebook's Copy view output: ``view=dict(azimuth=30, elevation=15, zoom=0.9)``.
|
|
365
|
+
lighting
|
|
366
|
+
``"studio"`` (default) or ``"unlit"`` (flat colours, e.g. for label maps).
|
|
367
|
+
preview_max_faces
|
|
368
|
+
Triangle budget of the notebook preview only (default 100,000). With the
|
|
369
|
+
optional ``fluxplot[mesh]`` extra the preview is simplified to this budget;
|
|
370
|
+
``fp.save`` always keeps the scene's full resolution.
|
|
371
|
+
|
|
372
|
+
Returns
|
|
373
|
+
-------
|
|
374
|
+
Scene3D
|
|
375
|
+
Display it as the last line of a notebook cell to orbit it interactively.
|
|
376
|
+
|
|
377
|
+
Examples
|
|
378
|
+
--------
|
|
379
|
+
>>> sc = fp.scene3d(figsize=(3.5, 3), units="nm", up="-y", scalebar=10_000)
|
|
380
|
+
>>> fp.mesh3d(sc, (vertices, faces), series="neuron")
|
|
381
|
+
>>> sc.view(azimuth=30, elevation=15, zoom=0.9)
|
|
382
|
+
>>> fp.save(sc, "plots/neuron")
|
|
383
|
+
"""
|
|
384
|
+
return Scene3D(figsize=figsize, units=units, up=up, title=title, axes=axes, scalebar=scalebar,
|
|
385
|
+
scalebar_label=scalebar_label, view=view, lighting=lighting,
|
|
386
|
+
preview_max_faces=preview_max_faces)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Semantic scene manifests assembled from the shared Mark/Registry machinery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
import warnings
|
|
11
|
+
import numpy as np
|
|
12
|
+
from matplotlib.ticker import MaxNLocator
|
|
13
|
+
from .scene3d import SCENE3D_SPEC_VERSION, _clean_float, scalebar_text
|
|
14
|
+
from .tagger import registry_for, Registry
|
|
15
|
+
from .descriptors import Mark
|
|
16
|
+
from .ids import IdAllocator
|
|
17
|
+
from .canonical_json import dumps
|
|
18
|
+
from .version import __version__, SPEC_VERSION
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_manifest(scene, glb, filename):
|
|
22
|
+
scene._resolve_pending_view(strict=True)
|
|
23
|
+
parts=[deepcopy(m.data['scene3d']) for m in registry_for(scene).marks if 'scene3d' in m.data]
|
|
24
|
+
allv=np.concatenate([v for p in scene.parts for v in [p.vertices,*p.states.values()]])
|
|
25
|
+
world=allv@scene.to_world[:3,:3].T
|
|
26
|
+
reg=Registry(); alloc=IdAllocator()
|
|
27
|
+
for p in parts: alloc.take(p['id'])
|
|
28
|
+
def guide(pid,role,**data):
|
|
29
|
+
gid=alloc.take(pid); spec=dict(id=gid,role=role,kind='furniture',**data)
|
|
30
|
+
reg.add(Mark(role=role,name=pid,gid=gid,data={'scene3d':spec}))
|
|
31
|
+
axes={'kind':scene.axes}
|
|
32
|
+
if scene.axes!='none':
|
|
33
|
+
axes['grid']=True
|
|
34
|
+
for i,k in enumerate('xyz'):
|
|
35
|
+
lo,hi=float(allv[:,i].min()),float(allv[:,i].max())
|
|
36
|
+
if lo==hi: lo-=.5; hi+=.5
|
|
37
|
+
spec={'lim':[lo,hi],'label':f'{k} ({scene.units})' if scene.units else k}
|
|
38
|
+
spec.update(scene._axis_specs.get(k,{}))
|
|
39
|
+
if 'ticks' not in spec:
|
|
40
|
+
ticks=[_clean_float(x) for x in MaxNLocator(nbins=4).tick_values(*spec['lim'])]
|
|
41
|
+
spec['ticks']=[x for x in ticks if spec['lim'][0]<=x<=spec['lim'][1]]
|
|
42
|
+
axes[k]=spec
|
|
43
|
+
for suffix,role in [('axis','axis'),('pane','pane'),('grid','gridline'),('ticks','tick-label')]: guide(f'axes.{k}.{suffix}',role)
|
|
44
|
+
guide(f'axes.{k}.label','axis-title',text=spec['label'])
|
|
45
|
+
if scene.scalebar is not None:
|
|
46
|
+
# length stays in data units; only the label is simplified (10000 nm -> 10 µm).
|
|
47
|
+
text=scene.scalebar_label if scene.scalebar_label is not None else scalebar_text(scene.scalebar,scene.units)
|
|
48
|
+
guide('scalebar','scalebar',length=scene.scalebar,label=text)
|
|
49
|
+
layout={}
|
|
50
|
+
if scene.title:
|
|
51
|
+
guide('title','title',text=str(scene.title)); layout['title']='top'
|
|
52
|
+
if scene._legend_entries:
|
|
53
|
+
guide('legend','legend',entries=list(scene._legend_entries)); layout['legend']='right'
|
|
54
|
+
if any(p['role']=='colorbar' for p in parts): layout['colorbar']='right'
|
|
55
|
+
parts.extend(m.data['scene3d'] for m in reg.marks)
|
|
56
|
+
out={'spec':'fluxplot/scene3d','schemaVersion':SCENE3D_SPEC_VERSION,'plotType':'scene3d','glb':filename,
|
|
57
|
+
'glbSha256':hashlib.sha256(glb).hexdigest(),'size':{'width':scene.figsize[0],'height':scene.figsize[1],'unit':'in'},
|
|
58
|
+
'units':scene.units,'toWorld':scene.to_world.ravel(order='F').tolist(),
|
|
59
|
+
'bounds':{'min':world.min(axis=0).tolist(),'max':world.max(axis=0).tolist()},
|
|
60
|
+
'view':deepcopy(scene._view),'lighting':scene.lighting,'style':dict(scene.style),'axes':axes,
|
|
61
|
+
'parts':parts,'order':[p['id'] for p in parts],'layout':layout,'build':{'generator':f'fluxplot {__version__}'}}
|
|
62
|
+
if scene.state_names: out.update(states=[{'name':n,'label':n} for n in scene.state_names],sequence=scene.sequence)
|
|
63
|
+
if scene.morph_group is not None: out['morphGroup']=scene.morph_group
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Scene3DSaveResult:
|
|
69
|
+
glb: str
|
|
70
|
+
manifest: str
|
|
71
|
+
recipe: str
|
|
72
|
+
warnings: list = field(default_factory=list)
|
|
73
|
+
skipped: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_scene3d(scene,path,*,recipe=None,validate=True,_now=None):
|
|
77
|
+
import fnmatch
|
|
78
|
+
from .glb import write_glb
|
|
79
|
+
from .recipe import build_recipe
|
|
80
|
+
from .api import _write_staged
|
|
81
|
+
path=os.fspath(path)
|
|
82
|
+
# A dotted stem is still a stem; only recognized output suffixes are stripped.
|
|
83
|
+
base=path[:-4] if path.lower().endswith('.glb') else path
|
|
84
|
+
if base.lower().endswith(('.svg','.png','.pdf')): raise ValueError('save a Scene3D to a stem or .glb path')
|
|
85
|
+
result=Scene3DSaveResult(base+'.glb',base+'.fluxplot.json',base+'.recipe.json')
|
|
86
|
+
only=os.environ.get('FLUXPLOT_ONLY','').strip()
|
|
87
|
+
patterns=[p.strip() for p in only.split(',') if p.strip()]
|
|
88
|
+
plot_name=os.path.basename(base)
|
|
89
|
+
if patterns and not any(fnmatch.fnmatchcase(plot_name,p) for p in patterns):
|
|
90
|
+
# Same targeted-rerun report as the 2D save.
|
|
91
|
+
print(f"fluxplot: skipped '{plot_name}' (FLUXPLOT_ONLY={only})",file=sys.stderr)
|
|
92
|
+
result.skipped=True; result.warnings.append(f'skipped by FLUXPLOT_ONLY={only}'); return result
|
|
93
|
+
data=write_glb(scene)
|
|
94
|
+
triangles=sum(len(p.faces) for p in scene.parts)
|
|
95
|
+
from ._scene3d_size import WARN_BYTES, WARN_TRIANGLES, face_cap_recommendation
|
|
96
|
+
if len(data)>WARN_BYTES or triangles>WARN_TRIANGLES:
|
|
97
|
+
message=f'3D plot has {triangles:,} triangles and {len(data)/1024**2:.1f} MiB; '+face_cap_recommendation(scene,data)
|
|
98
|
+
result.warnings.append(message); warnings.warn(message,stacklevel=2)
|
|
99
|
+
if len(scene.state_names)>20 or (scene.state_names and len(data)>100*1024**2):
|
|
100
|
+
message=f'{len(scene.state_names)} shape states add 24 bytes per vertex per state; use fewer frames to reduce saved size'
|
|
101
|
+
result.warnings.append(message); warnings.warn(message,stacklevel=2)
|
|
102
|
+
man=build_manifest(scene,data,os.path.basename(result.glb))
|
|
103
|
+
rec=build_recipe(recipe,plot_name=os.path.basename(base),glb_filename=os.path.basename(result.glb),
|
|
104
|
+
manifest_filename=os.path.basename(result.manifest),spec_version=SPEC_VERSION,
|
|
105
|
+
base_dir=os.getcwd(),recipe_dir=os.path.dirname(os.path.abspath(base)),now=_now)
|
|
106
|
+
if validate:
|
|
107
|
+
import jsonschema
|
|
108
|
+
jsonschema.validate(man,json.loads(files('fluxplot').joinpath('schemas/scene3d.schema.json').read_text()))
|
|
109
|
+
jsonschema.validate(rec,json.loads(files('fluxplot').joinpath('schemas/recipe.schema.json').read_text()))
|
|
110
|
+
os.makedirs(os.path.dirname(os.path.abspath(base)),exist_ok=True)
|
|
111
|
+
_write_staged([(result.glb,data),(result.manifest,dumps(man).encode()),(result.recipe,dumps(rec).encode())])
|
|
112
|
+
return result
|