catenate commited on
Commit
8a083e9
·
verified ·
1 Parent(s): 8a9b74c

visualize.py: harden void mesh (per-axis grid clamp, vertex cap, full failure fallback to atoms+beads)

Browse files
Files changed (1) hide show
  1. 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
- grid_spacing: float = 0.7,
 
 
 
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 on a `grid_spacing` grid (default 0.7 A) and runs marching
116
- cubes at the void boundary. Mirrors Mercury's Display > Voids > Contact
117
- Surface calculation (CCDC, https://www.ccdc.cam.ac.uk/discover/blog/
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
- Returns (vertices_cart, faces) where
 
 
 
 
 
 
 
 
 
 
 
 
127
  vertices_cart: np.ndarray [V, 3] in Cartesian Angstroms
128
- faces: np.ndarray [F, 3] of vertex indices per triangle
129
- or (None, None) if scikit-image is unavailable, the mesh is empty, or
130
- marching cubes fails.
131
  """
 
132
  try:
133
  from skimage.measure import marching_cubes
134
- except ImportError:
135
  return None, None
136
  try:
137
  from scipy.spatial import cKDTree
138
- except ImportError:
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
- verts_idx, faces, _, _ = marching_cubes(
171
- void, level=0.5, allow_degenerate=False
172
- )
173
- except (ValueError, RuntimeError):
174
- return None, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
- verts_frac = verts_idx / np.array([nx, ny, nz], dtype=float)
177
- verts_cart = verts_frac @ lattice
178
- return verts_cart.astype(float), faces.astype(int)
 
 
 
 
 
 
 
 
 
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
- vertex_arr = [
451
- {'x': float(v[0]), 'y': float(v[1]), 'z': float(v[2])}
452
- for v in verts_cart
453
- ]
454
- face_arr = faces.flatten().tolist()
455
- view.addCustom({
456
- 'vertexArr': vertex_arr,
457
- 'faceArr': face_arr,
458
- 'color': '#fbbf24', # warm Mercury-style yellow
459
- 'opacity': 0.25,
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