fluxplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/fits.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""``fp.regression`` and ``fp.kde``: fitted curves drawn as addressable series with the fit recorded.
|
|
2
|
+
|
|
3
|
+
A regression is three parts of one series — the points (``<series>.point.k``), the fit line
|
|
4
|
+
(``<series>.fit``) and its confidence band (``<series>.band``) — and a ``regression`` payload in
|
|
5
|
+
the manifest: the coefficients, R², the p-value, n and the confidence level, so a caption or a
|
|
6
|
+
consumer can state the fit exactly as drawn. A KDE is a line (and optionally its fill) with the
|
|
7
|
+
evaluation grid and density recorded, so it can be re-drawn without the raw values.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from . import data as _data
|
|
17
|
+
from . import tagger as _tagger
|
|
18
|
+
from .descriptors import Mark
|
|
19
|
+
|
|
20
|
+
__all__ = ["regression", "kde", "RegressionResult", "KdeResult", "lowess", "REGRESSION_KINDS"]
|
|
21
|
+
|
|
22
|
+
REGRESSION_KINDS = ("linear", "poly", "lowess")
|
|
23
|
+
_BOOT_SEED = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RegressionResult:
|
|
28
|
+
ax: Any
|
|
29
|
+
#: the fit ``Line2D``, the band ``PolyCollection`` (or ``None``) and the points (or ``None``)
|
|
30
|
+
line: Any
|
|
31
|
+
band: Any
|
|
32
|
+
points: Any
|
|
33
|
+
#: the evaluation grid and the fitted curve with its confidence bounds
|
|
34
|
+
grid: np.ndarray = field(repr=False, default=None)
|
|
35
|
+
fit: np.ndarray = field(repr=False, default=None)
|
|
36
|
+
lower: np.ndarray = field(repr=False, default=None)
|
|
37
|
+
upper: np.ndarray = field(repr=False, default=None)
|
|
38
|
+
#: ``{kind, degree, coefficients, r2, p, n, ci, …}`` as the manifest records it
|
|
39
|
+
stats: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class KdeResult:
|
|
44
|
+
ax: Any
|
|
45
|
+
line: Any
|
|
46
|
+
area: Any
|
|
47
|
+
grid: np.ndarray = field(repr=False, default=None)
|
|
48
|
+
density: np.ndarray = field(repr=False, default=None)
|
|
49
|
+
stats: Dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clean_xy(x, y, who):
|
|
53
|
+
x = np.asarray(x, dtype=float).ravel()
|
|
54
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
55
|
+
if x.size != y.size:
|
|
56
|
+
raise ValueError(f"{who}: x and y have different lengths ({x.size} vs {y.size})")
|
|
57
|
+
ok = np.isfinite(x) & np.isfinite(y)
|
|
58
|
+
if ok.sum() < 3:
|
|
59
|
+
raise ValueError(f"{who} needs at least 3 finite (x, y) pairs, got {int(ok.sum())}")
|
|
60
|
+
return x[ok], y[ok]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def lowess(x, y, grid, frac=2 / 3, it=3):
|
|
64
|
+
"""Cleveland's (1979) locally weighted regression: at each grid point a linear fit weighted by
|
|
65
|
+
the tricube of distance to the nearest ``frac`` of the data, with ``it`` robustifying
|
|
66
|
+
iterations that down-weight large residuals (bisquare). Returns the smooth on ``grid``."""
|
|
67
|
+
x = np.asarray(x, dtype=float)
|
|
68
|
+
y = np.asarray(y, dtype=float)
|
|
69
|
+
grid = np.asarray(grid, dtype=float)
|
|
70
|
+
n = x.size
|
|
71
|
+
k = max(int(np.ceil(frac * n)), 2)
|
|
72
|
+
robust = np.ones(n)
|
|
73
|
+
|
|
74
|
+
def fit_at(x0, weights_extra):
|
|
75
|
+
d = np.abs(x - x0)
|
|
76
|
+
h = np.sort(d)[min(k, n) - 1] or 1e-12
|
|
77
|
+
w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3 * weights_extra
|
|
78
|
+
sw = w.sum()
|
|
79
|
+
if sw <= 0:
|
|
80
|
+
return np.nan
|
|
81
|
+
xm = (w * x).sum() / sw
|
|
82
|
+
ym = (w * y).sum() / sw
|
|
83
|
+
var = (w * (x - xm) ** 2).sum()
|
|
84
|
+
slope = (w * (x - xm) * (y - ym)).sum() / var if var > 0 else 0.0
|
|
85
|
+
return ym + slope * (x0 - xm)
|
|
86
|
+
|
|
87
|
+
for _ in range(max(it, 0)):
|
|
88
|
+
fitted = np.array([fit_at(xi, robust) for xi in x])
|
|
89
|
+
resid = y - fitted
|
|
90
|
+
s = np.median(np.abs(resid)) or 1e-12
|
|
91
|
+
robust = np.clip(1 - (resid / (6 * s)) ** 2, 0, 1) ** 2
|
|
92
|
+
return np.array([fit_at(g, robust) for g in grid])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _poly_ci(x, y, grid, degree, ci):
|
|
96
|
+
"""Polynomial least squares with the confidence band of the mean response."""
|
|
97
|
+
from scipy import stats as _sp
|
|
98
|
+
n = x.size
|
|
99
|
+
dof = n - (degree + 1)
|
|
100
|
+
if dof < 1:
|
|
101
|
+
raise ValueError(f"regression: degree {degree} needs more than {degree + 1} points (n={n})")
|
|
102
|
+
coef, cov = np.polyfit(x, y, degree, cov=True)
|
|
103
|
+
fit_grid = np.polyval(coef, grid)
|
|
104
|
+
fitted = np.polyval(coef, x)
|
|
105
|
+
design = np.vander(grid, degree + 1) # highest power first, as polyfit's coefficients
|
|
106
|
+
se = np.sqrt(np.einsum("ij,jk,ik->i", design, cov, design))
|
|
107
|
+
t = _sp.t.ppf(0.5 + ci / 2, dof)
|
|
108
|
+
ss_res = float(((y - fitted) ** 2).sum())
|
|
109
|
+
ss_tot = float(((y - y.mean()) ** 2).sum())
|
|
110
|
+
r2 = 1 - ss_res / ss_tot if ss_tot > 0 else float("nan")
|
|
111
|
+
if degree == 1:
|
|
112
|
+
p = float(_sp.linregress(x, y).pvalue)
|
|
113
|
+
else:
|
|
114
|
+
f = ((ss_tot - ss_res) / degree) / (ss_res / dof) if ss_res > 0 else float("inf")
|
|
115
|
+
p = float(_sp.f.sf(f, degree, dof))
|
|
116
|
+
return coef, fit_grid, fit_grid - t * se, fit_grid + t * se, r2, p, dof
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def regression(ax, x, y, *, series, kind="linear", ci=0.95, degree=1, frac=2 / 3, points=True, n_grid=100,
|
|
120
|
+
n_boot=200, label=None, color=None, band_kw=None, point_kw=None, **line_kw) -> RegressionResult:
|
|
121
|
+
"""Fit ``y ~ x`` and draw the fit line, its confidence band and (by default) the points as one
|
|
122
|
+
series, recording the fit in the manifest.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
kind
|
|
127
|
+
``"linear"`` (a straight line), ``"poly"`` (a polynomial of ``degree``) or ``"lowess"``
|
|
128
|
+
(Cleveland's locally weighted regression with span ``frac``).
|
|
129
|
+
ci
|
|
130
|
+
The confidence level of the band: for linear / poly the t-interval of the mean response;
|
|
131
|
+
for lowess a seeded percentile bootstrap of the smooth (``n_boot`` resamples).
|
|
132
|
+
points
|
|
133
|
+
Draw the observations as ``<series>.point.k`` too.
|
|
134
|
+
color
|
|
135
|
+
The series colour (line, band and points); default the cycle's next colour.
|
|
136
|
+
band_kw, point_kw, **line_kw
|
|
137
|
+
Extra keywords for the band (``fill_between``), the points (``scatter``) and the fit line.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
RegressionResult
|
|
142
|
+
With ``stats = {kind, degree, coefficients, r2, p, n, ci, frac?}`` (``coefficients`` in
|
|
143
|
+
``numpy.polyfit`` order, highest power first; ``None`` for lowess, whose ``p`` is ``None``).
|
|
144
|
+
"""
|
|
145
|
+
from .api import _series_color, band as _band, scatter as _scatter
|
|
146
|
+
if kind not in REGRESSION_KINDS:
|
|
147
|
+
raise ValueError(f"regression: kind must be one of {REGRESSION_KINDS}, got {kind!r}")
|
|
148
|
+
if not 0 < ci < 1:
|
|
149
|
+
raise ValueError(f"regression: ci must lie in (0, 1), got {ci!r}")
|
|
150
|
+
xs, ys = _clean_xy(x, y, "regression")
|
|
151
|
+
grid = np.linspace(xs.min(), xs.max(), int(n_grid))
|
|
152
|
+
deg = 1 if kind == "linear" else int(degree)
|
|
153
|
+
if kind in ("linear", "poly"):
|
|
154
|
+
coef, fit, lo, hi, r2, p, dof = _poly_ci(xs, ys, grid, deg, ci)
|
|
155
|
+
stats = {"kind": kind, "degree": deg, "coefficients": [float(c) for c in coef], "r2": float(r2), "p": p,
|
|
156
|
+
"n": int(xs.size), "ci": float(ci), "dof": int(dof)}
|
|
157
|
+
else:
|
|
158
|
+
fit = lowess(xs, ys, grid, frac=frac)
|
|
159
|
+
rng = np.random.default_rng(_BOOT_SEED)
|
|
160
|
+
draws = []
|
|
161
|
+
for _ in range(int(n_boot)):
|
|
162
|
+
idx = rng.integers(0, xs.size, xs.size)
|
|
163
|
+
draws.append(lowess(xs[idx], ys[idx], grid, frac=frac))
|
|
164
|
+
draws = np.array(draws)
|
|
165
|
+
lo, hi = np.nanpercentile(draws, [50 * (1 - ci), 50 * (1 + ci)], axis=0)
|
|
166
|
+
fitted = np.interp(xs, grid, fit)
|
|
167
|
+
ss_res = float(((ys - fitted) ** 2).sum())
|
|
168
|
+
ss_tot = float(((ys - ys.mean()) ** 2).sum())
|
|
169
|
+
stats = {"kind": "lowess", "degree": None, "coefficients": None,
|
|
170
|
+
"r2": 1 - ss_res / ss_tot if ss_tot > 0 else float("nan"), "p": None, "n": int(xs.size),
|
|
171
|
+
"ci": float(ci), "frac": float(frac), "bootstrap": int(n_boot)}
|
|
172
|
+
|
|
173
|
+
kw = dict(line_kw)
|
|
174
|
+
if color is not None:
|
|
175
|
+
kw["color"] = color
|
|
176
|
+
_series_color(series, kw)
|
|
177
|
+
the_color = kw.get("color")
|
|
178
|
+
reg = _tagger.registry_for(ax.figure)
|
|
179
|
+
# the fit is registered first, so the series is a "regression" (its first mark names its kind);
|
|
180
|
+
# drawn first too, it still sits above the points (a line's zorder beats a collection's)
|
|
181
|
+
(ln,) = ax.plot(grid, fit, label=label, **kw)
|
|
182
|
+
if the_color is None:
|
|
183
|
+
the_color = ln.get_color()
|
|
184
|
+
payload = {**stats, "grid": _data.values(grid), "fit": _data.values(fit)}
|
|
185
|
+
reg.add(Mark(role="line", series=series, name="fit", kind="regression", x=_data.values(grid), y=_data.values(fit),
|
|
186
|
+
label=label, artists=[ln], data={"regression": payload}))
|
|
187
|
+
pts = None
|
|
188
|
+
if points:
|
|
189
|
+
pkw = dict(point_kw or {})
|
|
190
|
+
pkw.setdefault("s", 14)
|
|
191
|
+
pkw.setdefault("alpha", 0.6)
|
|
192
|
+
pkw.setdefault("color", the_color)
|
|
193
|
+
pts = _scatter(ax, xs, ys, series=series, **pkw)
|
|
194
|
+
bkw = dict(band_kw or {})
|
|
195
|
+
bkw.setdefault("color", the_color)
|
|
196
|
+
band = _band(ax, grid, lo, hi, series=series, what=f"{ci:.0%} CI", **bkw)
|
|
197
|
+
return RegressionResult(ax=ax, line=ln, band=band, points=pts, grid=grid, fit=fit, lower=np.asarray(lo),
|
|
198
|
+
upper=np.asarray(hi), stats=stats)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def kde(ax, values, *, series, bw="scott", fill=False, n_grid=200, cut=3.0, label=None, color=None,
|
|
202
|
+
fill_kw=None, **line_kw) -> KdeResult:
|
|
203
|
+
"""A Gaussian kernel density estimate drawn as a line (``<series>.line``; ``fill=True`` adds
|
|
204
|
+
``<series>.fill``), recording the grid, the density and the bandwidth in the manifest.
|
|
205
|
+
|
|
206
|
+
``bw`` is scipy's ``bw_method`` (``"scott"``, ``"silverman"``, a number or a callable);
|
|
207
|
+
the grid runs ``cut`` bandwidths past the data on either side.
|
|
208
|
+
"""
|
|
209
|
+
from scipy import stats as _sp
|
|
210
|
+
from .api import _series_color
|
|
211
|
+
v = np.asarray(values, dtype=float).ravel()
|
|
212
|
+
v = v[np.isfinite(v)]
|
|
213
|
+
if v.size < 2:
|
|
214
|
+
raise ValueError(f"kde needs at least 2 finite values, got {v.size}")
|
|
215
|
+
if v.std(ddof=1) == 0:
|
|
216
|
+
raise ValueError("kde: the values have zero variance")
|
|
217
|
+
estimator = _sp.gaussian_kde(v, bw_method=bw)
|
|
218
|
+
bandwidth = float(estimator.factor * v.std(ddof=1))
|
|
219
|
+
grid = np.linspace(v.min() - cut * bandwidth, v.max() + cut * bandwidth, int(n_grid))
|
|
220
|
+
density = estimator(grid)
|
|
221
|
+
kw = dict(line_kw)
|
|
222
|
+
if color is not None:
|
|
223
|
+
kw["color"] = color
|
|
224
|
+
_series_color(series, kw)
|
|
225
|
+
(ln,) = ax.plot(grid, density, label=label, **kw)
|
|
226
|
+
reg = _tagger.registry_for(ax.figure)
|
|
227
|
+
payload = {"grid": _data.values(grid), "density": _data.values(density), "bandwidth": bandwidth,
|
|
228
|
+
"method": bw if isinstance(bw, str) else ("callable" if callable(bw) else float(bw)), "n": int(v.size)}
|
|
229
|
+
reg.add(Mark(role="line", series=series, kind="kde", live_data=True, x=None, y=None, label=label, artists=[ln],
|
|
230
|
+
data={"kde": payload}))
|
|
231
|
+
area = None
|
|
232
|
+
if fill:
|
|
233
|
+
fkw = dict(fill_kw or {})
|
|
234
|
+
fkw.setdefault("alpha", 0.25)
|
|
235
|
+
fkw.setdefault("linewidth", 0)
|
|
236
|
+
fkw.setdefault("color", ln.get_color())
|
|
237
|
+
area = ax.fill_between(grid, 0.0, density, **fkw)
|
|
238
|
+
reg.add(Mark(role="area", series=series, name="fill", kind="kde", artists=[area],
|
|
239
|
+
data={"band": {"x": _data.values(grid), "y1": _data.values(density), "y2": [0.0] * int(n_grid)}}))
|
|
240
|
+
return KdeResult(ax=ax, line=ln, area=area, grid=grid, density=density, stats=payload)
|
fluxplot/glb.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Deterministic numpy-only binary glTF 2.0 writer (scene3d contract 0.1.0)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import struct
|
|
5
|
+
import numpy as np
|
|
6
|
+
from matplotlib.colors import to_rgba
|
|
7
|
+
from .version import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def linear_rgb(rgb):
|
|
11
|
+
rgb=np.asarray(rgb,dtype=float)
|
|
12
|
+
return np.where(rgb<=.04045,rgb/12.92,((rgb+.055)/1.055)**2.4)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def vertex_normals(vertices, faces):
|
|
16
|
+
out=np.zeros_like(vertices,dtype=np.float64)
|
|
17
|
+
# Convert before subtraction/cross products: finite float32 coordinates can
|
|
18
|
+
# overflow float32 area products while their float64 products remain finite.
|
|
19
|
+
t=np.asarray(vertices,dtype=np.float64)[faces]
|
|
20
|
+
area=np.cross(t[:,1]-t[:,0],t[:,2]-t[:,0])
|
|
21
|
+
for i in range(3): np.add.at(out,faces[:,i],area)
|
|
22
|
+
size=np.linalg.norm(out,axis=1)
|
|
23
|
+
out[size==0]=(0,1,0)
|
|
24
|
+
return (out/np.linalg.norm(out,axis=1,keepdims=True)).astype('<f4')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _finite32(a, name):
|
|
28
|
+
with np.errstate(over='ignore'): a=np.asarray(a,dtype='<f4')
|
|
29
|
+
if not np.isfinite(a).all(): raise ValueError(f'{name} cannot be represented by finite float32 values')
|
|
30
|
+
return a
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_glb(scene):
|
|
34
|
+
"""Serialize one GLB. No timestamps, external resources, textures, or vertex welding."""
|
|
35
|
+
if not scene.parts: raise ValueError('cannot save an empty Scene3D; add a mesh first with fp.mesh3d(sc, ...)')
|
|
36
|
+
scene._resolve_pending_view(strict=True)
|
|
37
|
+
binary=bytearray()
|
|
38
|
+
doc=dict(asset={'version':'2.0','generator':f'fluxplot {__version__}'},buffers=[{}],bufferViews=[],accessors=[],meshes=[],nodes=[],materials=[],scenes=[{'nodes':list(range(len(scene.parts)))}],scene=0)
|
|
39
|
+
def accessor(a, kind, component, *, bounds=False, normalized=False, target=None, stride=None):
|
|
40
|
+
a=np.ascontiguousarray(a); binary.extend(b'\0'*(-len(binary)%4))
|
|
41
|
+
view={'buffer':0,'byteOffset':len(binary),'byteLength':a.nbytes}
|
|
42
|
+
if target is not None: view['target']=target
|
|
43
|
+
if stride is not None: view['byteStride']=stride
|
|
44
|
+
doc['bufferViews'].append(view); binary.extend(a.tobytes())
|
|
45
|
+
entry={'bufferView':len(doc['bufferViews'])-1,'componentType':component,'count':len(a),'type':kind}
|
|
46
|
+
if bounds: entry.update(min=a.min(axis=0).tolist(),max=a.max(axis=0).tolist())
|
|
47
|
+
if normalized: entry['normalized']=True
|
|
48
|
+
doc['accessors'].append(entry)
|
|
49
|
+
return len(doc['accessors'])-1
|
|
50
|
+
rotation=scene.to_world[:3,:3]
|
|
51
|
+
for part in scene.parts:
|
|
52
|
+
v=_finite32(part.vertices@rotation.T,'positions')
|
|
53
|
+
f=part.faces.astype('<u2' if part.faces.max()<65536 else '<u4')
|
|
54
|
+
n=vertex_normals(v,f)
|
|
55
|
+
attrs={'POSITION':accessor(v,'VEC3',5126,bounds=True,target=34962),'NORMAL':accessor(n,'VEC3',5126,target=34962)}
|
|
56
|
+
if part.values is not None:
|
|
57
|
+
valid=np.isfinite(part.values)
|
|
58
|
+
value=_finite32(np.where(valid,part.values,0),'values')
|
|
59
|
+
attrs['_VALUE']=accessor(value,'SCALAR',5126,target=34962)
|
|
60
|
+
if not valid.all(): attrs['_VALID']=accessor(np.column_stack([valid,np.zeros((len(valid),3),dtype='u1')]).astype('u1'),'SCALAR',5121,target=34962,stride=4)
|
|
61
|
+
if part.colors is not None:
|
|
62
|
+
rgba=np.asarray(part.colors,dtype=float).copy(); rgba[:,:3]=linear_rgb(rgba[:,:3])
|
|
63
|
+
attrs['COLOR_0']=accessor(np.rint(np.clip(rgba,0,1)*255).astype('u1'),'VEC4',5121,normalized=True,target=34962)
|
|
64
|
+
rgba=to_rgba(part.color); base=[*linear_rgb(rgba[:3]).tolist(),rgba[3]]
|
|
65
|
+
# Vertex colors already carry the complete map; white avoids tinting them twice.
|
|
66
|
+
# The part colour's alpha (fp.surface3d(alpha=...)) still applies as opacity.
|
|
67
|
+
if part.colors is not None: base=[1,1,1,rgba[3] if rgba[3]<1 else 1]
|
|
68
|
+
material={'name':part.id,'pbrMetallicRoughness':{'baseColorFactor':base,'metallicFactor':0,'roughnessFactor':.6},'doubleSided':True}
|
|
69
|
+
if rgba[3]<1 or (part.colors is not None and (part.colors[:,3]<1).any()): material['alphaMode']='BLEND'
|
|
70
|
+
doc['materials'].append(material)
|
|
71
|
+
prim={'attributes':attrs,'indices':accessor(f.ravel(),'SCALAR',5123 if f.dtype.itemsize==2 else 5125,target=34963),'material':len(doc['materials'])-1,'mode':4}
|
|
72
|
+
mesh={'name':part.id,'primitives':[prim]}
|
|
73
|
+
if part.states:
|
|
74
|
+
prim['targets']=[]
|
|
75
|
+
for name,target in part.states.items():
|
|
76
|
+
world=_finite32(target@rotation.T,'shape positions')
|
|
77
|
+
prim['targets'].append({'POSITION':accessor(_finite32(world-v,'shape deltas'),'VEC3',5126,bounds=True,target=34962),'NORMAL':accessor(vertex_normals(world,f)-n,'VEC3',5126,target=34962)})
|
|
78
|
+
mesh['extras']={'targetNames':list(part.states)}
|
|
79
|
+
mesh['weights']=[scene._view.get('states',{}).get(name,0) for name in part.states]
|
|
80
|
+
doc['meshes'].append(mesh); doc['nodes'].append({'name':part.id,'mesh':len(doc['meshes'])-1})
|
|
81
|
+
doc['buffers'][0]['byteLength']=len(binary)
|
|
82
|
+
js=json.dumps(doc,sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf8')
|
|
83
|
+
js+=b' '*(-len(js)%4); binary.extend(b'\0'*(-len(binary)%4))
|
|
84
|
+
return struct.pack('<4sII',b'glTF',2,28+len(js)+len(binary))+struct.pack('<I4s',len(js),b'JSON')+js+struct.pack('<I4s',len(binary),b'BIN\0')+binary
|
fluxplot/ids.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Deterministic semantic IDs — the public, long-lived join key (spec §7, open Q6).
|
|
2
|
+
|
|
3
|
+
An id is a dotted path of `[a-z0-9-]` segments; the dot is the only separator and the index is
|
|
4
|
+
0-based:
|
|
5
|
+
|
|
6
|
+
control.line control.point.3 axis.x.title axis.x.tick.2 legend annotation.peak
|
|
7
|
+
|
|
8
|
+
matplotlib's own ids use ``_`` + hex hashes (``line2d_7``, ``p3dcf139bf4``) and never contain dots,
|
|
9
|
+
so this namespace is provably disjoint from anything matplotlib autogenerates.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Structural roots that stand alone at the head of an id (not series-scoped).
|
|
16
|
+
STRUCTURAL_ROOTS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"figure",
|
|
19
|
+
"panel",
|
|
20
|
+
"plot-area",
|
|
21
|
+
"axis",
|
|
22
|
+
"legend",
|
|
23
|
+
"colorbar",
|
|
24
|
+
"title",
|
|
25
|
+
"background",
|
|
26
|
+
"annotation",
|
|
27
|
+
"reference-line",
|
|
28
|
+
"highlight-region",
|
|
29
|
+
"significance-bracket",
|
|
30
|
+
"label",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_SLUG_DROP = re.compile(r"[^a-z0-9-]+")
|
|
35
|
+
_SLUG_DASHES = re.compile(r"-+")
|
|
36
|
+
_SEGMENT_RE = re.compile(r"^[a-z0-9-]+$")
|
|
37
|
+
|
|
38
|
+
# Characters that are spelled out rather than dropped: Greek letters (α → alpha), the micro sign,
|
|
39
|
+
# degrees, percent and the common maths signs scientific names carry.
|
|
40
|
+
TRANSLITERATE = {
|
|
41
|
+
"α": "alpha", "β": "beta", "γ": "gamma", "δ": "delta", "ε": "epsilon", "ζ": "zeta", "η": "eta",
|
|
42
|
+
"θ": "theta", "ι": "iota", "κ": "kappa", "λ": "lambda", "μ": "mu", "ν": "nu", "ξ": "xi",
|
|
43
|
+
"ο": "omicron", "π": "pi", "ρ": "rho", "σ": "sigma", "ς": "sigma", "τ": "tau", "υ": "upsilon",
|
|
44
|
+
"φ": "phi", "χ": "chi", "ψ": "psi", "ω": "omega", "ϵ": "epsilon", "ϑ": "theta", "ϕ": "phi",
|
|
45
|
+
"µ": "mu", "°": "deg", "%": "pct", "‰": "permille", "±": "pm", "×": "x", "∞": "inf",
|
|
46
|
+
"→": "to", "←": "from", "≤": "le", "≥": "ge", "≠": "ne", "−": "-", "–": "-", "—": "-", "‑": "-",
|
|
47
|
+
"&": "and", "+": "plus",
|
|
48
|
+
}
|
|
49
|
+
# Punctuation whose loss never confuses two names: sentence marks and quotes.
|
|
50
|
+
_SOFT_DROP = set(".,;:!?'\u2019\"`")
|
|
51
|
+
_HASH_LEN = 6
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fold(name: str) -> str:
|
|
55
|
+
"""Lowercase, accents stripped, superscripts/subscripts and Greek spelled out."""
|
|
56
|
+
import unicodedata
|
|
57
|
+
out = []
|
|
58
|
+
for ch in unicodedata.normalize("NFKD", name):
|
|
59
|
+
if unicodedata.category(ch) == "Mn": # a combining accent
|
|
60
|
+
continue
|
|
61
|
+
low = ch.lower()
|
|
62
|
+
out.append(TRANSLITERATE.get(low, low))
|
|
63
|
+
return "".join(out)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _slug_parts(name: object):
|
|
67
|
+
raw = str(name).strip()
|
|
68
|
+
folded = _fold(raw).replace("_", "-").replace(" ", "-")
|
|
69
|
+
dropped = [ch for ch in set(_SLUG_DROP.findall(folded.replace("-", ""))) for ch in ch]
|
|
70
|
+
lossy = any(ch not in _SOFT_DROP and not ch.isspace() for ch in dropped)
|
|
71
|
+
s = _SLUG_DROP.sub("-", folded)
|
|
72
|
+
s = _SLUG_DASHES.sub("-", s).strip("-")
|
|
73
|
+
return raw, s, lossy
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def slugify(name: object) -> str:
|
|
77
|
+
"""Turn an arbitrary series/category name into a stable id segment.
|
|
78
|
+
|
|
79
|
+
Deterministic: accents folded, Greek letters / ``µ`` / ``°`` / ``%`` and super- or subscript
|
|
80
|
+
digits spelled out (``α`` → ``alpha``, ``CO₂`` → ``co2``), lowercase, spaces/underscores to
|
|
81
|
+
``-``, other characters dropped, repeated ``-`` collapsed and trimmed. When dropping
|
|
82
|
+
characters lost information that could make two names collide (brackets, slashes, symbols —
|
|
83
|
+
``IL-6 (pg/mL)`` vs ``IL-6 [pg/mL]``), or when nothing legible is left, a 6-hex-digit hash of
|
|
84
|
+
the original name is appended, so distinct names give distinct, stable ids. Empty input falls
|
|
85
|
+
back to ``series``.
|
|
86
|
+
"""
|
|
87
|
+
raw, s, lossy = _slug_parts(name)
|
|
88
|
+
if raw and (not s or s == "series" or lossy):
|
|
89
|
+
import hashlib
|
|
90
|
+
s = (s or "series") + "-" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:_HASH_LEN]
|
|
91
|
+
return s or "series"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def legacy_slugify(name: object) -> str:
|
|
95
|
+
"""The pre-0.3.2 slug (lowercase, ``_``/space → ``-``, everything else dropped): what an
|
|
96
|
+
older manifest called the same series, for ``idAliases``."""
|
|
97
|
+
s = str(name).strip().lower().replace("_", "-").replace(" ", "-")
|
|
98
|
+
s = _SLUG_DROP.sub("-", s)
|
|
99
|
+
s = _SLUG_DASHES.sub("-", s).strip("-")
|
|
100
|
+
return s or "series"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_valid_segment(seg: str) -> bool:
|
|
104
|
+
return bool(_SEGMENT_RE.match(seg))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def join(*segments: object) -> str:
|
|
108
|
+
"""Compose an id from segments, validating each is a legal segment."""
|
|
109
|
+
parts = []
|
|
110
|
+
for seg in segments:
|
|
111
|
+
if seg is None:
|
|
112
|
+
continue
|
|
113
|
+
seg = str(seg)
|
|
114
|
+
if not is_valid_segment(seg):
|
|
115
|
+
raise ValueError(f"invalid id segment: {seg!r}")
|
|
116
|
+
parts.append(seg)
|
|
117
|
+
if not parts:
|
|
118
|
+
raise ValueError("an id needs at least one segment")
|
|
119
|
+
return ".".join(parts)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def series_root(series: object) -> str:
|
|
123
|
+
"""Slug for a series, disambiguated from structural roots.
|
|
124
|
+
|
|
125
|
+
A series literally named "legend" becomes ``legend-series`` so structural roots stay unambiguous.
|
|
126
|
+
"""
|
|
127
|
+
slug = slugify(series)
|
|
128
|
+
return f"{slug}-series" if slug in STRUCTURAL_ROOTS else slug
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def series_id(series: object, role: str | None = None, index: int | None = None) -> str:
|
|
132
|
+
"""``control`` → ``control``; +role → ``control.line``; +index → ``control.point.3``."""
|
|
133
|
+
segs: list[object] = [series_root(series)]
|
|
134
|
+
if role is not None:
|
|
135
|
+
segs.append(role)
|
|
136
|
+
if index is not None:
|
|
137
|
+
segs.append(int(index))
|
|
138
|
+
return join(*segs)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def axis_id(which: str, part: str | None = None, index: int | None = None) -> str:
|
|
142
|
+
"""``axis.x``, ``axis.x.title``, ``axis.x.tick.2`` (which ∈ x, y, x2, y2)."""
|
|
143
|
+
segs: list[object] = ["axis", which]
|
|
144
|
+
if part is not None:
|
|
145
|
+
segs.append(part)
|
|
146
|
+
if index is not None:
|
|
147
|
+
segs.append(int(index))
|
|
148
|
+
return join(*segs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class IdAllocator:
|
|
152
|
+
"""Per-plot id allocation with deterministic collision handling.
|
|
153
|
+
|
|
154
|
+
Same inputs → same ids. If a candidate id is already taken, later ones get a deterministic
|
|
155
|
+
``-2``, ``-3``, … suffix on the final segment (by insertion order).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
self._seen: set[str] = set()
|
|
160
|
+
|
|
161
|
+
def take(self, candidate: str) -> str:
|
|
162
|
+
if candidate not in self._seen:
|
|
163
|
+
self._seen.add(candidate)
|
|
164
|
+
return candidate
|
|
165
|
+
head, _, tail = candidate.rpartition(".")
|
|
166
|
+
i = 2
|
|
167
|
+
while True:
|
|
168
|
+
suffixed = f"{tail}-{i}"
|
|
169
|
+
new = f"{head}.{suffixed}" if head else suffixed
|
|
170
|
+
if new not in self._seen:
|
|
171
|
+
self._seen.add(new)
|
|
172
|
+
return new
|
|
173
|
+
i += 1
|