visualize.py: harden void mesh (per-axis grid clamp, vertex cap, full failure fallback to atoms+beads)
Browse files- visualize.py +100 -58
visualize.py
CHANGED
|
@@ -106,16 +106,18 @@ _METAL_ELEMENTS = {
|
|
| 106 |
def _compute_void_mesh(
|
| 107 |
structure,
|
| 108 |
probe_radius: float = 1.2,
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
| 110 |
vdw_eff: float = 1.5,
|
| 111 |
):
|
| 112 |
"""Compute a Mercury-style contact-surface void mesh for the unit cell.
|
| 113 |
|
| 114 |
Rolls a `probe_radius` probe (default 1.2 A, helium-sized) through the
|
| 115 |
-
unit cell
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
how-to-search-visualize-and-analyse-mof-structures).
|
| 119 |
|
| 120 |
Atoms are replicated to a 3x3x3 super-image so distance queries respect
|
| 121 |
periodic boundaries. A constant effective vdW radius of 1.5 A is used
|
|
@@ -123,59 +125,93 @@ def _compute_void_mesh(
|
|
| 123 |
Zn etc.) the per-element value is within 0.2 A of this mean and the
|
| 124 |
visual is unchanged.
|
| 125 |
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
vertices_cart: np.ndarray [V, 3] in Cartesian Angstroms
|
| 128 |
-
faces:
|
| 129 |
-
or (None, None)
|
| 130 |
-
marching cubes fails.
|
| 131 |
"""
|
|
|
|
| 132 |
try:
|
| 133 |
from skimage.measure import marching_cubes
|
| 134 |
-
except
|
| 135 |
return None, None
|
| 136 |
try:
|
| 137 |
from scipy.spatial import cKDTree
|
| 138 |
-
except
|
| 139 |
-
return None, None
|
| 140 |
-
|
| 141 |
-
lattice = np.asarray(structure.lattice.matrix, dtype=float)
|
| 142 |
-
a, b, c = structure.lattice.abc
|
| 143 |
-
nx = max(int(np.ceil(a / grid_spacing)), 16)
|
| 144 |
-
ny = max(int(np.ceil(b / grid_spacing)), 16)
|
| 145 |
-
nz = max(int(np.ceil(c / grid_spacing)), 16)
|
| 146 |
-
|
| 147 |
-
fx = np.linspace(0.0, 1.0, nx, endpoint=False)
|
| 148 |
-
fy = np.linspace(0.0, 1.0, ny, endpoint=False)
|
| 149 |
-
fz = np.linspace(0.0, 1.0, nz, endpoint=False)
|
| 150 |
-
grid_frac = np.stack(np.meshgrid(fx, fy, fz, indexing='ij'), axis=-1)
|
| 151 |
-
grid_cart = grid_frac.reshape(-1, 3) @ lattice
|
| 152 |
-
|
| 153 |
-
atom_cart = np.array([site.coords for site in structure], dtype=float)
|
| 154 |
-
shifts = []
|
| 155 |
-
for da in (-1, 0, 1):
|
| 156 |
-
for db in (-1, 0, 1):
|
| 157 |
-
for dc in (-1, 0, 1):
|
| 158 |
-
shifts.append(da * lattice[0] + db * lattice[1] + dc * lattice[2])
|
| 159 |
-
images = np.vstack([atom_cart + s for s in shifts])
|
| 160 |
-
|
| 161 |
-
tree = cKDTree(images)
|
| 162 |
-
cutoff = vdw_eff + probe_radius
|
| 163 |
-
dist, _ = tree.query(grid_cart, k=1)
|
| 164 |
-
void = (dist > cutoff).reshape(nx, ny, nz).astype(np.float32)
|
| 165 |
-
|
| 166 |
-
if void.max() < 0.5 or void.min() > 0.5:
|
| 167 |
return None, None
|
| 168 |
|
| 169 |
try:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
def _cluster_pore_vertices(
|
|
@@ -447,17 +483,23 @@ def create_3d_visualization(
|
|
| 447 |
verts_cart, faces = None, None
|
| 448 |
|
| 449 |
if verts_cart is not None and faces is not None and len(faces) > 0:
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
# Per-cavity attribution glyphs: small opaque beads at cluster centres.
|
| 463 |
# Size scales with |attribution| so the strongest contributing cavity
|
|
|
|
| 106 |
def _compute_void_mesh(
|
| 107 |
structure,
|
| 108 |
probe_radius: float = 1.2,
|
| 109 |
+
target_grid_spacing: float = 0.7,
|
| 110 |
+
n_per_axis_min: int = 16,
|
| 111 |
+
n_per_axis_max: int = 60,
|
| 112 |
+
max_vertices: int = 40000,
|
| 113 |
vdw_eff: float = 1.5,
|
| 114 |
):
|
| 115 |
"""Compute a Mercury-style contact-surface void mesh for the unit cell.
|
| 116 |
|
| 117 |
Rolls a `probe_radius` probe (default 1.2 A, helium-sized) through the
|
| 118 |
+
unit cell and runs marching cubes at the void boundary. Mirrors
|
| 119 |
+
Mercury's Display > Voids > Contact Surface (CCDC, see
|
| 120 |
+
https://www.ccdc.cam.ac.uk/discover/blog/how-to-search-visualize-and-analyse-mof-structures).
|
|
|
|
| 121 |
|
| 122 |
Atoms are replicated to a 3x3x3 super-image so distance queries respect
|
| 123 |
periodic boundaries. A constant effective vdW radius of 1.5 A is used
|
|
|
|
| 125 |
Zn etc.) the per-element value is within 0.2 A of this mean and the
|
| 126 |
visual is unchanged.
|
| 127 |
|
| 128 |
+
Robustness behaviour. Per-axis grid resolution is clamped to
|
| 129 |
+
[`n_per_axis_min`, `n_per_axis_max`] so very large unit cells do not
|
| 130 |
+
blow up compute or browser payload, and very small cells still get a
|
| 131 |
+
decent surface. Mesh vertex count is capped at `max_vertices`; if the
|
| 132 |
+
surface would exceed that, the function returns (None, None) and the
|
| 133 |
+
caller falls back to a no-mesh render (atom colouring plus attribution
|
| 134 |
+
beads). All anticipated failure modes (missing scikit-image, missing
|
| 135 |
+
scipy, degenerate lattice, all-void or all-solid grid, marching-cubes
|
| 136 |
+
failure, oversize mesh) return (None, None).
|
| 137 |
+
|
| 138 |
+
Returns
|
| 139 |
+
-------
|
| 140 |
+
(vertices_cart, faces) where
|
| 141 |
vertices_cart: np.ndarray [V, 3] in Cartesian Angstroms
|
| 142 |
+
faces: np.ndarray [F, 3] of vertex indices per triangle
|
| 143 |
+
or (None, None).
|
|
|
|
| 144 |
"""
|
| 145 |
+
# Optional dependencies; degrade silently if either is missing
|
| 146 |
try:
|
| 147 |
from skimage.measure import marching_cubes
|
| 148 |
+
except Exception:
|
| 149 |
return None, None
|
| 150 |
try:
|
| 151 |
from scipy.spatial import cKDTree
|
| 152 |
+
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return None, None
|
| 154 |
|
| 155 |
try:
|
| 156 |
+
lattice = np.asarray(structure.lattice.matrix, dtype=float)
|
| 157 |
+
a, b, c = structure.lattice.abc
|
| 158 |
+
if min(a, b, c) <= 0.0:
|
| 159 |
+
return None, None
|
| 160 |
+
|
| 161 |
+
# Per-axis resolution clamped so any cell stays within the compute
|
| 162 |
+
# and payload budget. Tiny cells get more samples than nominal
|
| 163 |
+
# spacing would suggest; huge cells get coarser sampling.
|
| 164 |
+
nx = int(np.clip(np.ceil(a / target_grid_spacing),
|
| 165 |
+
n_per_axis_min, n_per_axis_max))
|
| 166 |
+
ny = int(np.clip(np.ceil(b / target_grid_spacing),
|
| 167 |
+
n_per_axis_min, n_per_axis_max))
|
| 168 |
+
nz = int(np.clip(np.ceil(c / target_grid_spacing),
|
| 169 |
+
n_per_axis_min, n_per_axis_max))
|
| 170 |
+
|
| 171 |
+
fx = np.linspace(0.0, 1.0, nx, endpoint=False)
|
| 172 |
+
fy = np.linspace(0.0, 1.0, ny, endpoint=False)
|
| 173 |
+
fz = np.linspace(0.0, 1.0, nz, endpoint=False)
|
| 174 |
+
grid_frac = np.stack(np.meshgrid(fx, fy, fz, indexing='ij'), axis=-1)
|
| 175 |
+
grid_cart = grid_frac.reshape(-1, 3) @ lattice
|
| 176 |
+
|
| 177 |
+
atom_cart = np.array([site.coords for site in structure], dtype=float)
|
| 178 |
+
if atom_cart.shape[0] == 0:
|
| 179 |
+
return None, None
|
| 180 |
+
|
| 181 |
+
shifts = []
|
| 182 |
+
for da in (-1, 0, 1):
|
| 183 |
+
for db in (-1, 0, 1):
|
| 184 |
+
for dc in (-1, 0, 1):
|
| 185 |
+
shifts.append(da * lattice[0] + db * lattice[1] + dc * lattice[2])
|
| 186 |
+
images = np.vstack([atom_cart + s for s in shifts])
|
| 187 |
+
|
| 188 |
+
tree = cKDTree(images)
|
| 189 |
+
cutoff = vdw_eff + probe_radius
|
| 190 |
+
dist, _ = tree.query(grid_cart, k=1)
|
| 191 |
+
void = (dist > cutoff).reshape(nx, ny, nz).astype(np.float32)
|
| 192 |
+
|
| 193 |
+
if void.max() < 0.5 or void.min() > 0.5:
|
| 194 |
+
return None, None
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
verts_idx, faces, _, _ = marching_cubes(
|
| 198 |
+
void, level=0.5, allow_degenerate=False
|
| 199 |
+
)
|
| 200 |
+
except Exception:
|
| 201 |
+
return None, None
|
| 202 |
|
| 203 |
+
# Vertex cap so the JSON payload shipped to the iframe stays
|
| 204 |
+
# bounded even for unusual very-high-porosity structures
|
| 205 |
+
if verts_idx.shape[0] > max_vertices:
|
| 206 |
+
return None, None
|
| 207 |
+
|
| 208 |
+
verts_frac = verts_idx / np.array([nx, ny, nz], dtype=float)
|
| 209 |
+
verts_cart = verts_frac @ lattice
|
| 210 |
+
return verts_cart.astype(float), faces.astype(int)
|
| 211 |
+
except Exception:
|
| 212 |
+
# Catch-all so a malformed structure never breaks the rest of the
|
| 213 |
+
# rendering pipeline. Caller falls back to no-mesh render.
|
| 214 |
+
return None, None
|
| 215 |
|
| 216 |
|
| 217 |
def _cluster_pore_vertices(
|
|
|
|
| 483 |
verts_cart, faces = None, None
|
| 484 |
|
| 485 |
if verts_cart is not None and faces is not None and len(faces) > 0:
|
| 486 |
+
try:
|
| 487 |
+
vertex_arr = [
|
| 488 |
+
{'x': float(v[0]), 'y': float(v[1]), 'z': float(v[2])}
|
| 489 |
+
for v in verts_cart
|
| 490 |
+
]
|
| 491 |
+
face_arr = faces.flatten().tolist()
|
| 492 |
+
view.addCustom({
|
| 493 |
+
'vertexArr': vertex_arr,
|
| 494 |
+
'faceArr': face_arr,
|
| 495 |
+
'color': '#fbbf24', # warm Mercury-style yellow
|
| 496 |
+
'opacity': 0.25,
|
| 497 |
+
})
|
| 498 |
+
except Exception:
|
| 499 |
+
# Mesh shape construction failed (e.g. py3Dmol API mismatch on
|
| 500 |
+
# some build). Skip the mesh and continue with the rest of the
|
| 501 |
+
# render so the user still sees atoms and attribution beads.
|
| 502 |
+
pass
|
| 503 |
|
| 504 |
# Per-cavity attribution glyphs: small opaque beads at cluster centres.
|
| 505 |
# Size scales with |attribution| so the strongest contributing cavity
|