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