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