Coverage for cosmolayer/cosmosac/visualize.py: 88%

195 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:09 +0000

1""" 

2.. module:: cosmolayer.cosmosac.visualize 

3 :synopsis: Visualize COSMO-SAC surface segments. 

4 

5.. functionauthor:: Charlles Abreu <craabreu@gmail.com> 

6""" 

7 

8import argparse 

9import pathlib 

10 

11import cmap 

12import networkx as nx 

13import numpy as np 

14import open3d as o3d 

15import periodictable as pt 

16 

17from cosmolayer.cosmosac import Component 

18 

19RADII_MULTIPLIERS: tuple[float, float, float] = (1.5, 2.5, 4.0) 

20 

21ELEMENT_COLORS = { # https://pymolwiki.org/Color_Values 

22 "Br": (0.650980392, 0.160784314, 0.160784314), 

23 "C": (0.2, 1.0, 0.2), 

24 "Cl": (0.121568627, 0.941176471, 0.121568627), 

25 "F": (0.701960784, 1.0, 1.0), 

26 "H": (0.9, 0.9, 0.9), 

27 "I": (0.580392157, 0.0, 0.580392157), 

28 "N": (0.2, 0.2, 1.0), 

29 "O": (1.0, 0.3, 0.3), 

30 "P": (1.0, 0.501960784, 0.0), 

31 "Si": (0.941176471, 0.784313725, 0.627450980), 

32 "S": (0.9, 0.775, 0.25), 

33} 

34 

35 

36TOLERANCE: float = 1e-10 

37DOT_PRODUCT_TOLERANCE: float = 0.9 

38X_AXIS: np.ndarray = np.array([1.0, 0.0, 0.0]) 

39Y_AXIS: np.ndarray = np.array([0.0, 1.0, 0.0]) 

40Z_AXIS: np.ndarray = np.array([0.0, 0.0, 1.0]) 

41 

42 

43def estimate_vdw_radius(element: str) -> float: 

44 covalent_radius = pt.elements.symbol(element).covalent_radius 

45 if covalent_radius is None: 

46 raise ValueError(f"Unknown covalent radius for element {element!r}") 

47 return float(covalent_radius) + 0.8 # Å 

48 

49 

50def create_atom_spheres( 

51 component: Component, 

52 radius_scale: float, 

53 resolution: int = 40, 

54 default_color: tuple[float, float, float] = (0.7, 0.7, 0.7), 

55) -> list[o3d.geometry.TriangleMesh]: 

56 atom_df = component.atom_data 

57 spheres: list[o3d.geometry.TriangleMesh] = [] 

58 for element, x, y, z in zip( 

59 atom_df["element"], 

60 atom_df["x"], 

61 atom_df["y"], 

62 atom_df["z"], 

63 strict=True, 

64 ): 

65 element_name = str(element).strip() 

66 radius = estimate_vdw_radius(element_name) * radius_scale 

67 sphere = o3d.geometry.TriangleMesh.create_sphere( 

68 radius=radius, 

69 resolution=resolution, 

70 ) 

71 sphere.compute_vertex_normals() 

72 sphere.translate((float(x), float(y), float(z))) 

73 rgb = np.array(ELEMENT_COLORS.get(element_name, default_color)) 

74 sphere.paint_uniform_color(rgb) 

75 spheres.append(sphere) 

76 return spheres 

77 

78 

79def compute_rotation_matrix( 

80 original_axis: np.ndarray, target_axis: np.ndarray, normalize: bool = False 

81) -> np.ndarray: 

82 """Rodrigues' rotation formula between two unit-direction vectors.""" 

83 if normalize: 

84 original_axis = original_axis / np.linalg.norm(original_axis) 

85 target_axis = target_axis / np.linalg.norm(target_axis) 

86 v = np.cross(original_axis, target_axis) 

87 c = original_axis.dot(target_axis) 

88 s2 = v.dot(v) 

89 if s2 < TOLERANCE: 

90 if c > 0: 

91 return np.eye(3) # Parallel (c ≈ 1) → identity 

92 arbitrary = X_AXIS if abs(original_axis[0]) < DOT_PRODUCT_TOLERANCE else Y_AXIS 

93 arbitrary -= original_axis * original_axis.dot(arbitrary) 

94 orthogonal = arbitrary / np.linalg.norm(arbitrary) 

95 return 2.0 * np.outer(orthogonal, orthogonal) - np.eye(3) 

96 kmat = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) 

97 rotation: np.ndarray = np.eye(3) + kmat + ((1 - c) / s2) * kmat @ kmat 

98 return rotation 

99 

100 

101def create_bond_sticks( 

102 component: Component, 

103 atom_radius_scale: float, 

104 bond_radius: float, 

105 resolution: int = 100, 

106 default_color: tuple[float, float, float] = (0.7, 0.7, 0.7), 

107) -> list[o3d.geometry.TriangleMesh]: 

108 atom_df = component.atom_data 

109 coords = atom_df[["x", "y", "z"]].values 

110 elements = atom_df["element"].values 

111 radii = atom_df["element"].apply(estimate_vdw_radius).values * atom_radius_scale 

112 bonds = component.bonds 

113 cylinders: list[o3d.geometry.TriangleMesh] = [] 

114 for i, j in bonds: 

115 vector = coords[j] - coords[i] 

116 length = np.linalg.norm(vector) 

117 if length < radii[i] + radii[j]: 

118 continue 

119 axis = vector / length 

120 rotation = compute_rotation_matrix(Z_AXIS, axis) 

121 midpoint = (coords[i] + coords[j] + (radii[i] - radii[j]) * axis) / 2 

122 for k in (i, j): 

123 cylinder = o3d.geometry.TriangleMesh.create_cylinder( 

124 radius=bond_radius, 

125 height=np.linalg.norm(coords[k] - midpoint), 

126 resolution=resolution, 

127 ) 

128 cylinder.rotate(rotation, center=np.zeros(3)) 

129 cylinder.translate((coords[k] + midpoint) / 2) 

130 cylinder.compute_vertex_normals() 

131 rgb = np.array(ELEMENT_COLORS.get(elements[k], default_color)) 

132 cylinder.paint_uniform_color(rgb) 

133 cylinders.append(cylinder) 

134 return cylinders 

135 

136 

137def ball_pivoting_algorithm( 

138 points: np.ndarray, 

139 normals: np.ndarray, 

140 vertex_rgb: np.ndarray, 

141 radii_multipliers: tuple[float, float, float], 

142) -> tuple[o3d.geometry.TriangleMesh, np.ndarray]: 

143 pcd = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(points)) 

144 pcd.normals = o3d.utility.Vector3dVector(normals) 

145 

146 spacing = np.asarray(pcd.compute_nearest_neighbor_distance()).mean().item() 

147 radii = o3d.utility.DoubleVector([m * spacing for m in radii_multipliers]) 

148 

149 mesh_bpa = o3d.geometry.TriangleMesh.create_from_point_cloud_ball_pivoting( 

150 pcd, radii 

151 ) 

152 

153 mesh_bpa.remove_degenerate_triangles() 

154 mesh_bpa.remove_duplicated_triangles() 

155 mesh_bpa.remove_non_manifold_edges() 

156 mesh_bpa.remove_unreferenced_vertices() 

157 

158 kdtree = o3d.geometry.KDTreeFlann(pcd) 

159 indices = np.empty(len(mesh_bpa.vertices), dtype=int) 

160 for vi, v in enumerate(mesh_bpa.vertices): 

161 _, idx, _ = kdtree.search_knn_vector_3d(v, 1) 

162 indices[vi] = int(idx[0]) 

163 

164 vertex_rgb = vertex_rgb[indices] 

165 mesh_bpa.vertex_colors = o3d.utility.Vector3dVector(vertex_rgb) 

166 

167 return mesh_bpa, indices 

168 

169 

170def find_loops( 

171 mesh: o3d.geometry.TriangleMesh, edge_color: str 

172) -> list[o3d.geometry.LineSet]: 

173 graph = nx.Graph() 

174 for triangle in mesh.triangles: 

175 _, j, k = map(int, triangle) 

176 graph.add_edge(j, k) 

177 loops = nx.cycle_basis(graph) 

178 

179 vertices = np.asarray(mesh.vertices, dtype=float) 

180 linesets: list[o3d.geometry.LineSet] = [] 

181 

182 for loop in loops: 

183 idx = np.asarray(loop + [loop[0]], dtype=int) 

184 pts = vertices[idx] 

185 lines = np.column_stack( 

186 [np.arange(len(idx) - 1), np.arange(1, len(idx))] 

187 ).astype(np.int32) 

188 lineset = o3d.geometry.LineSet( 

189 points=o3d.utility.Vector3dVector(pts), 

190 lines=o3d.utility.Vector2iVector(lines), 

191 ) 

192 if edge_color is not None: 

193 rgb = np.asarray(cmap.Color(edge_color))[:3] 

194 lineset.paint_uniform_color(rgb) 

195 linesets.append(lineset) 

196 

197 return linesets 

198 

199 

200def geodesic_centroid(center: np.ndarray, *vertices: np.ndarray) -> np.ndarray: 

201 num_vertices = len(vertices) 

202 vectors = [v - center for v in vertices] 

203 norms = [np.linalg.norm(v) for v in vectors] 

204 radius = sum(norms) / num_vertices 

205 mean_vector = sum(vectors) / num_vertices 

206 centroid: np.ndarray = center + radius * mean_vector / np.linalg.norm(mean_vector) 

207 return centroid 

208 

209 

210def surface_tessellation( 

211 component: Component, 

212 original_charge_densities: bool = False, 

213 interpolated_colors: bool = False, 

214 colormap: str = "jet", 

215) -> o3d.geometry.TriangleMesh: 

216 segment_data = component.segment_data 

217 atom_data = component.atom_data 

218 sigma_grid = component.sigma_grid 

219 vmin, vmax = sigma_grid[0], sigma_grid[-1] 

220 sigmas = segment_data[ 

221 "sigma" if original_charge_densities else "sigma_avg" 

222 ].values.clip(vmin, vmax) 

223 

224 atom_coords = np.stack( 

225 [segment_data["atom"].map(atom_data[axis]).values for axis in "xyz"], axis=1 

226 ) 

227 pts = segment_data[["x", "y", "z"]].values 

228 displacements = pts - atom_coords 

229 normals = displacements / np.linalg.norm(displacements, axis=1, keepdims=True) 

230 

231 normalized_sigmas = (sigmas.clip(vmin, vmax) - vmin) / (vmax - vmin) 

232 mapper = cmap.Colormap(colormap) 

233 vertex_rgb = mapper(normalized_sigmas)[:, :3] 

234 

235 mesh_bpa, indices = ball_pivoting_algorithm( 

236 pts, normals, vertex_rgb, RADII_MULTIPLIERS 

237 ) 

238 

239 if interpolated_colors: 

240 return mesh_bpa 

241 

242 vertices = np.asarray(mesh_bpa.vertices, dtype=float) 

243 triangles = np.asarray(mesh_bpa.triangles, dtype=int) 

244 colors = np.asarray(mesh_bpa.vertex_colors, dtype=float) 

245 atoms = segment_data["atom"].values[indices] 

246 

247 new_vertices = vertices.tolist() 

248 new_colors = colors.tolist() 

249 

250 def add_vertex(v: np.ndarray, c: np.ndarray) -> int: 

251 idx = len(new_vertices) 

252 new_vertices.append(v) 

253 new_colors.append(c) 

254 return idx 

255 

256 midpoint_cache: dict[tuple[int, int], int] = {} 

257 

258 def midpoint_vertices(i: int, j: int) -> tuple[int, int]: 

259 if (i, j) in midpoint_cache: 

260 return midpoint_cache[(i, j)], midpoint_cache[(j, i)] 

261 

262 if atoms[i] == atoms[j]: 

263 midpoint = geodesic_centroid( 

264 atom_coords[atoms[i]], vertices[i], vertices[j] 

265 ) 

266 else: 

267 midpoint = (vertices[i] + vertices[j]) / 2 

268 

269 mij = midpoint_cache[(i, j)] = add_vertex(midpoint, colors[i]) 

270 mji = midpoint_cache[(j, i)] = add_vertex(midpoint, colors[j]) 

271 return mij, mji 

272 

273 new_triangles = [] 

274 

275 for triangle in triangles: 

276 i, j, k = map(int, triangle) 

277 mij, mji = midpoint_vertices(i, j) 

278 mjk, mkj = midpoint_vertices(j, k) 

279 mik, mki = midpoint_vertices(i, k) 

280 

281 if atoms[i] == atoms[j] == atoms[k]: 

282 centroid = geodesic_centroid( 

283 atom_coords[atoms[i]], vertices[i], vertices[j], vertices[k] 

284 ) 

285 else: 

286 centroid = (vertices[i] + vertices[j] + vertices[k]) / 3 

287 

288 mijk = add_vertex(centroid, colors[i]) 

289 mjki = add_vertex(centroid, colors[j]) 

290 mkij = add_vertex(centroid, colors[k]) 

291 

292 new_triangles += [ 

293 [i, mij, mijk], 

294 [i, mik, mijk], 

295 [j, mji, mjki], 

296 [j, mjk, mjki], 

297 [k, mkj, mkij], 

298 [k, mki, mkij], 

299 ] 

300 

301 mesh = o3d.geometry.TriangleMesh( 

302 vertices=o3d.utility.Vector3dVector(new_vertices), 

303 triangles=o3d.utility.Vector3iVector(new_triangles), 

304 ) 

305 mesh.vertex_colors = o3d.utility.Vector3dVector(new_colors) 

306 mesh.compute_vertex_normals() 

307 

308 return mesh 

309 

310 

311def generate_geometries( 

312 component: Component, 

313 original_charge_densities: bool = False, 

314 use_continuous_colors: bool = False, 

315 colormap: str = "jet", 

316 segment_edge_color: str | None = None, 

317) -> tuple[o3d.geometry.Geometry3D, ...]: 

318 """Build Open3D geometries for visualizing a component's COSMO surface. 

319 

320 Returns a tuple of Open3D geometries: 

321 

322 (1) a tessellated surface mesh colored by screening charge density; 

323 (2) optionally, segment-boundary loop line sets when ``segment_edge_color`` is set; 

324 (3) atom spheres; and 

325 (4) bond sticks. 

326 

327 Parameters 

328 ---------- 

329 component : Component 

330 The molecular component whose COSMO surface is to be visualized. 

331 original_charge_densities : bool, optional 

332 If ``True``, color the surface using the original (unsmoothed) segment 

333 charge densities instead of the distance-weighted averages. Default is 

334 ``False``. 

335 use_continuous_colors : bool, optional 

336 If ``True``, use interpolated colors across the surface; otherwise, 

337 segments are uniformly colored. Default is ``False``. 

338 colormap : str, optional 

339 Name of the colormap used to map charge density to color (e.g. 

340 ``"jet"``, ``"viridis"``). Default is ``"jet"``. 

341 segment_edge_color : str or None, optional 

342 Color name for the edges between segments (e.g. ``"black"``). 

343 If ``None`` or if ``use_continuous_colors`` is ``True``, no edge 

344 loops are drawn. Default is ``None``. 

345 

346 Returns 

347 ------- 

348 tuple of Geometry3D 

349 A sequence of Open3D geometries: mesh, loops (if any), atom spheres, 

350 and bond sticks. 

351 

352 Examples 

353 -------- 

354 >>> from importlib.resources import files 

355 >>> from cosmolayer.cosmosac import Component 

356 >>> from cosmolayer.cosmosac.visualize import generate_geometries 

357 >>> path = files("cosmolayer.data") / "C=C(N)O.cosmo" 

358 >>> component = Component(path.read_text()) 

359 >>> geometries = generate_geometries(component) 

360 >>> len(geometries) >= 1 

361 True 

362 >>> type(geometries[0]).__name__ 

363 'TriangleMesh' 

364 >>> geometries_loops = generate_geometries(component, segment_edge_color="black") 

365 >>> len(geometries_loops) > len(geometries) 

366 True 

367 """ 

368 mesh = surface_tessellation( 

369 component, 

370 original_charge_densities, 

371 use_continuous_colors, 

372 colormap, 

373 ) 

374 if segment_edge_color is None or use_continuous_colors: 

375 loops = [] 

376 else: 

377 loops = find_loops(mesh, segment_edge_color) 

378 atom_spheres = create_atom_spheres(component, 0.25) 

379 bond_sticks = create_bond_sticks(component, 0.25, 0.1) 

380 return (mesh, *loops, *atom_spheres, *bond_sticks) 

381 

382 

383def get_parser() -> argparse.ArgumentParser: 

384 """Return the argument parser for cosmoviz (used by sphinx-argparse).""" 

385 parser = argparse.ArgumentParser( 

386 prog="cosmoviz", 

387 formatter_class=argparse.RawTextHelpFormatter, 

388 description="Visualize COSMO files", 

389 ) 

390 parser.add_argument( 

391 "cosmo_file", 

392 type=pathlib.Path, 

393 help="Path to a COSMO quantum mechanical output file", 

394 ) 

395 parser.add_argument( 

396 "--show-original-charge-densities", 

397 action="store_true", 

398 help="Show original charge densities instead of smoothed ones", 

399 ) 

400 parser.add_argument( 

401 "--use-continuous-colors", 

402 action="store_true", 

403 help="Use continuous colors instead of uniformly colored segments", 

404 ) 

405 parser.add_argument( 

406 "--segment-edge-color", 

407 type=str, 

408 default=None, 

409 help="Color of the edges between segments (default: None)", 

410 ) 

411 parser.add_argument( 

412 "--colormap", 

413 type=str, 

414 default="jet", 

415 help="Matplotlib colormap name (default: jet)", 

416 ) 

417 return parser 

418 

419 

420def main() -> None: 

421 args = get_parser().parse_args() 

422 component = Component(args.cosmo_file.read_text()) 

423 geometries = generate_geometries( 

424 component, 

425 args.show_original_charge_densities, 

426 args.use_continuous_colors, 

427 args.colormap, 

428 args.segment_edge_color, 

429 ) 

430 o3d.visualization.draw_geometries( # ty: ignore[possibly-missing-submodule] 

431 geometries, 

432 mesh_show_back_face=True, 

433 window_name=f"Surface Segments from {args.cosmo_file.name}", 

434 ) 

435 

436 

437if __name__ == "__main__": 

438 main()