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
@@ -0,0 +1,633 @@
1
+ """Notebook HTML using Flux's vendored renderer, bundled with a static PNG alternative."""
2
+ from __future__ import annotations
3
+ import base64
4
+ from copy import deepcopy
5
+ from functools import cmp_to_key
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import math
10
+ from importlib.resources import files
11
+ import warnings
12
+ import numpy as np
13
+
14
+ #: CSS pixels per figure inch of the notebook viewer (its HTML payload uses the same).
15
+ PX_PER_INCH = 120
16
+ #: The static PNG is rendered at this multiple of its CSS size for HiDPI screens.
17
+ HIDPI = 2
18
+
19
+ EMPTY_MESSAGE = ('Scene3D: nothing to show yet; add a mesh with fp.mesh3d(sc, (vertices, faces), '
20
+ "series='name') or a value map with fp.surface3d(sc, values, series=..., surfaces=...)")
21
+
22
+
23
+ def preview_scene(scene):
24
+ """Reduce a private notebook copy only; saving the original remains full resolution."""
25
+ total=sum(len(p.faces) for p in scene.parts)
26
+ if total<=scene.preview_max_faces: return scene
27
+ try: import fast_simplification # noqa: F401
28
+ except ImportError: return scene
29
+ from ._mesh_reduce import reduce_part,face_budgets
30
+ from ._fieldmap import continuous_mapping
31
+ from .tagger import registry_for
32
+ from matplotlib.colors import to_rgba, Normalize, LinearSegmentedColormap
33
+ if scene.preview_max_faces<len(scene.parts):
34
+ warnings.warn('preview_max_faces cannot preserve one triangle per part; showing the full scene',stacklevel=3)
35
+ return scene
36
+ out=deepcopy(scene)
37
+ specs={m.gid:m.data.get('scene3d',{}) for m in registry_for(out).marks}
38
+ budgets=face_budgets([len(p.faces) for p in out.parts],out.preview_max_faces)
39
+ for p,budget in zip(out.parts,budgets):
40
+ if len(p.faces)>budget:
41
+ reduce_part(p,max_faces=budget)
42
+ field=specs.get(p.id,{}).get('field')
43
+ if isinstance(field,dict) and p.colors is not None:
44
+ try:
45
+ cmap,norm=continuous_mapping(p.values[np.isfinite(p.values)],field['cmap']['name'],field['range'],None)
46
+ except (KeyError, ValueError):
47
+ cmap=LinearSegmentedColormap.from_list(field['cmap']['name'],field['cmap']['stops'])
48
+ norm=Normalize(*field['range'])
49
+ p.colors=np.asarray(cmap(norm(p.values)))
50
+ p.colors[~np.isfinite(p.values)]=to_rgba(field.get('missingColor','#D8D8D8'))
51
+ return out
52
+
53
+
54
+ # ---- static still ------------------------------------------------------------------------
55
+ # The PNG mirrors the notebook viewer: the same CSS-pixel layout rules as Flux's
56
+ # furnitureLayout.ts / furniture.ts (text in physical points, 4/3 CSS px per point),
57
+ # a camera matching orbit.ts, and a painter's sort instead of a depth buffer.
58
+
59
+ def _tick_label(value):
60
+ """Flux ticks.ts tickLabel: compact decimals, exponent outside [1e-3, 1e5)."""
61
+ if value == 0:
62
+ return '0'
63
+ if abs(value) >= 1e5 or abs(value) < 1e-3:
64
+ mantissa, exponent = f'{value:.2e}'.split('e')
65
+ if set(mantissa.split('.')[-1]) == {'0'}:
66
+ mantissa = mantissa.split('.')[0]
67
+ exponent = int(exponent)
68
+ return f'{mantissa}e{"+" if exponent >= 0 else "-"}{abs(exponent)}'
69
+ return f'{value:.6g}'
70
+
71
+
72
+ # Deterministic Arial advance estimates shared with Flux textMetrics.ts; unknown
73
+ # code points use 600/1000 em. The shared 20% reserve covers common sans fonts;
74
+ # custom fonts remain estimates. Physical rendered sizes do not change.
75
+ _ADVANCE = tuple(map(int, '278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584'.split(',')))
76
+ _EXTRA = {'µ':576,'°':400,'±':549,'−':584,'×':584,'÷':549,'²':333,'³':333,'¹':333,'·':278,'–':556,'—':1000,'λ':500,'Å':667,'π':690,'σ':617,'Δ':668,'α':578,'β':575,'γ':500,'θ':556,'…':1000,'’':222}
77
+
78
+
79
+ def _text_width(text, font_px):
80
+ return sum(_ADVANCE[ord(ch)-32] if 32 <= ord(ch) < 127 else _EXTRA.get(ch, 600) for ch in text) * font_px / 1000 * 1.2
81
+
82
+
83
+ def _wrap_words(text, font_px, max_width):
84
+ lines = []; line = ''
85
+ for word in text.split():
86
+ if line and _text_width(line + ' ' + word, font_px) <= max_width:
87
+ line += ' ' + word
88
+ continue
89
+ if line:
90
+ lines.append(line); line = ''
91
+ for ch in word:
92
+ if line and _text_width(line + ch, font_px) > max_width:
93
+ lines.append(line); line = ''
94
+ line += ch
95
+ if line:
96
+ lines.append(line)
97
+ return lines or ['']
98
+
99
+
100
+ def _nice_ticks(lo, hi, count=5):
101
+ if lo == hi:
102
+ return [lo]
103
+ raw = (hi-lo)/(count-1); power = 10 ** math.floor(math.log10(raw)); error = raw/power
104
+ step = (10 if error >= math.sqrt(50) else 5 if error >= math.sqrt(10) else 2 if error >= math.sqrt(2) else 1)*power
105
+ first = math.ceil(lo/step-1e-10); last = math.floor(hi/step+1e-10)
106
+ return [float(f'{i*step:.12g}') or 0 for i in range(first, min(last+1, first+1000))]
107
+
108
+
109
+ def _field_ticks(field):
110
+ lo, hi = field['range']
111
+ return [v for v in field.get('ticks', _nice_ticks(lo, hi)) if lo <= v <= hi]
112
+
113
+
114
+ def _layout(manifest, width, height):
115
+ """Source-layout twin of Flux furnitureLayout (CSS px, y down; physical fonts)."""
116
+ width, height = max(1, width), max(1, height)
117
+ style = manifest.get('style', {}); parts = manifest.get('parts', [])
118
+ by_id = {p['id']: p for p in parts}
119
+ def hidden(part):
120
+ return part.get('hidden', False) or bool(part.get('parent') in by_id and hidden(by_id[part['parent']]))
121
+ def visible(role):
122
+ return [p for p in parts if p['role'] == role and not hidden(p)]
123
+ fs = style.get('fontSizePt', 7)*4/3; line_height = fs*1.4; pad = fs*.5
124
+ rules = manifest.get('layout', {})
125
+ titles = [] if rules.get('title') == 'none' else visible('title')
126
+ bars = [] if rules.get('colorbar') == 'none' else visible('colorbar')
127
+ legends = [] if rules.get('legend') == 'none' else visible('legend')
128
+ scales = visible('scalebar')
129
+ title_h = max(line_height, style.get('titleSizePt', 8)*4/3*1.5)+4 if titles else 0
130
+ def field_of(part):
131
+ return by_id.get(part.get('field'), {}).get('field')
132
+ def entries_of(part):
133
+ return [pid for pid in part.get('entries', []) if pid in by_id and not hidden(by_id[pid])]
134
+ need_w = 0
135
+ for part in bars:
136
+ field = field_of(part)
137
+ if not isinstance(field, dict):
138
+ continue
139
+ tick_w = max([0] + [_text_width(_tick_label(v), fs) for v in _field_ticks(field)])
140
+ need_w = max(need_w, fs + max(_text_width(field.get('label', ''), fs), max(6, fs)+6+tick_w)+pad)
141
+ for part in legends:
142
+ need_w = max(need_w, fs*2.5+max([0]+[_text_width(by_id[pid].get('label', pid), fs) for pid in entries_of(part)])+pad)
143
+ guide_w = min(width*.4, max(64, fs*9, need_w)) if bars or legends else 0
144
+ margin = min(3*fs, width*.15, height*.15) if manifest.get('axes', {}).get('kind') == 'box' else 0
145
+ vp = dict(x=margin, y=title_h+margin, width=max(1, width-guide_w-2*margin), height=max(1, height-title_h-2*margin))
146
+ out = dict(width=width, height=height, fs=fs, line_height=line_height, viewport=vp,
147
+ colorbars=[], legends=[], scalebars=[], title=None, overflow=False, overflowParts=[])
148
+ def overflow(pid):
149
+ if pid not in out['overflowParts']:
150
+ out['overflowParts'].append(pid)
151
+ guides = []
152
+ for part in bars:
153
+ field = field_of(part) or {}; label = field.get('label')
154
+ lines = _wrap_words(label, fs, guide_w-fs-pad) if label else []
155
+ top = fs*(2.1+1.25*max(0, len(lines)-1)); bottom = 2*line_height
156
+ if any(_text_width(line, fs) > guide_w-fs-pad for line in lines) or any(fs+max(6, fs)+6+_text_width(_tick_label(v), fs)+pad > guide_w for v in (_field_ticks(field) if field else [])):
157
+ overflow(part['id'])
158
+ guides.append(dict(part=part, top=top, bottom=bottom, minHeight=top+bottom+max(fs*2, (len(_field_ticks(field))-1)*fs*1.4 if field else 0), titleLines=lines))
159
+ for part in legends:
160
+ rows = []; offset = 0
161
+ for pid in entries_of(part):
162
+ lines = _wrap_words(by_id[pid].get('label', pid), fs, guide_w-fs*2.5-pad)
163
+ rows.append(dict(id=pid, lines=lines, offset=offset)); offset += len(lines)*line_height
164
+ if any(_text_width(line, fs) > guide_w-fs*2.5-pad for line in lines):
165
+ overflow(part['id'])
166
+ guides.append(dict(part=part, top=line_height, bottom=line_height, minHeight=line_height+max(line_height, offset), legendRows=rows))
167
+ available = max(0, height-title_h); equal = available/max(1, len(guides))
168
+ heights = [max(equal, g['minHeight']) for g in guides]; deficit = sum(heights)-available
169
+ for i, g in enumerate(guides):
170
+ if deficit <= 0:
171
+ break
172
+ take = min(deficit, heights[i]-g['minHeight']); heights[i] -= take; deficit -= take
173
+ offset = 0
174
+ for g, slot_h in zip(guides, heights):
175
+ if offset+slot_h > available+1e-9:
176
+ overflow(g['part']['id'])
177
+ slot = dict(part=g['part'], x=width-guide_w+fs, y=title_h+offset+g['top'])
178
+ if 'titleLines' in g:
179
+ slot.update(width=max(6, fs), height=max(1, slot_h-g['top']-g['bottom']), titleLines=g['titleLines'])
180
+ out['colorbars'].append(slot)
181
+ else:
182
+ slot.update(width=max(1, guide_w-fs*2), height=max(g['top'], slot_h-g['top']), legendRows=g['legendRows'])
183
+ out['legends'].append(slot)
184
+ offset += slot_h
185
+ for i, part in enumerate(scales):
186
+ out['scalebars'].append(dict(part=part, x=vp['x']+12, y=vp['y']+vp['height']-12-i*(line_height+10)))
187
+ if titles:
188
+ out['title'] = dict(part=titles[0], x=0, y=0, width=width, height=title_h)
189
+ out['overflow'] = bool(out['overflowParts'])
190
+ return out
191
+
192
+
193
+ class _Camera:
194
+ """Orbit camera of Flux ``orbitPose`` over the manifest's world bounds."""
195
+
196
+ def __init__(self, view, bounds, viewport):
197
+ az, el, roll = np.deg2rad([view['azimuth'] % 360, view['elevation'], view.get('roll', 0)])
198
+ direction = np.array([np.sin(az)*np.cos(el), np.sin(el), np.cos(az)*np.cos(el)]) # toward the camera
199
+ right = np.array([np.cos(az), 0, -np.sin(az)]); up = np.cross(direction, right)
200
+ right, up = right*np.cos(roll) + up*np.sin(roll), up*np.cos(roll) - right*np.sin(roll)
201
+ # Framing (Flux orbit.ts boundsSphere/orbitPose): the tight ``radius`` when the
202
+ # bounds carry one (see _framing_bounds), else the circumscribed sphere;
203
+ # radius / zoom across the viewport's smaller side.
204
+ tight = bounds.get('radius')
205
+ bounds = np.array([bounds['min'], bounds['max']]); center = bounds.mean(axis=0)
206
+ radius = max(tight if tight is not None and np.isfinite(tight) and tight >= 0
207
+ else np.linalg.norm((bounds[1] - bounds[0]) / 2), 1e-9)
208
+ target = center + radius * (view.get('panX', 0) * right + view.get('panY', 0) * up)
209
+ half = radius / view['zoom']; half_fov = np.deg2rad(view.get('fov', 30)) / 2
210
+ distance = half / np.sin(half_fov)
211
+ self.perspective = view['projection'] == 'perspective'
212
+ if self.perspective:
213
+ half = distance * np.tan(half_fov)
214
+ self.near = max(.001 * distance, distance - 1.2 * radius * max(1, 1 / view['zoom']))
215
+ else:
216
+ distance = 3 * radius
217
+ self.near = -math.inf
218
+ aspect = viewport['width'] / viewport['height']
219
+ self.half_width, self.half_height = half * max(aspect, 1), half * max(1 / aspect, 1)
220
+ self.direction, self.right, self.up, self.target = direction, right, up, target
221
+ self.distance, self.viewport = distance, viewport
222
+
223
+ def project(self, points):
224
+ """Camera-plane coordinates (image plane at the target) and depth toward the camera."""
225
+ delta = np.asarray(points, dtype=float) - self.target
226
+ xy = np.column_stack([delta @ self.right, delta @ self.up]); toward = delta @ self.direction
227
+ if self.perspective:
228
+ xy *= self.distance / np.maximum(self.distance - toward, 1e-9)[:, None]
229
+ return xy, toward
230
+
231
+ def screen(self, points):
232
+ """CSS-pixel positions (y down) and distance from the camera, like Flux ``project``."""
233
+ xy, toward = self.project(np.atleast_2d(points))
234
+ vp = self.viewport
235
+ x = vp['x'] + vp['width'] * (.5 + xy[:, 0] / (2 * self.half_width))
236
+ y = vp['y'] + vp['height'] * (.5 - xy[:, 1] / (2 * self.half_height))
237
+ return np.column_stack([x, y, self.distance - toward])
238
+
239
+ def visible(self, point):
240
+ return bool(np.isfinite(point[:2]).all() and point[2] >= self.near)
241
+
242
+ def pixels_per_unit(self):
243
+ return None if self.perspective else self.viewport['height'] / (2 * self.half_height)
244
+
245
+
246
+ class _Furniture:
247
+ """Draws Flux furniture nodes into one matplotlib axes whose data units are CSS px."""
248
+
249
+ def __init__(self, ax, style, fs):
250
+ self.ax, self.style, self.fs = ax, style, fs
251
+ self.ink = style.get('ink', '#100F0F'); self.muted = style.get('muted', '#6F6E69')
252
+ self.lw = style.get('lineWidthPt', .6) * 4 / 3
253
+ self.font = style.get('font')
254
+
255
+ @staticmethod
256
+ def pt(px):
257
+ """CSS px -> matplotlib points at the viewer's PX_PER_INCH."""
258
+ return px * 72 / PX_PER_INCH
259
+
260
+ def line(self, a, b, *, color=None, alpha=1, width=None):
261
+ self.ax.plot([a[0], b[0]], [a[1], b[1]], color=color or self.ink, alpha=alpha,
262
+ lw=self.pt(width or self.lw), solid_capstyle='butt')
263
+
264
+ def text(self, x, y, label, *, anchor='middle', size=None, rotation=0, center=None):
265
+ """SVG-style text: (x, y) is the baseline point; optional rotation about ``center``."""
266
+ ha = {'start': 'left', 'middle': 'center', 'end': 'right'}[anchor]
267
+ kwargs = dict(ha=ha, va='baseline', fontsize=self.pt(size or self.fs), color=self.ink)
268
+ if self.font:
269
+ kwargs['fontfamily'] = self.font
270
+ if rotation:
271
+ cx, cy = center
272
+ theta = np.deg2rad(rotation); dx, dy = x - cx, y - cy
273
+ x = cx + dx * np.cos(theta) - dy * np.sin(theta)
274
+ y = cy + dx * np.sin(theta) + dy * np.cos(theta)
275
+ kwargs.update(rotation=-rotation, rotation_mode='anchor')
276
+ self.ax.text(x, y, label, **kwargs)
277
+
278
+ def polygon(self, points, **kwargs):
279
+ from matplotlib.patches import Polygon
280
+ self.ax.add_patch(Polygon(points, closed=True, **kwargs))
281
+
282
+
283
+ def _rotation(manifest):
284
+ return np.asarray(manifest['toWorld'], dtype=float).reshape(4, 4, order='F')[:3, :3]
285
+
286
+
287
+ def _box_limits(manifest):
288
+ """Data-space box-axes limits: an axis's ``lim``, else the world bounds carried back
289
+ into data space (Flux framing.ts ``axesBoxLimits``)."""
290
+ world_bounds = np.array([manifest['bounds']['min'], manifest['bounds']['max']])
291
+ corners = np.array([[world_bounds[(mask >> i) & 1, i] for i in range(3)] for mask in range(8)])
292
+ data_corners = corners @ _rotation(manifest) # inverse of a proper rotation is its transpose
293
+ axes = [manifest['axes'].get(k, {}) for k in 'xyz']
294
+ return [a.get('lim', [data_corners[:, i].min(), data_corners[:, i].max()]) for i, a in enumerate(axes)]
295
+
296
+
297
+ def _framing_bounds(scene, manifest):
298
+ """The bounds the camera frames, exactly as Flux frames the saved GLB.
299
+
300
+ A bare mesh frames its tight sphere: the largest distance from the AABB centre to
301
+ any vertex, base shape and every state (Flux glbCore ``framingRadius``). Box axes
302
+ are part of the figure, so the frame grows to hold the whole axes box and uses its
303
+ circumscribed sphere (Flux framing.ts ``framingBounds``).
304
+ """
305
+ lo, hi = np.array(manifest['bounds']['min'], dtype=float), np.array(manifest['bounds']['max'], dtype=float)
306
+ rotation = _rotation(manifest)
307
+ if manifest.get('axes', {}).get('kind') == 'box':
308
+ limits = _box_limits(manifest)
309
+ corners = np.array([[limits[i][(mask >> i) & 1] for i in range(3)] for mask in range(8)]) @ rotation.T
310
+ return {'min': np.minimum(lo, corners.min(axis=0)).tolist(), 'max': np.maximum(hi, corners.max(axis=0)).tolist()}
311
+ world = np.concatenate([v for p in scene.parts for v in [p.vertices, *p.states.values()]]) @ rotation.T
312
+ radius = float(np.linalg.norm(world - (lo + hi) / 2, axis=1).max())
313
+ return {'min': lo.tolist(), 'max': hi.tolist(), 'radius': radius}
314
+
315
+
316
+ def _tick_labels_collide(labels, font_px, anchor):
317
+ """Flux furniture.ts ``tickLabelsCollide``: do any two of one axis's tick labels collide?
318
+
319
+ ``labels`` are ``(x, y, width)`` label points (vertical centre) sharing an anchor and a
320
+ font size; each is a box of its deterministic width by one font size. Side by side they
321
+ must sit at least a word space apart. A pure function of the pose (no hysteresis).
322
+ """
323
+ space = _text_width(' ', font_px)
324
+ boxes = []
325
+ for x, y, width in labels:
326
+ x0 = x - width if anchor == 'end' else x if anchor == 'start' else x - width / 2
327
+ boxes.append((x0 - space / 2, x0 + width + space / 2, y - font_px / 2, y + font_px / 2))
328
+ return any(a[0] < b[1] and b[0] < a[1] and a[2] < b[3] and b[2] < a[3]
329
+ for i, a in enumerate(boxes) for b in boxes[i + 1:])
330
+
331
+
332
+ def _draw_box_axes(draw, manifest, camera, fs):
333
+ """Back panes and grid behind the mesh; axis lines on silhouette edges, labels outward."""
334
+ rotation = _rotation(manifest)
335
+ axes = [manifest['axes'].get(k, {}) for k in 'xyz']
336
+ limits = _box_limits(manifest)
337
+ center = np.array([sum(lim) / 2 for lim in limits])
338
+ screen = lambda p: camera.screen(np.asarray(p, dtype=float) @ rotation.T)[0]
339
+ back = [0 if rotation[:, axis] @ camera.direction >= 0 else 1 for axis in range(3)]
340
+ parts = {p['id']: p for p in manifest.get('parts', [])}
341
+ for axis in range(3):
342
+ other = [i for i in range(3) if i != axis]
343
+ quad = []
344
+ for a, b in ((0, 0), (1, 0), (1, 1), (0, 1)):
345
+ p = center.copy(); p[axis] = limits[axis][back[axis]]
346
+ p[other[0]] = limits[other[0]][a]; p[other[1]] = limits[other[1]][b]
347
+ quad.append(screen(p))
348
+ if all(camera.visible(q) for q in quad):
349
+ points = [q[:2] for q in quad]
350
+ draw.polygon(points, facecolor=draw.muted, alpha=.06, edgecolor='none')
351
+ draw.polygon(points, facecolor='none', edgecolor=draw.muted, alpha=.25, lw=draw.pt(draw.lw))
352
+ box = [screen([limits[i][(mask >> i) & 1] for i in range(3)]) for mask in range(8)]
353
+ box = [c for c in box if camera.visible(c)]
354
+ labelled = []
355
+ for axis, name in enumerate('xyz'):
356
+ other = [i for i in range(3) if i != axis]
357
+ candidates = []
358
+ for k in range(4):
359
+ a = center.copy(); b = center.copy(); a[axis] = limits[axis][0]; b[axis] = limits[axis][1]
360
+ for j in range(2):
361
+ a[other[j]] = b[other[j]] = limits[other[j]][(k >> j) & 1]
362
+ mid = screen((a + b) / 2)
363
+ if camera.visible(mid) and camera.visible(screen(a)) and camera.visible(screen(b)):
364
+ candidates.append(dict(a=a, b=b, y=mid[1], depth=mid[2]))
365
+ if not candidates:
366
+ continue
367
+
368
+ def silhouette(edge):
369
+ sa, sb = screen(edge['a']), screen(edge['b'])
370
+ length = math.hypot(sb[0] - sa[0], sb[1] - sa[1])
371
+ if length < 1:
372
+ return False
373
+ d = [((sb[0]-sa[0]) * (p[1]-sa[1]) - (sb[1]-sa[1]) * (p[0]-sa[0])) / length for p in box]
374
+ return all(v >= -1e-6 for v in d) or all(v <= 1e-6 for v in d)
375
+
376
+ def overlaps(edge):
377
+ sa, sb = screen(edge['a']), screen(edge['b'])
378
+ dx, dy = sb[0] - sa[0], sb[1] - sa[1]; length = math.hypot(dx, dy)
379
+ for e in labelled:
380
+ ex, ey = e[2] - e[0], e[3] - e[1]
381
+ if (abs(dx*ey - dy*ex) < 1e-6 * length * math.hypot(ex, ey)
382
+ and abs(dx*(e[1]-sa[1]) - dy*(e[0]-sa[0])) < fs * length):
383
+ return 1
384
+ return 0
385
+
386
+ def order(e1, e2):
387
+ first = overlaps(e1) - overlaps(e2)
388
+ if first:
389
+ return first
390
+ return e2['y'] - e1['y'] if abs(e2['y'] - e1['y']) > 1e-6 else e1['depth'] - e2['depth']
391
+
392
+ edges = [e for e in candidates if silhouette(e)] or candidates
393
+ edge = sorted(edges, key=cmp_to_key(order))[0]
394
+ sa, sb = screen(edge['a']), screen(edge['b'])
395
+ length = math.hypot(sb[0] - sa[0], sb[1] - sa[1])
396
+ if length < 1:
397
+ continue
398
+ mid = (sa[:2] + sb[:2]) / 2; origin = screen(center)
399
+ ox, oy = -(sb[1] - sa[1]) / length, (sb[0] - sa[0]) / length
400
+ if ox * (mid[0] - origin[0]) + oy * (mid[1] - origin[1]) < 0:
401
+ ox, oy = -ox, -oy
402
+ labelled.append((sa[0], sa[1], sb[0], sb[1]))
403
+ draw.line(sa, sb)
404
+ anchor = 'end' if ox < -.5 else 'start' if ox > .5 else 'middle'
405
+ spec = axes[axis]
406
+ drawn = []
407
+ for value in spec.get('ticks', []):
408
+ if not limits[axis][0] <= value <= limits[axis][1]:
409
+ continue
410
+ p = edge['a'].copy(); p[axis] = value; at = screen(p)
411
+ if camera.visible(at):
412
+ drawn.append((value, at, _tick_label(value)))
413
+ # Seen nearly end-on, an axis projects to a stub too short for its labels.
414
+ # Hide them (and a title longer than the stub) instead of stacking them.
415
+ crowded = _tick_labels_collide(
416
+ [(at[0] + ox * (fs*.9 + 4), at[1] + oy * (fs*.9 + 4), _text_width(label, fs)) for _, at, label in drawn], fs, anchor)
417
+ title = parts.get(f'axes.{name}.label', {}).get('text') or spec.get('label') or name
418
+ title_hidden = crowded and _text_width(title, fs) > length
419
+ for value, at, label in drawn:
420
+ draw.line(at, (at[0] + ox * 4, at[1] + oy * 4))
421
+ if not crowded:
422
+ draw.text(at[0] + ox * (fs*.9 + 4), at[1] + oy * (fs*.9 + 4) + fs*.3, label, anchor=anchor)
423
+ if manifest['axes'].get('grid') is not False:
424
+ for plane in other:
425
+ across = next(i for i in other if i != plane)
426
+ p1 = center.copy(); p2 = center.copy(); p1[axis] = p2[axis] = value
427
+ p1[plane] = p2[plane] = limits[plane][back[plane]]
428
+ p1[across] = limits[across][0]; p2[across] = limits[across][1]
429
+ g1, g2 = screen(p1), screen(p2)
430
+ if camera.visible(g1) and camera.visible(g2):
431
+ draw.line(g1, g2, color=draw.muted, alpha=.3)
432
+ if title_hidden:
433
+ continue
434
+ title_x, title_y = mid[0] + ox * fs * 3.4, mid[1] + oy * fs * 3.4
435
+ angle = math.degrees(math.atan2(sb[1] - sa[1], sb[0] - sa[0]))
436
+ upright = angle - 180 if angle > 90 else angle + 180 if angle < -90 else angle
437
+ draw.text(title_x, title_y + fs * .3, title, rotation=upright, center=(title_x, title_y))
438
+
439
+
440
+ def _draw_mesh(ax, scene, camera):
441
+ from matplotlib.collections import PolyCollection
442
+ from matplotlib.colors import to_rgba
443
+ from .surface import _face_shading, _shade_rgba
444
+ view = scene._view
445
+ polygons = []; colors = []; depths = []
446
+ for part in scene.parts:
447
+ vertices = part.vertices.copy()
448
+ for name, weight in view.get('states', {}).items():
449
+ if name in part.states: vertices += weight * (part.states[name] - part.vertices)
450
+ world = vertices @ scene.to_world[:3, :3].T
451
+ xy, depth = camera.project(world)
452
+ polygons.append(xy[part.faces]); depths.append(depth[part.faces].mean(axis=1))
453
+ if part.colors is not None:
454
+ rgba = part.colors[part.faces].mean(axis=1)
455
+ rgba[:, 3] *= to_rgba(part.color)[3] # alpha= on a coloured field
456
+ else:
457
+ rgba = np.tile(to_rgba(part.color), (len(part.faces), 1))
458
+ if scene.lighting == 'studio':
459
+ # Reframe into surface's (toward viewer, right, up) convention.
460
+ camera_vertices = np.column_stack([world @ camera.direction, world @ camera.right, world @ camera.up])
461
+ rgba = _shade_rgba(rgba, _face_shading(camera_vertices, part.faces, 1, (.8, -.4, .6), .35))
462
+ colors.append(rgba)
463
+ polygons = np.concatenate(polygons); colors = np.concatenate(colors); depths = np.concatenate(depths)
464
+ order = np.argsort(depths, kind='stable')
465
+ colors = colors[order]
466
+ # Opaque faces draw hairline edges in their own colour to close raster seams.
467
+ # Translucent faces must not: each edge would be blended twice and etch a
468
+ # triangle lattice into the part. (Aliased fills double-cover shared edges too.)
469
+ opaque = colors[:, 3] >= 1
470
+ edges = colors.copy(); edges[~opaque] = 0
471
+ # The camera sets both limits. Avoid scanning every triangle a second time to
472
+ # derive automatic limits that would immediately be overwritten.
473
+ ax.add_collection(PolyCollection(polygons[order], facecolors=colors, edgecolors=edges,
474
+ linewidths=np.where(opaque, .1, 0.), antialiased=True),
475
+ autolim=False)
476
+
477
+
478
+ def _draw_guides(draw, manifest, layout, camera):
479
+ """Title, legend, colorbar, scale bar and triad in Flux's slots."""
480
+ from matplotlib.colors import LinearSegmentedColormap, to_rgba
481
+ fs, line_height, vp = layout['fs'], layout['line_height'], layout['viewport']
482
+ style = manifest.get('style', {})
483
+ parts = {p['id']: p for p in manifest.get('parts', [])}
484
+ rotation = np.asarray(manifest['toWorld'], dtype=float).reshape(4, 4, order='F')[:3, :3]
485
+ triad = manifest.get('axes', {}).get('kind') == 'triad'
486
+ if triad:
487
+ # Bottom-left orientation gizmo (Flux: viewport corner inset by 30 px).
488
+ origin = (vp['x'] + 30, vp['y'] + vp['height'] - 30)
489
+ for axis, name in enumerate('xyz'):
490
+ direction = rotation[:, axis]
491
+ dx, dy = float(direction @ camera.right), -float(direction @ camera.up)
492
+ draw.line(origin, (origin[0] + dx * 22, origin[1] + dy * 22))
493
+ draw.text(origin[0] + dx * 32, origin[1] + dy * 32 + fs * .3, name)
494
+ ppu = camera.pixels_per_unit()
495
+ for slot in layout['scalebars']:
496
+ part = slot['part']
497
+ if not part.get('length') or ppu is None:
498
+ continue
499
+ length = part['length'] * ppu * float(np.linalg.norm(rotation[:, 0]))
500
+ x, y = slot['x'], slot['y']
501
+ if triad:
502
+ # The triad owns the bottom-left corner; the scale bar takes the bottom-right.
503
+ x = vp['x'] + vp['width'] - 12 - length
504
+ draw.line((x, y), (x + length, y), width=max(draw.lw, 1.5))
505
+ draw.text(x + length / 2, y - fs * .7, part.get('label') or _tick_label(part['length']))
506
+ for slot in layout['legends']:
507
+ for row in slot['legendRows']:
508
+ pid = row['id']
509
+ entry = parts.get(pid)
510
+ if entry is None:
511
+ continue
512
+ y = slot['y'] + row['offset']
513
+ color = to_rgba(entry.get('color', '#4385BE'))
514
+ color = (*color[:3], color[3] * entry.get('opacity', 1))
515
+ draw.polygon([(slot['x'], y - fs*.7), (slot['x'] + fs, y - fs*.7), (slot['x'] + fs, y + fs*.3),
516
+ (slot['x'], y + fs*.3)], facecolor=color, edgecolor='none')
517
+ for i, label in enumerate(row['lines']):
518
+ draw.text(slot['x'] + fs * 1.5, y + fs * .2 + i*line_height, label, anchor='start')
519
+ for slot in layout['colorbars']:
520
+ field = parts.get(slot['part'].get('field'), {}).get('field')
521
+ if not isinstance(field, dict):
522
+ continue
523
+ lo, hi = field['range']
524
+ x, y, w, h = slot['x'], slot['y'], slot['width'], slot['height']
525
+ cmap = LinearSegmentedColormap.from_list('preview', field['cmap']['stops'])
526
+ ramp = cmap(np.linspace(1, 0, 256))[:, None, :]
527
+ draw.ax.imshow(ramp, extent=(x, x + w, y + h, y), aspect='auto', interpolation='bilinear', zorder=1)
528
+ draw.polygon([(x, y), (x + w, y), (x + w, y + h), (x, y + h)], facecolor='none',
529
+ edgecolor=draw.muted, lw=draw.pt(draw.lw), zorder=2)
530
+ for value in _field_ticks(field):
531
+ if not lo <= value <= hi:
532
+ continue
533
+ ty = y + h * (.5 if hi == lo else 1 - (value - lo) / (hi - lo))
534
+ draw.line((x + w, ty), (x + w + 3, ty))
535
+ draw.text(x + w + 6, ty + fs * .3, _tick_label(value), anchor='start')
536
+ for i, label in enumerate(slot['titleLines']):
537
+ draw.text(x, y-fs*(1.05+1.25*(len(slot['titleLines'])-1-i)), label, anchor='start')
538
+ if layout['title']:
539
+ slot = layout['title']; part = slot['part']
540
+ draw.text(slot['x'] + slot['width'] / 2, slot['y'] + slot['height'] * .7,
541
+ part.get('text') or part.get('label') or '', size=style.get('titleSizePt', 8) * 4 / 3)
542
+
543
+
544
+ def png_preview(scene, *, _manifest=None):
545
+ """Static orthographic/perspective painter still of the notebook viewer, at 2× (HiDPI).
546
+
547
+ Furniture follows Flux's layout rules. The interactive notebook and Flux views
548
+ use a depth buffer; this fallback sorts all faces together, so intersecting
549
+ transparent surfaces remain an approximate painter view.
550
+ """
551
+ from matplotlib.figure import Figure
552
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
553
+ from .scene3d_manifest import build_manifest
554
+ from .glb import write_glb
555
+ if not scene.parts: raise ValueError(EMPTY_MESSAGE)
556
+ man = _manifest if _manifest is not None else build_manifest(scene, write_glb(scene), 'preview.glb')
557
+ width, height = scene.figsize[0] * PX_PER_INCH, scene.figsize[1] * PX_PER_INCH
558
+ layout = _layout(man, width, height)
559
+ if layout['overflow']:
560
+ warnings.warn('Enlarge scene3d figsize to fit guide labels at the current font size', stacklevel=2)
561
+ vp = layout['viewport']
562
+ camera = _Camera(scene._view, _framing_bounds(scene, man), vp)
563
+ dpi = PX_PER_INCH * HIDPI
564
+ fig = Figure(figsize=scene.figsize, dpi=dpi, layout='none'); FigureCanvasAgg(fig)
565
+ fig.patch.set_facecolor('white')
566
+ # Mesh axes first (fig.axes[0]): camera-plane units over exactly the viewport.
567
+ ax = fig.add_axes([vp['x'] / width, 1 - (vp['y'] + vp['height']) / height,
568
+ vp['width'] / width, vp['height'] / height], zorder=1)
569
+ ax.set_xlim(-camera.half_width, camera.half_width); ax.set_ylim(-camera.half_height, camera.half_height)
570
+ ax.set_axis_off(); ax.patch.set_alpha(0)
571
+ # Furniture layers in CSS px (y down): 'under' sits behind the mesh, 'over' in front.
572
+ layers = []
573
+ for zorder in (0, 2):
574
+ layer = fig.add_axes([0, 0, 1, 1], zorder=zorder)
575
+ layer.set_xlim(0, width); layer.set_ylim(height, 0); layer.set_axis_off(); layer.patch.set_alpha(0)
576
+ layers.append(_Furniture(layer, man.get('style', scene.style), layout['fs']))
577
+ under, over = layers
578
+ _draw_mesh(ax, scene, camera)
579
+ if man.get('axes', {}).get('kind') == 'box':
580
+ _draw_box_axes(under, man, camera, layout['fs'])
581
+ _draw_guides(over, man, layout, camera)
582
+ stream = io.BytesIO(); fig.savefig(stream, format='png', transparent=False, facecolor='white', dpi=dpi)
583
+ return stream.getvalue()
584
+
585
+
586
+ def viewer_bundle():
587
+ folder=files('fluxplot').joinpath('_viewer')
588
+ source=folder.joinpath('flux-model3d-viewer.min.js').read_bytes()
589
+ stamp=json.loads(folder.joinpath('stamp.json').read_text())
590
+ if hashlib.sha256(source).hexdigest()!=stamp['sha256']: raise RuntimeError('Flux notebook viewer stamp mismatch; run scripts/sync_flux_viewer.py')
591
+ return source.decode('utf8')
592
+
593
+
594
+ def display_size(scene):
595
+ """CSS size of the notebook viewer and of its PNG still."""
596
+ return round(scene.figsize[0] * PX_PER_INCH), round(scene.figsize[1] * PX_PER_INCH)
597
+
598
+
599
+ def mimebundle_metadata(scene, bundle):
600
+ """Show the 2× PNG at its CSS size (Jupyter/VS Code honour image width/height)."""
601
+ if 'image/png' not in bundle:
602
+ return {}
603
+ width, height = display_size(scene)
604
+ return {'image/png': {'width': width, 'height': height}}
605
+
606
+
607
+ def mimebundle(scene,*,static=False):
608
+ from .glb import write_glb
609
+ from .scene3d_manifest import build_manifest
610
+ if not scene.parts:
611
+ return {'text/plain': EMPTY_MESSAGE}
612
+ preview=preview_scene(scene)
613
+ # One immutable preparation per representation; Scene3D remains mutable
614
+ # between calls, so this must never become a cross-call cache.
615
+ data=write_glb(preview)
616
+ manifest=build_manifest(preview,data,'preview.glb')
617
+ png=png_preview(preview,_manifest=manifest)
618
+ bundle={'image/png':png}
619
+ if static: return bundle
620
+ if len(data)>30*1024**2: warnings.warn(f'Notebook preview is {len(data)/1024**2:.1f} MiB; install fluxplot[mesh] or lower preview_max_faces',stacklevel=3)
621
+ try: runtime=viewer_bundle()
622
+ except FileNotFoundError:
623
+ warnings.warn('Interactive 3D viewer is not bundled yet; displaying PNG fallback',stacklevel=3)
624
+ return bundle
625
+ if '</script' in runtime.lower(): raise RuntimeError('viewer bundle contains an unsafe script terminator')
626
+ width,height=display_size(scene)
627
+ payload=json.dumps({'glb':base64.b64encode(data).decode(),'manifest':manifest,'width':width,'height':height},allow_nan=False).replace('<','\\u003c').replace('\u2028','\\u2028').replace('\u2029','\\u2029')
628
+ fallback=base64.b64encode(png).decode()
629
+ image=f'<img alt="3D plot preview" width="{width}" height="{height}" style="max-width:100%;height:auto" src="data:image/png;base64,{fallback}" />'
630
+ # currentScript belongs to each output even in a renderer's shadow root. Capture it
631
+ # synchronously; a document-global id lookup can select another notebook output.
632
+ bundle['text/html']=f'''<div class="fluxplot-scene3d"><div data-fluxplot-scene3d-host>{image}</div><script>(()=>{{const script=document.currentScript;const host=script.parentElement.querySelector('[data-fluxplot-scene3d-host]');const fallback=host.innerHTML;{runtime}\nFluxModel3dViewer.mount(host,{payload}).then(view=>{{if(view.available===false)host.innerHTML=fallback;}}).catch(error=>{{host.innerHTML=fallback;host.title=String(error);}});}})();</script></div>'''
633
+ return bundle
File without changes