plot3 0.4.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.
- plot3/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
plot3/stats3d.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""3D stats helpers: regular-grid surfaces, density grids, isosurfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from plot3.table import ColumnNotFound
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def regular_grid_mesh(
|
|
12
|
+
df: pd.DataFrame,
|
|
13
|
+
xcol: str,
|
|
14
|
+
ycol: str,
|
|
15
|
+
zcol: str,
|
|
16
|
+
ccol: str | None = None,
|
|
17
|
+
) -> tuple[pd.DataFrame, np.ndarray, int, int]:
|
|
18
|
+
"""Build ordered vertices + triangle indices for a regular surface grid.
|
|
19
|
+
|
|
20
|
+
Returns vertices (y-slow, x-fast), triangle indices ``(ntri, 3)``, nx, ny.
|
|
21
|
+
"""
|
|
22
|
+
for name, col in (("x", xcol), ("y", ycol), ("z", zcol)):
|
|
23
|
+
if col not in df.columns:
|
|
24
|
+
raise ColumnNotFound([col], df)
|
|
25
|
+
if not pd.api.types.is_numeric_dtype(df[col]):
|
|
26
|
+
raise ValueError(f"geom_surface() {name}={col!r} must be numeric")
|
|
27
|
+
|
|
28
|
+
cols = [xcol, ycol, zcol]
|
|
29
|
+
if ccol and ccol not in cols:
|
|
30
|
+
cols.append(ccol)
|
|
31
|
+
work = df.loc[:, cols].dropna(subset=[xcol, ycol, zcol])
|
|
32
|
+
work = work.drop_duplicates(subset=[xcol, ycol], keep="first")
|
|
33
|
+
|
|
34
|
+
xs = np.sort(pd.unique(work[xcol].to_numpy()))
|
|
35
|
+
ys = np.sort(pd.unique(work[ycol].to_numpy()))
|
|
36
|
+
nx, ny = int(len(xs)), int(len(ys))
|
|
37
|
+
if nx < 2 or ny < 2:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"geom_surface() needs at least a 2×2 grid "
|
|
40
|
+
f"(got nx={nx}, ny={ny})"
|
|
41
|
+
)
|
|
42
|
+
expected = nx * ny
|
|
43
|
+
if len(work) != expected:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"geom_surface() requires a complete regular x–y grid: "
|
|
46
|
+
f"expected {expected} unique (x,y) cells from "
|
|
47
|
+
f"{nx}×{ny} levels, got {len(work)}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
full = pd.MultiIndex.from_product([ys, xs], names=[ycol, xcol])
|
|
51
|
+
indexed = work.set_index([ycol, xcol]).reindex(full)
|
|
52
|
+
if bool(indexed[zcol].isna().any()):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"geom_surface() grid has missing z values after alignment"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
z_grid = indexed[zcol].to_numpy(dtype=np.float64).reshape(ny, nx)
|
|
58
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
59
|
+
vertices = pd.DataFrame(
|
|
60
|
+
{
|
|
61
|
+
"x": xx.ravel(),
|
|
62
|
+
"y": yy.ravel(),
|
|
63
|
+
"z": z_grid.ravel(),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
if ccol:
|
|
67
|
+
if ccol == zcol:
|
|
68
|
+
vertices["colour"] = z_grid.ravel()
|
|
69
|
+
else:
|
|
70
|
+
c_grid = indexed[ccol].to_numpy(dtype=np.float64).reshape(ny, nx)
|
|
71
|
+
if not np.isfinite(c_grid).all():
|
|
72
|
+
c_grid = np.where(np.isfinite(c_grid), c_grid, z_grid)
|
|
73
|
+
vertices["colour"] = c_grid.ravel()
|
|
74
|
+
|
|
75
|
+
tris: list[list[int]] = []
|
|
76
|
+
for j in range(ny - 1):
|
|
77
|
+
for i in range(nx - 1):
|
|
78
|
+
v00 = j * nx + i
|
|
79
|
+
v10 = j * nx + (i + 1)
|
|
80
|
+
v01 = (j + 1) * nx + i
|
|
81
|
+
v11 = (j + 1) * nx + (i + 1)
|
|
82
|
+
tris.append([v00, v10, v11])
|
|
83
|
+
tris.append([v00, v11, v01])
|
|
84
|
+
indices = np.asarray(tris, dtype=np.int32)
|
|
85
|
+
return vertices, indices, nx, ny
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def density_grid_3d(
|
|
89
|
+
points: np.ndarray,
|
|
90
|
+
*,
|
|
91
|
+
n: int = 32,
|
|
92
|
+
pad: float = 0.05,
|
|
93
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
94
|
+
"""Histogram density on a cubic grid.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
points:
|
|
99
|
+
Array shape ``(N, 3)`` of finite xyz samples.
|
|
100
|
+
n:
|
|
101
|
+
Bins per axis (clamped to 8..64 for HTML size).
|
|
102
|
+
pad:
|
|
103
|
+
Fractional padding of the data range on each side.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
density:
|
|
108
|
+
``(n, n, n)`` float64 field normalized so ``max == 1`` (or zeros).
|
|
109
|
+
xs, ys, zs:
|
|
110
|
+
1D bin-center coordinates along each axis.
|
|
111
|
+
"""
|
|
112
|
+
pts = np.asarray(points, dtype=np.float64)
|
|
113
|
+
if pts.ndim != 2 or pts.shape[1] != 3:
|
|
114
|
+
raise ValueError("density_grid_3d() expects an (N, 3) point array")
|
|
115
|
+
pts = pts[np.isfinite(pts).all(axis=1)]
|
|
116
|
+
n = int(max(8, min(64, n)))
|
|
117
|
+
if pts.shape[0] == 0:
|
|
118
|
+
grid = np.linspace(-1, 1, n)
|
|
119
|
+
return np.zeros((n, n, n), dtype=np.float64), grid, grid, grid
|
|
120
|
+
|
|
121
|
+
lo = pts.min(axis=0)
|
|
122
|
+
hi = pts.max(axis=0)
|
|
123
|
+
span = np.maximum(hi - lo, 1e-9)
|
|
124
|
+
lo = lo - pad * span
|
|
125
|
+
hi = hi + pad * span
|
|
126
|
+
# histogramdd returns density with shape (nx, ny, nz) matching bins order
|
|
127
|
+
hist, edges = np.histogramdd(pts, bins=n, range=list(zip(lo, hi)))
|
|
128
|
+
dens = hist.astype(np.float64)
|
|
129
|
+
peak = float(dens.max())
|
|
130
|
+
if peak > 0:
|
|
131
|
+
dens /= peak
|
|
132
|
+
xs = 0.5 * (edges[0][:-1] + edges[0][1:])
|
|
133
|
+
ys = 0.5 * (edges[1][:-1] + edges[1][1:])
|
|
134
|
+
zs = 0.5 * (edges[2][:-1] + edges[2][1:])
|
|
135
|
+
# Extra smoothing so isolevels are less blocky.
|
|
136
|
+
dens = _smooth3(dens, passes=2)
|
|
137
|
+
peak = float(dens.max())
|
|
138
|
+
if peak > 0:
|
|
139
|
+
dens /= peak
|
|
140
|
+
return dens, xs, ys, zs
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _smooth3(vol: np.ndarray, passes: int = 1) -> np.ndarray:
|
|
144
|
+
"""6-neighbor average (edge-padded), optionally repeated."""
|
|
145
|
+
out = np.asarray(vol, dtype=np.float64)
|
|
146
|
+
for _ in range(max(1, int(passes))):
|
|
147
|
+
p = np.pad(out, 1, mode="edge")
|
|
148
|
+
out = (
|
|
149
|
+
p[1:-1, 1:-1, 1:-1] * 0.4
|
|
150
|
+
+ p[:-2, 1:-1, 1:-1] * 0.1
|
|
151
|
+
+ p[2:, 1:-1, 1:-1] * 0.1
|
|
152
|
+
+ p[1:-1, :-2, 1:-1] * 0.1
|
|
153
|
+
+ p[1:-1, 2:, 1:-1] * 0.1
|
|
154
|
+
+ p[1:-1, 1:-1, :-2] * 0.1
|
|
155
|
+
+ p[1:-1, 1:-1, 2:] * 0.1
|
|
156
|
+
)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def isosurface_mesh(
|
|
161
|
+
density: np.ndarray,
|
|
162
|
+
level: float,
|
|
163
|
+
xs: np.ndarray,
|
|
164
|
+
ys: np.ndarray,
|
|
165
|
+
zs: np.ndarray,
|
|
166
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
167
|
+
"""Extract a triangle mesh where ``density >= level`` meets empty space.
|
|
168
|
+
|
|
169
|
+
Dependency-free **voxel face** extraction: for each solid voxel, emit
|
|
170
|
+
quads on faces adjacent to empty (or boundary). Robust for interactive
|
|
171
|
+
EDA; slightly blocky at low ``n``.
|
|
172
|
+
|
|
173
|
+
Returns vertices ``(V, 3)`` and triangle indices ``(T, 3)``.
|
|
174
|
+
"""
|
|
175
|
+
vol = np.asarray(density, dtype=np.float64)
|
|
176
|
+
if vol.ndim != 3:
|
|
177
|
+
raise ValueError("density must be a 3D array")
|
|
178
|
+
nx, ny, nz = vol.shape
|
|
179
|
+
xs = np.asarray(xs, dtype=np.float64)
|
|
180
|
+
ys = np.asarray(ys, dtype=np.float64)
|
|
181
|
+
zs = np.asarray(zs, dtype=np.float64)
|
|
182
|
+
level = float(level)
|
|
183
|
+
solid = vol >= level
|
|
184
|
+
if not solid.any():
|
|
185
|
+
return np.zeros((0, 3), dtype=np.float64), np.zeros((0, 3), dtype=np.int32)
|
|
186
|
+
|
|
187
|
+
def centers(axis: np.ndarray) -> np.ndarray:
|
|
188
|
+
if len(axis) == 1:
|
|
189
|
+
return axis.copy()
|
|
190
|
+
# half-bin edges for face placement
|
|
191
|
+
return axis
|
|
192
|
+
|
|
193
|
+
def half_step(axis: np.ndarray) -> float:
|
|
194
|
+
if len(axis) < 2:
|
|
195
|
+
return 1.0
|
|
196
|
+
return float(np.median(np.diff(axis)) * 0.5)
|
|
197
|
+
|
|
198
|
+
hx, hy, hz = half_step(xs), half_step(ys), half_step(zs)
|
|
199
|
+
verts: list[list[float]] = []
|
|
200
|
+
faces: list[list[int]] = []
|
|
201
|
+
|
|
202
|
+
def add_quad(corners: list[tuple[float, float, float]], flip: bool = False):
|
|
203
|
+
base = len(verts)
|
|
204
|
+
for c in corners:
|
|
205
|
+
verts.append([c[0], c[1], c[2]])
|
|
206
|
+
if flip:
|
|
207
|
+
faces.append([base, base + 2, base + 1])
|
|
208
|
+
faces.append([base, base + 3, base + 2])
|
|
209
|
+
else:
|
|
210
|
+
faces.append([base, base + 1, base + 2])
|
|
211
|
+
faces.append([base, base + 2, base + 3])
|
|
212
|
+
|
|
213
|
+
# 6 neighbor offsets and corresponding face corner templates in local ±half
|
|
214
|
+
neighbors = [
|
|
215
|
+
# (di,dj,dk), four corners relative (sx,sy,sz) in ±1 for the face
|
|
216
|
+
(1, 0, 0, [(1, -1, -1), (1, 1, -1), (1, 1, 1), (1, -1, 1)], False),
|
|
217
|
+
(-1, 0, 0, [(-1, -1, -1), (-1, -1, 1), (-1, 1, 1), (-1, 1, -1)], False),
|
|
218
|
+
(0, 1, 0, [(-1, 1, -1), (-1, 1, 1), (1, 1, 1), (1, 1, -1)], False),
|
|
219
|
+
(0, -1, 0, [(-1, -1, -1), (1, -1, -1), (1, -1, 1), (-1, -1, 1)], False),
|
|
220
|
+
(0, 0, 1, [(-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)], False),
|
|
221
|
+
(0, 0, -1, [(-1, -1, -1), (-1, 1, -1), (1, 1, -1), (1, -1, -1)], False),
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
for i in range(nx):
|
|
225
|
+
for j in range(ny):
|
|
226
|
+
for k in range(nz):
|
|
227
|
+
if not solid[i, j, k]:
|
|
228
|
+
continue
|
|
229
|
+
cx, cy, cz = float(xs[i]), float(ys[j]), float(zs[k])
|
|
230
|
+
for di, dj, dk, corners, flip in neighbors:
|
|
231
|
+
ii, jj, kk = i + di, j + dj, k + dk
|
|
232
|
+
outside = (
|
|
233
|
+
ii < 0
|
|
234
|
+
or jj < 0
|
|
235
|
+
or kk < 0
|
|
236
|
+
or ii >= nx
|
|
237
|
+
or jj >= ny
|
|
238
|
+
or kk >= nz
|
|
239
|
+
or not solid[ii, jj, kk]
|
|
240
|
+
)
|
|
241
|
+
if not outside:
|
|
242
|
+
continue
|
|
243
|
+
# Interpolate face position toward empty neighbor for less blockiness
|
|
244
|
+
if 0 <= ii < nx and 0 <= jj < ny and 0 <= kk < nz:
|
|
245
|
+
va = vol[i, j, k]
|
|
246
|
+
vb = vol[ii, jj, kk]
|
|
247
|
+
t = 0.5 if abs(vb - va) < 1e-15 else (level - va) / (vb - va)
|
|
248
|
+
t = float(np.clip(t, 0.0, 1.0))
|
|
249
|
+
else:
|
|
250
|
+
t = 0.5
|
|
251
|
+
# Face center between voxel centers
|
|
252
|
+
fcx = cx + di * hx * 2 * t
|
|
253
|
+
fcy = cy + dj * hy * 2 * t
|
|
254
|
+
fcz = cz + dk * hz * 2 * t
|
|
255
|
+
# Build quad in the plane perpendicular to (di,dj,dk)
|
|
256
|
+
world = []
|
|
257
|
+
for sx, sy, sz in corners:
|
|
258
|
+
# project local face offsets onto the face plane
|
|
259
|
+
if di != 0:
|
|
260
|
+
world.append(
|
|
261
|
+
(fcx, cy + sy * hy, cz + sz * hz)
|
|
262
|
+
)
|
|
263
|
+
elif dj != 0:
|
|
264
|
+
world.append(
|
|
265
|
+
(cx + sx * hx, fcy, cz + sz * hz)
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
world.append(
|
|
269
|
+
(cx + sx * hx, cy + sy * hy, fcz)
|
|
270
|
+
)
|
|
271
|
+
add_quad(world, flip=flip)
|
|
272
|
+
|
|
273
|
+
if not verts:
|
|
274
|
+
return np.zeros((0, 3), dtype=np.float64), np.zeros((0, 3), dtype=np.int32)
|
|
275
|
+
V = np.asarray(verts, dtype=np.float64)
|
|
276
|
+
F = np.asarray(faces, dtype=np.int32)
|
|
277
|
+
V = _laplacian_smooth(V, F, iterations=2)
|
|
278
|
+
return V, F
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _laplacian_smooth(
|
|
282
|
+
vertices: np.ndarray,
|
|
283
|
+
faces: np.ndarray,
|
|
284
|
+
*,
|
|
285
|
+
iterations: int = 2,
|
|
286
|
+
lambda_: float = 0.45,
|
|
287
|
+
) -> np.ndarray:
|
|
288
|
+
"""Relax mesh vertices toward neighbor averages (boundary-friendly)."""
|
|
289
|
+
if len(vertices) == 0 or len(faces) == 0 or iterations < 1:
|
|
290
|
+
return vertices
|
|
291
|
+
n = len(vertices)
|
|
292
|
+
# undirected adjacency
|
|
293
|
+
nbrs: list[set[int]] = [set() for _ in range(n)]
|
|
294
|
+
for a, b, c in faces:
|
|
295
|
+
for u, v in ((a, b), (b, c), (c, a)):
|
|
296
|
+
if 0 <= u < n and 0 <= v < n and u != v:
|
|
297
|
+
nbrs[u].add(int(v))
|
|
298
|
+
nbrs[v].add(int(u))
|
|
299
|
+
V = vertices.copy()
|
|
300
|
+
for _ in range(int(iterations)):
|
|
301
|
+
new = V.copy()
|
|
302
|
+
for i in range(n):
|
|
303
|
+
if not nbrs[i]:
|
|
304
|
+
continue
|
|
305
|
+
avg = V[list(nbrs[i])].mean(axis=0)
|
|
306
|
+
new[i] = (1.0 - lambda_) * V[i] + lambda_ * avg
|
|
307
|
+
V = new
|
|
308
|
+
return V
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def isosurface_levels(
|
|
312
|
+
points: np.ndarray,
|
|
313
|
+
levels: list[float] | tuple[float, ...],
|
|
314
|
+
*,
|
|
315
|
+
n: int = 32,
|
|
316
|
+
absolute: bool = False,
|
|
317
|
+
) -> tuple[pd.DataFrame, np.ndarray, list[float]]:
|
|
318
|
+
"""Density-grid + multi-level isosurface mesh.
|
|
319
|
+
|
|
320
|
+
Parameters
|
|
321
|
+
----------
|
|
322
|
+
points:
|
|
323
|
+
``(N, 3)`` xyz samples.
|
|
324
|
+
levels:
|
|
325
|
+
Relative to max density in ``[0, 1]`` unless ``absolute=True``.
|
|
326
|
+
n:
|
|
327
|
+
Grid resolution per axis.
|
|
328
|
+
absolute:
|
|
329
|
+
If True, treat levels as raw density thresholds in ``[0, 1]`` after
|
|
330
|
+
normalization (same scale); kept for API clarity.
|
|
331
|
+
|
|
332
|
+
Returns
|
|
333
|
+
-------
|
|
334
|
+
vertices:
|
|
335
|
+
Columns x, y, z, level (numeric level id 0..L-1), colour (= level).
|
|
336
|
+
indices:
|
|
337
|
+
Triangle indices into vertices.
|
|
338
|
+
used_levels:
|
|
339
|
+
Absolute thresholds applied.
|
|
340
|
+
"""
|
|
341
|
+
dens, xs, ys, zs = density_grid_3d(points, n=n)
|
|
342
|
+
peak = float(dens.max())
|
|
343
|
+
if peak <= 0:
|
|
344
|
+
empty = pd.DataFrame(columns=["x", "y", "z", "level", "colour"])
|
|
345
|
+
return empty, np.zeros((0, 3), dtype=np.int32), []
|
|
346
|
+
|
|
347
|
+
used: list[float] = []
|
|
348
|
+
all_verts: list[np.ndarray] = []
|
|
349
|
+
all_faces: list[np.ndarray] = []
|
|
350
|
+
all_level: list[np.ndarray] = []
|
|
351
|
+
v_offset = 0
|
|
352
|
+
for li, raw in enumerate(levels):
|
|
353
|
+
thr = float(raw)
|
|
354
|
+
if not absolute:
|
|
355
|
+
thr = float(np.clip(thr, 0.0, 1.0)) * peak
|
|
356
|
+
# dens already normalized to max 1, so relative level is thr as-is
|
|
357
|
+
thr = float(np.clip(raw, 0.0, 1.0))
|
|
358
|
+
thr = float(np.clip(thr, 1e-6, 1.0 - 1e-9))
|
|
359
|
+
verts, faces = isosurface_mesh(dens, thr, xs, ys, zs)
|
|
360
|
+
if len(verts) == 0 or len(faces) == 0:
|
|
361
|
+
continue
|
|
362
|
+
used.append(thr)
|
|
363
|
+
all_verts.append(verts)
|
|
364
|
+
all_faces.append(faces + v_offset)
|
|
365
|
+
all_level.append(np.full(len(verts), li, dtype=np.float64))
|
|
366
|
+
v_offset += len(verts)
|
|
367
|
+
|
|
368
|
+
if not all_verts:
|
|
369
|
+
empty = pd.DataFrame(columns=["x", "y", "z", "level", "colour"])
|
|
370
|
+
return empty, np.zeros((0, 3), dtype=np.int32), used
|
|
371
|
+
|
|
372
|
+
V = np.vstack(all_verts)
|
|
373
|
+
F = np.vstack(all_faces)
|
|
374
|
+
L = np.concatenate(all_level)
|
|
375
|
+
vertices = pd.DataFrame(
|
|
376
|
+
{
|
|
377
|
+
"x": V[:, 0],
|
|
378
|
+
"y": V[:, 1],
|
|
379
|
+
"z": V[:, 2],
|
|
380
|
+
"level": L,
|
|
381
|
+
"colour": L,
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
return vertices, F.astype(np.int32), used
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Box corner order: bottom loop, then top loop (each closed), then the four
|
|
388
|
+
# uprights, as six polylines.
|
|
389
|
+
_BOX_LOOP = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def box3d_layers(geom, mapping: dict, data) -> list:
|
|
393
|
+
"""geom_box3d rows as wireframe line layers, one layer per class.
|
|
394
|
+
|
|
395
|
+
One colour scale serves the whole figure, and a lidar scene colours its
|
|
396
|
+
points by height. So each box class becomes its own fixed-colour layer
|
|
397
|
+
with a legend entry, and the height scale stays free for the points.
|
|
398
|
+
"""
|
|
399
|
+
from plot3.scales import ordered_levels
|
|
400
|
+
from plot3.stat2d import _frame, _layer
|
|
401
|
+
from plot3.table import has_column
|
|
402
|
+
from plot3.themes import _THEMES
|
|
403
|
+
|
|
404
|
+
need = ["x", "y", "z", "length", "width", "height"]
|
|
405
|
+
missing = [k for k in need if not mapping.get(k)]
|
|
406
|
+
if missing:
|
|
407
|
+
raise ValueError(
|
|
408
|
+
"geom_box3d() requires aes(x=, y=, z=, length=, width=, height=) "
|
|
409
|
+
f"(missing {', '.join(missing)}); angle= is the heading in radians"
|
|
410
|
+
)
|
|
411
|
+
colour = mapping.get("color")
|
|
412
|
+
colour = colour if colour and has_column(data, colour) else None
|
|
413
|
+
angle = mapping.get("angle")
|
|
414
|
+
cols = [mapping[k] for k in need] + ([angle] if angle else []) + ([colour] if colour else [])
|
|
415
|
+
frame = _frame(data, cols)
|
|
416
|
+
values = {k: pd.to_numeric(frame[mapping[k]], errors="coerce").to_numpy(np.float64) for k in need}
|
|
417
|
+
yaw = (
|
|
418
|
+
pd.to_numeric(frame[angle], errors="coerce").to_numpy(np.float64)
|
|
419
|
+
if angle else np.zeros(len(frame))
|
|
420
|
+
)
|
|
421
|
+
keys = frame[colour].astype(str).to_numpy() if colour else np.full(len(frame), "")
|
|
422
|
+
levels = ordered_levels(list(dict.fromkeys(keys.tolist()))) if colour else [""]
|
|
423
|
+
palette = getattr(geom, "_palette", None) or _THEMES["dark"]["cat"]
|
|
424
|
+
layers = []
|
|
425
|
+
for index, level in enumerate(levels):
|
|
426
|
+
xs, ys, zs, starts = [], [], [], []
|
|
427
|
+
for i in np.flatnonzero(keys == level):
|
|
428
|
+
cx, cy, cz, ln, wd, ht = (values[k][i] for k in need)
|
|
429
|
+
a = yaw[i] if np.isfinite(yaw[i]) else 0.0
|
|
430
|
+
if not all(np.isfinite([cx, cy, cz, ln, wd, ht])):
|
|
431
|
+
continue
|
|
432
|
+
c, s = np.cos(a), np.sin(a)
|
|
433
|
+
|
|
434
|
+
def corner(u, v, w):
|
|
435
|
+
dx, dy = u * ln / 2.0, v * wd / 2.0
|
|
436
|
+
return cx + c * dx - s * dy, cy + s * dx + c * dy, cz + w * ht / 2.0
|
|
437
|
+
|
|
438
|
+
pieces = [
|
|
439
|
+
[corner(u, v, -1) for u, v in _BOX_LOOP],
|
|
440
|
+
[corner(u, v, 1) for u, v in _BOX_LOOP],
|
|
441
|
+
] + [[corner(u, v, -1), corner(u, v, 1)] for u, v in _BOX_LOOP[:4]]
|
|
442
|
+
for piece in pieces:
|
|
443
|
+
starts.append([len(xs), len(piece)])
|
|
444
|
+
for px, py, pz in piece:
|
|
445
|
+
xs.append(px)
|
|
446
|
+
ys.append(py)
|
|
447
|
+
zs.append(pz)
|
|
448
|
+
if not starts:
|
|
449
|
+
continue
|
|
450
|
+
out_frame = pd.DataFrame({"x": xs, "y": ys, "z": zs})
|
|
451
|
+
out = _layer("line", out_frame, {"x": "x", "y": "y", "z": "z"}, geom,
|
|
452
|
+
_groups=starts, linewidth=float(getattr(geom, "linewidth", 1.5)))
|
|
453
|
+
if colour:
|
|
454
|
+
out.const_color = geom.const_color or palette[index % len(palette)]
|
|
455
|
+
out._legend_label = level
|
|
456
|
+
out._entries_title = str(colour)
|
|
457
|
+
else:
|
|
458
|
+
out.const_color = geom.const_color or "#ffffff"
|
|
459
|
+
layers.append(out)
|
|
460
|
+
if not layers:
|
|
461
|
+
raise ValueError("geom_box3d() found no complete boxes (missing sizes or centres)")
|
|
462
|
+
return layers
|