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/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