"""
GlyphX Surface3DSeries — 3D surface / mesh plot.
Renders a smooth coloured surface from a 2-D Z matrix defined over a
regular X×Y grid. The SVG path uses the painter's algorithm: faces
sorted back-to-front so nearer faces always draw on top.
"""
from __future__ import annotations
import math
import numpy as np
from .projection3d import Camera3D, normalize, _format_3d_tick
from .colormaps import apply_colormap
from .utils import svg_escape
[docs]
class Surface3DSeries:
"""
3D surface plot — z = f(x, y) over a regular grid.
Args:
x: 1-D X grid values (length N).
y: 1-D Y grid values (length M).
z: 2-D Z matrix, shape (M, N). ``z[j][i]`` is the height
at ``(x[i], y[j])``.
cmap: Colormap name (default ``"viridis"``).
alpha: Surface opacity 0–1.
wireframe:If ``True``, draw grid lines over the surface.
wire_color: Color of wireframe lines.
label: Legend / tooltip label.
"""
def __init__(
self,
x, y, z,
cmap: str = "viridis",
alpha: float = 0.90,
wireframe: bool = True,
wire_color: str = "#ffffff44",
label: str | None = None,
threshold: int | None = None,
) -> None:
self.x_1d = list(x)
self.y_1d = list(y)
self.z_mat = [list(row) for row in z]
self.cmap = cmap
self.alpha = float(alpha)
self.wireframe = wireframe
self.wire_color = wire_color
self.label = label
self.threshold = threshold
self.last_downsample_info = None
self.css_class = f"series3d-{id(self) % 100000}"
# Pre-compute face colours from Z values
z_arr = np.asarray(z, dtype=float)
self._z_min = float(z_arr.min())
self._z_max = float(z_arr.max())
self._z_span = self._z_max - self._z_min or 1.0
def _face_color(self, z_val: float) -> str:
norm = (z_val - self._z_min) / self._z_span
return apply_colormap(norm, self.cmap)
[docs]
def to_svg(self, cam: Camera3D,
x_range, y_range, z_range) -> str:
"""
Render each grid quad as a coloured SVG polygon.
Quads are sorted back-to-front by their average projected depth.
"""
from .downsample import decimate_grid, cull_faces, _ds_comment, AUTO_THRESHOLD
# Decimate grid before projection to keep face count manageable.
_thresh = self.threshold if self.threshold is not None else AUTO_THRESHOLD
_orig_nx, _orig_ny = len(self.x_1d), len(self.y_1d)
x_1d, y_1d, z_mat_arr = decimate_grid(
self.x_1d, self.y_1d, self.z_mat, max_faces=_thresh
)
_ds_svg = ""
if len(x_1d) < _orig_nx or len(y_1d) < _orig_ny:
_orig_faces = (_orig_nx - 1) * (_orig_ny - 1)
_new_faces = (len(x_1d) - 1) * (len(y_1d) - 1)
_ds_svg = _ds_comment(_orig_faces, _new_faces, "grid-decimate (faces)")
self.last_downsample_info = {
"algorithm": "grid-decimate",
"original_n": _orig_faces,
"thinned_n": _new_faces,
}
else:
self.last_downsample_info = None
nx = len(x_1d)
ny = len(y_1d)
# Normalise to [-1, 1]
xn, xlo, xhi = normalize(x_1d)
yn, ylo, yhi = normalize(y_1d)
z_flat = [v for row in z_mat_arr for v in row]
zn_flat, zlo, zhi = normalize(z_flat)
z_norm = [zn_flat[j * nx + i] for j in range(ny) for i in range(nx)]
def znv(j, i):
return z_norm[j * nx + i]
# Project all grid vertices
verts: list[list] = []
for j in range(ny):
row = []
for i in range(nx):
p = cam.project(xn[i], yn[j], znv(j, i))
row.append(p)
verts.append(row)
# Build quads (i, j) → (i+1, j) → (i+1, j+1) → (i, j+1)
faces = []
for j in range(ny - 1):
for i in range(nx - 1):
ps = [verts[j][i], verts[j][i+1],
verts[j+1][i+1], verts[j+1][i]]
depth = sum(p.depth for p in ps) / 4
# Average Z value for colour
z_vals = [z_mat_arr[j][i], z_mat_arr[j][i+1],
z_mat_arr[j+1][i+1], z_mat_arr[j+1][i]]
avg_z = sum(z_vals) / 4
faces.append((depth, ps, avg_z))
# Cull sub-pixel faces before sorting (cheap, saves sort work)
faces = cull_faces(faces)
# Sort back-to-front
faces.sort(key=lambda f: f[0])
elements: list[str] = [_ds_svg] if _ds_svg else []
for depth, ps, avg_z in faces:
pts = " ".join(f"{p.px:.1f},{p.py:.1f}" for p in ps)
col = self._face_color(avg_z)
elements.append(
f'<polygon points="{pts}" fill="{col}" '
f'fill-opacity="{self.alpha}" stroke="none"/>'
)
if self.wireframe:
elements.append(
f'<polygon points="{pts}" fill="none" '
f'stroke="{self.wire_color}" stroke-width="0.4"/>'
)
return "\n".join(elements)
[docs]
def to_threejs_data(self) -> dict:
return {
"type": "surface",
"x": self.x_1d,
"y": self.y_1d,
"z": self.z_mat,
"cmap": self.cmap,
"alpha": self.alpha,
"wireframe": self.wireframe,
"wire_color": self.wire_color,
"label": self.label or "",
}