pyphyschemtools 0.1.0__py3-none-any.whl → 0.1.1__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.
- pyphyschemtools/.__init__.py.swp +0 -0
- pyphyschemtools/.ipynb_checkpoints/Chem3D-checkpoint.py +835 -0
- pyphyschemtools/.ipynb_checkpoints/PeriodicTable-checkpoint.py +294 -0
- pyphyschemtools/.ipynb_checkpoints/aithermo-checkpoint.py +349 -0
- pyphyschemtools/.ipynb_checkpoints/core-checkpoint.py +120 -0
- pyphyschemtools/.ipynb_checkpoints/spectra-checkpoint.py +471 -0
- pyphyschemtools/.ipynb_checkpoints/survey-checkpoint.py +1048 -0
- pyphyschemtools/.ipynb_checkpoints/sympyUtilities-checkpoint.py +51 -0
- pyphyschemtools/.ipynb_checkpoints/tools4AS-checkpoint.py +964 -0
- pyphyschemtools/Chem3D.py +12 -8
- pyphyschemtools/ML.py +6 -4
- pyphyschemtools/PeriodicTable.py +9 -4
- pyphyschemtools/__init__.py +3 -3
- pyphyschemtools/aithermo.py +5 -6
- pyphyschemtools/core.py +7 -6
- pyphyschemtools/spectra.py +78 -58
- pyphyschemtools/survey.py +0 -449
- pyphyschemtools/sympyUtilities.py +9 -9
- pyphyschemtools/tools4AS.py +12 -8
- {pyphyschemtools-0.1.0.dist-info → pyphyschemtools-0.1.1.dist-info}/METADATA +2 -2
- {pyphyschemtools-0.1.0.dist-info → pyphyschemtools-0.1.1.dist-info}/RECORD +29 -20
- /pyphyschemtools/{icons-logos-banner → icons_logos_banner}/Logo_pyPhysChem_border.svg +0 -0
- /pyphyschemtools/{icons-logos-banner → icons_logos_banner}/__init__.py +0 -0
- /pyphyschemtools/{icons-logos-banner → icons_logos_banner}/logo.png +0 -0
- /pyphyschemtools/{icons-logos-banner → icons_logos_banner}/tools4pyPC_banner.png +0 -0
- /pyphyschemtools/{icons-logos-banner → icons_logos_banner}/tools4pyPC_banner.svg +0 -0
- {pyphyschemtools-0.1.0.dist-info → pyphyschemtools-0.1.1.dist-info}/WHEEL +0 -0
- {pyphyschemtools-0.1.0.dist-info → pyphyschemtools-0.1.1.dist-info}/licenses/LICENSE +0 -0
- {pyphyschemtools-0.1.0.dist-info → pyphyschemtools-0.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
############################################################
|
|
2
|
+
# 3D Chemistry
|
|
3
|
+
############################################################
|
|
4
|
+
from .visualID_Eng import fg, bg, hl
|
|
5
|
+
from .core import centerTitle, centertxt
|
|
6
|
+
|
|
7
|
+
import py3Dmol
|
|
8
|
+
import io, os
|
|
9
|
+
from ase import Atoms
|
|
10
|
+
from ase.io import read, write
|
|
11
|
+
from ase.data import vdw_radii, atomic_numbers
|
|
12
|
+
import requests
|
|
13
|
+
import numpy as np
|
|
14
|
+
from ipywidgets import GridspecLayout, VBox, Label, Layout
|
|
15
|
+
import CageCavityCalc as CCC
|
|
16
|
+
|
|
17
|
+
# ============================================================
|
|
18
|
+
# Jmol-like element color palette
|
|
19
|
+
# ============================================================
|
|
20
|
+
JMOL_COLORS = {
|
|
21
|
+
'H': '#FFFFFF',
|
|
22
|
+
'C': '#909090',
|
|
23
|
+
'N': '#3050F8',
|
|
24
|
+
'O': '#FF0D0D',
|
|
25
|
+
'F': '#90E050',
|
|
26
|
+
'Cl': '#1FF01F',
|
|
27
|
+
'Br': '#A62929',
|
|
28
|
+
'I': '#940094',
|
|
29
|
+
'S': '#FFFF30',
|
|
30
|
+
'P': '#FF8000',
|
|
31
|
+
'B': '#FFB5B5',
|
|
32
|
+
'Si': '#F0C8A0',
|
|
33
|
+
|
|
34
|
+
'Li': '#CC80FF',
|
|
35
|
+
'Na': '#AB5CF2',
|
|
36
|
+
'K': '#8F40D4',
|
|
37
|
+
'Mg': '#8AFF00',
|
|
38
|
+
'Ca': '#3DFF00',
|
|
39
|
+
|
|
40
|
+
'Fe': '#E06633',
|
|
41
|
+
'Co': '#F090A0',
|
|
42
|
+
'Ni': '#50D050',
|
|
43
|
+
'Cu': '#C88033',
|
|
44
|
+
'Zn': '#7D80B0',
|
|
45
|
+
|
|
46
|
+
'Ru': '#248F8F', # Ruthenium (Jmol faithful)
|
|
47
|
+
'Rh': '#E000E0',
|
|
48
|
+
'Pd': '#A0A0C0',
|
|
49
|
+
'Ag': '#C0C0C0',
|
|
50
|
+
'Pt': '#D0D0D0',
|
|
51
|
+
'Au': '#FFD123',
|
|
52
|
+
'Ir': '#175487',
|
|
53
|
+
'Os': '#266696',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class XYZData:
|
|
57
|
+
"""
|
|
58
|
+
Object containing molecular coordinates and symbols extracted by molView.
|
|
59
|
+
Allows for geometric calculations without reloading data.
|
|
60
|
+
"""
|
|
61
|
+
def __init__(self, symbols, positions):
|
|
62
|
+
self.symbols = np.array(symbols)
|
|
63
|
+
self.positions = np.array(positions, dtype=float)
|
|
64
|
+
|
|
65
|
+
def get_center_of_mass(self):
|
|
66
|
+
return np.mean(self.positions, axis=0)
|
|
67
|
+
|
|
68
|
+
def get_center_of_geometry(self):
|
|
69
|
+
"""
|
|
70
|
+
Calculates the arithmetic mean of the atomic positions (Centroid).
|
|
71
|
+
"""
|
|
72
|
+
return np.mean(self.positions, axis=0)
|
|
73
|
+
|
|
74
|
+
def get_bounding_sphere(self, include_vdw=True, scale=1.0):
|
|
75
|
+
"""
|
|
76
|
+
Calculates the center and radius of the bounding sphere using ASE.
|
|
77
|
+
scale: multiplication factor (e.g., 0.6 to match a reduced CPK style).
|
|
78
|
+
"""
|
|
79
|
+
center = np.mean(self.positions, axis=0)
|
|
80
|
+
distances = np.linalg.norm(self.positions - center, axis=1)
|
|
81
|
+
|
|
82
|
+
if include_vdw:
|
|
83
|
+
z_numbers = [atomic_numbers[s] for s in self.symbols]
|
|
84
|
+
radii = vdw_radii[z_numbers] * scale
|
|
85
|
+
radius = np.max(distances + radii)
|
|
86
|
+
else:
|
|
87
|
+
radius = np.max(distances)
|
|
88
|
+
|
|
89
|
+
return center, radius
|
|
90
|
+
|
|
91
|
+
def get_cage_volume(self, grid_spacing=0.5, return_spheres=False):
|
|
92
|
+
"""
|
|
93
|
+
Calculates the internal cavity volume of a molecular cage using CageCavityCalc.
|
|
94
|
+
|
|
95
|
+
This method interfaces with the CageCavityCalc library by generating a
|
|
96
|
+
temporary PDB file of the current structure. It can also retrieve the
|
|
97
|
+
coordinates of the 'dummy atoms' (points) that fill the detected void.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
grid_spacing : float, optional
|
|
102
|
+
The resolution of the grid used for volume integration in Å.
|
|
103
|
+
Smaller values provide higher precision (default: 0.5).
|
|
104
|
+
return_spheres : bool, optional
|
|
105
|
+
If True, returns both the volume and an ase.Atoms object
|
|
106
|
+
containing the dummy atoms representing the cavity (default: False).
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
volume : float or None
|
|
111
|
+
The calculated cavity volume in ų. Returns None if the
|
|
112
|
+
calculation fails.
|
|
113
|
+
cavity_atoms : ase.Atoms, optional
|
|
114
|
+
Returned only if return_spheres is True. An ASE Atoms object
|
|
115
|
+
representing the internal void space.
|
|
116
|
+
"""
|
|
117
|
+
import tempfile
|
|
118
|
+
import os
|
|
119
|
+
from ase import Atoms
|
|
120
|
+
from ase.io import read as ase_read, write as ase_write
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
from CageCavityCalc.CageCavityCalc import cavity
|
|
124
|
+
|
|
125
|
+
# 1. Fichier temporaire pour la cage
|
|
126
|
+
with tempfile.NamedTemporaryFile(suffix=".pdb", delete=False) as tmp:
|
|
127
|
+
cage_tmp = tmp.name
|
|
128
|
+
temp_atoms = Atoms(symbols=self.symbols, positions=self.positions)
|
|
129
|
+
ase_write(cage_tmp, temp_atoms)
|
|
130
|
+
|
|
131
|
+
cav = cavity()
|
|
132
|
+
cav.read_file(cage_tmp)
|
|
133
|
+
cav.grid_spacing = float(grid_spacing)
|
|
134
|
+
cav.dummy_atom_radii = float(grid_spacing)
|
|
135
|
+
volume = cav.calculate_volume()
|
|
136
|
+
|
|
137
|
+
cavity_atoms = None
|
|
138
|
+
if return_spheres:
|
|
139
|
+
with tempfile.NamedTemporaryFile(suffix=".pdb", delete=False) as tmp2:
|
|
140
|
+
cav_tmp = tmp2.name
|
|
141
|
+
|
|
142
|
+
cav.print_to_file(cav_tmp)
|
|
143
|
+
|
|
144
|
+
# --- NOUVEAU : Correction pour ASE (remplace ' D ' par ' H ') ---
|
|
145
|
+
with open(cav_tmp, 'r') as f:
|
|
146
|
+
content = f.read().replace(' D ', ' H ') # On transforme les Dummy en Hydrogène
|
|
147
|
+
with open(cav_tmp, 'w') as f:
|
|
148
|
+
f.write(content)
|
|
149
|
+
|
|
150
|
+
# Maintenant ASE peut lire le fichier sans erreur
|
|
151
|
+
cavity_atoms = ase_read(cav_tmp)
|
|
152
|
+
|
|
153
|
+
if os.path.exists(cav_tmp):
|
|
154
|
+
os.remove(cav_tmp)
|
|
155
|
+
|
|
156
|
+
# ... (fin de la fonction) ...
|
|
157
|
+
if return_spheres:
|
|
158
|
+
return volume, cavity_atoms
|
|
159
|
+
return volume
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
print(f"Erreur CageCavityCalc : {e}")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def get_cavity_dimensions(self, cavity_atoms):
|
|
166
|
+
"""
|
|
167
|
+
Calculates the principal dimensions (Length, Width, Height) of the cavity points.
|
|
168
|
+
|
|
169
|
+
This method uses Principal Component Analysis (PCA) to find the natural
|
|
170
|
+
axes of the cavity, making it independent of the molecule's orientation.
|
|
171
|
+
Percentiles are used instead of absolute Max-Min to filter out
|
|
172
|
+
potential outliers or 'leaking' points at the openings.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
cavity_atoms : ase.Atoms
|
|
177
|
+
The Atoms object containing the 'dummy atoms' generated
|
|
178
|
+
by the cavity calculation.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
tuple (float, float, float)
|
|
183
|
+
The dimensions (L, W, H) sorted from largest to smallest.
|
|
184
|
+
"""
|
|
185
|
+
import numpy as np
|
|
186
|
+
|
|
187
|
+
# On récupère les positions des dummy atoms (les points de vide)
|
|
188
|
+
points = cavity_atoms.get_positions()
|
|
189
|
+
|
|
190
|
+
if len(points) < 2:
|
|
191
|
+
return 0, 0, 0
|
|
192
|
+
|
|
193
|
+
# Center the points at the origin (Arithmetic Mean)
|
|
194
|
+
centered_points = points - np.mean(points, axis=0)
|
|
195
|
+
|
|
196
|
+
# Compute the Covariance Matrix to find the spread direction
|
|
197
|
+
cov = np.cov(centered_points, rowvar=False)
|
|
198
|
+
|
|
199
|
+
# Compute Eigenvalues and Eigenvectors
|
|
200
|
+
# Eigenvectors represent the principal axes of the cavity
|
|
201
|
+
evals, evecs = np.linalg.eigh(cov)
|
|
202
|
+
|
|
203
|
+
# Project the points onto the principal axes (PCA transformation)
|
|
204
|
+
projections = np.dot(centered_points, evecs)
|
|
205
|
+
|
|
206
|
+
dims = []
|
|
207
|
+
for i in range(3):
|
|
208
|
+
# Calculate the spread using percentiles (2% to 98%)
|
|
209
|
+
# This is more robust than np.ptp() as it ignores outliers
|
|
210
|
+
p_min = np.percentile(projections[:, i], 2)
|
|
211
|
+
p_max = np.percentile(projections[:, i], 98)
|
|
212
|
+
dims.append(p_max - p_min)
|
|
213
|
+
|
|
214
|
+
# Sort dimensions from largest to smallest
|
|
215
|
+
dims = sorted(dims, reverse=True)
|
|
216
|
+
|
|
217
|
+
return dims[0], dims[1], dims[2]
|
|
218
|
+
|
|
219
|
+
def __repr__(self):
|
|
220
|
+
return f"<XYZData: {len(self.symbols)} atoms>"
|
|
221
|
+
|
|
222
|
+
class molView:
|
|
223
|
+
"""
|
|
224
|
+
Initializes a molecular/crystal viewer and coordinate extractor.
|
|
225
|
+
|
|
226
|
+
This class acts as a bridge between various molecular data sources and
|
|
227
|
+
the py3Dmol interactive viewer. It can operate in 'Full' mode (display +
|
|
228
|
+
analysis) or 'Headless' mode (analysis only) by toggling the `viewer` parameter.
|
|
229
|
+
|
|
230
|
+
The class automatically extracts geometric data into the `self.data` attribute
|
|
231
|
+
(an XYZData object), allowing for volume, dimension, and cavity calculations.
|
|
232
|
+
|
|
233
|
+
Display molecular and crystal structures in py3Dmol from various sources:
|
|
234
|
+
|
|
235
|
+
- XYZ/PDB/CIF local files
|
|
236
|
+
- XYZ-format string
|
|
237
|
+
- PubChem CID
|
|
238
|
+
- ASE Atoms object
|
|
239
|
+
- COD ID
|
|
240
|
+
- RSCB PDB ID
|
|
241
|
+
|
|
242
|
+
Three visualization styles are available:
|
|
243
|
+
|
|
244
|
+
- 'bs' : ball-and-stick (default)
|
|
245
|
+
- 'cpk' : CPK space-filling spheres (with adjustable size)
|
|
246
|
+
- 'cartoon': protein backbone representation
|
|
247
|
+
|
|
248
|
+
Upon creation, an interactive 3D viewer is shown directly in a Jupyter notebook cell, unless the headless viewer parameter is set to False.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
mol : str or ase.Atoms
|
|
253
|
+
The molecular structure to visualize.
|
|
254
|
+
|
|
255
|
+
- If `source='file'`, this should be a path to a structure file (XYZ, PDB, etc.)
|
|
256
|
+
- If `source='mol'`, this should be a string containing the structure (XYZ, PDB...)
|
|
257
|
+
- If `source='cif'`, this should be a cif file (string)
|
|
258
|
+
- If `source='cid'`, this should be a PubChem CID (string or int)
|
|
259
|
+
- If `source='rscb'`, this should be a RSCB PDB ID (string)
|
|
260
|
+
- If `source='cod'`, this should be a COD ID (string)
|
|
261
|
+
- If `source='ase'`, this should be an `ase.Atoms` object
|
|
262
|
+
source : {'file', 'mol', 'cif', 'cid', 'rscb', 'ase'}, optional
|
|
263
|
+
The type of the input `mol` (default: 'file').
|
|
264
|
+
style : {'bs', 'cpk', 'cartoon'}, optional
|
|
265
|
+
Visualization style (default: 'bs').
|
|
266
|
+
|
|
267
|
+
- 'bs' → ball-and-stick
|
|
268
|
+
- 'cpk' → CPK space-filling spheres
|
|
269
|
+
- 'cartoon' → draws a smooth tube or ribbon through the protein backbone
|
|
270
|
+
(default for pdb structures)
|
|
271
|
+
displayHbonds : plots hydrogen bonds (default: True)
|
|
272
|
+
cpk_scale : float, optional
|
|
273
|
+
Overall scaling factor for sphere size in CPK style (default: 0.5).
|
|
274
|
+
Ignored when `style='bs'`.
|
|
275
|
+
supercell : tuple of int
|
|
276
|
+
Repetition of the unit cell (na, nb, nc). Default is (1, 1, 1).
|
|
277
|
+
w : int, optional
|
|
278
|
+
Width of the viewer in pixels (default: 600).
|
|
279
|
+
h : int, optional
|
|
280
|
+
Height of the viewer in pixels (default: 400).
|
|
281
|
+
detect_BondOrders : bool, optional
|
|
282
|
+
If True (default) and input is XYZ, uses RDKit to perceive connectivity
|
|
283
|
+
and bond orders (detects double/triple bonds).
|
|
284
|
+
Requires the `rdkit` library. If False, fallback to standard 3Dmol
|
|
285
|
+
distance-based single bonds.
|
|
286
|
+
viewer : bool, optional
|
|
287
|
+
If True (default), initializes the py3Dmol viewer and renders the
|
|
288
|
+
molecule. If False, operates in 'headless' mode: only coordinates
|
|
289
|
+
are processed for calculations (default: True).
|
|
290
|
+
zoom : None, optional
|
|
291
|
+
scaling factor
|
|
292
|
+
|
|
293
|
+
Attributes
|
|
294
|
+
----------
|
|
295
|
+
data : XYZData or None
|
|
296
|
+
Container for atomic symbols and positions, used for geometric analysis.
|
|
297
|
+
v : py3Dmol.view or None
|
|
298
|
+
The 3Dmol.js viewer instance (None if viewer=False).
|
|
299
|
+
|
|
300
|
+
Examples
|
|
301
|
+
--------
|
|
302
|
+
>>> molView("molecule.xyz", source="file")
|
|
303
|
+
>>> molView(xyz_string, source="mol")
|
|
304
|
+
>>> molView(2244, source="cid") # PubChem aspirin
|
|
305
|
+
>>> from ase.build import molecule
|
|
306
|
+
>>> molView(molecule("H2O"), source="ase")
|
|
307
|
+
>>> molView.view_grid([2244, 2519, 702], n_cols=3, source='cid', style='bs')
|
|
308
|
+
>>> molView.view_grid(xyzFiles, n_cols=3, source='file', style='bs', titles=titles, w=500, sync=True)
|
|
309
|
+
>>> # Headless mode for high-throughput volume calculations
|
|
310
|
+
>>> mv = molView("cage.xyz", viewer=False)
|
|
311
|
+
>>> vol = mv.data.get_cage_volume()
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, mol, source='file', style='bs', displayHbonds=True, cpk_scale=0.6, w=600, h=400,\
|
|
315
|
+
supercell=(1, 1, 1), display_now=True, detect_BondOrders=True, viewer=True, zoom=None):
|
|
316
|
+
self.mol = mol
|
|
317
|
+
self.source = source
|
|
318
|
+
self.style = style
|
|
319
|
+
self.cpk_scale = cpk_scale
|
|
320
|
+
self.displayHbonds = displayHbonds
|
|
321
|
+
self.w = w
|
|
322
|
+
self.h = h
|
|
323
|
+
self.detect_bonds = detect_BondOrders # Store the option
|
|
324
|
+
self.supercell = supercell
|
|
325
|
+
self.viewer = viewer
|
|
326
|
+
self.zoom = zoom
|
|
327
|
+
self.v = py3Dmol.view(width=self.w, height=self.h) # Création du viewer une seule fois
|
|
328
|
+
self._load_and_display(show=display_now)
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def view_grid(cls, mol_list, n_cols=3, titles=None, **kwargs):
|
|
332
|
+
"""
|
|
333
|
+
Displays a list of molecular structures in an interactive n_rows x n_cols grid.
|
|
334
|
+
|
|
335
|
+
This method uses ipywidgets.GridspecLayout to organize multiple 3D viewers
|
|
336
|
+
into a clean matrix. It automatically calculates the required number of rows
|
|
337
|
+
based on the length of the input list.
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
mol_list : list
|
|
342
|
+
A list containing the molecular data to visualize. Elements should
|
|
343
|
+
match the expected 'mol' input for the class (paths, CIDs, strings, etc.).
|
|
344
|
+
n_cols : int, optional
|
|
345
|
+
Number of columns in the grid (default: 3).
|
|
346
|
+
titles : list of str, optional
|
|
347
|
+
Custom labels for each cell. If None, the string representation
|
|
348
|
+
of the 'mol' input is used as the title.
|
|
349
|
+
**kwargs : dict
|
|
350
|
+
Additional arguments passed to the molView constructor:
|
|
351
|
+
- source : {'file', 'mol', 'cif', 'cid', 'rscb', 'ase'}
|
|
352
|
+
- style : {'bs', 'cpk', 'cartoon'}
|
|
353
|
+
- displayHbonds : plots hydrogen bonds (default: True)
|
|
354
|
+
- w : width of each individual viewer in pixels (default: 300)
|
|
355
|
+
- h : height of each individual viewer in pixels (default: 300)
|
|
356
|
+
- supercell : tuple (na, nb, nc) for crystal structures
|
|
357
|
+
- cpk_scale : scaling factor for space-filling spheres
|
|
358
|
+
|
|
359
|
+
Returns
|
|
360
|
+
-------
|
|
361
|
+
ipywidgets.GridspecLayout
|
|
362
|
+
A widget object containing the grid of molecular viewers.
|
|
363
|
+
|
|
364
|
+
Examples
|
|
365
|
+
--------
|
|
366
|
+
>>> files = ["mol1.xyz", "mol2.xyz", "mol3.xyz", "mol4.xyz"]
|
|
367
|
+
>>> labels = ["Reactant", "TS", "Intermediate", "Product"]
|
|
368
|
+
>>> molView.view_grid(files, n_cols=2, titles=labels, source='file', w=400)
|
|
369
|
+
"""
|
|
370
|
+
from ipywidgets import GridspecLayout, VBox, Label, Layout, Output
|
|
371
|
+
from IPython.display import display
|
|
372
|
+
|
|
373
|
+
# 1. Gestion des dimensions
|
|
374
|
+
w_cell = kwargs.get('w', 300)
|
|
375
|
+
h_cell = kwargs.get('h', 300)
|
|
376
|
+
|
|
377
|
+
n_mol = len(mol_list)
|
|
378
|
+
n_rows = (n_mol + n_cols - 1) // n_cols # Calcul automatique du nombre de lignes
|
|
379
|
+
|
|
380
|
+
# Largeur totale pour éviter le scroll horizontal
|
|
381
|
+
total_width = n_cols * (w_cell + 25)
|
|
382
|
+
grid = GridspecLayout(n_rows, n_cols, layout=Layout(width=f'{total_width}px'))
|
|
383
|
+
|
|
384
|
+
kwargs['w'] = w_cell
|
|
385
|
+
kwargs['h'] = h_cell
|
|
386
|
+
kwargs['display_now'] = False # Indispensable pour garder le contrôle
|
|
387
|
+
|
|
388
|
+
# 2. Remplissage de la grille
|
|
389
|
+
for i, mol in enumerate(mol_list):
|
|
390
|
+
row, col = i // n_cols, i % n_cols
|
|
391
|
+
t = titles[i] if titles and i < len(titles) else str(mol)
|
|
392
|
+
|
|
393
|
+
# Création de l'instance (charge les données et styles)
|
|
394
|
+
obj = cls(mol, **kwargs)
|
|
395
|
+
|
|
396
|
+
# Widget de sortie pour capturer le rendu JS de py3Dmol
|
|
397
|
+
out = Output(layout=Layout(
|
|
398
|
+
width=f'{w_cell}px',
|
|
399
|
+
height=f'{h_cell}px',
|
|
400
|
+
overflow='hidden'
|
|
401
|
+
))
|
|
402
|
+
|
|
403
|
+
with out:
|
|
404
|
+
display(obj.v)
|
|
405
|
+
|
|
406
|
+
# Assemblage Titre + Molécule dans la cellule
|
|
407
|
+
grid[row, col] = VBox([
|
|
408
|
+
Label(value=t, layout=Layout(display='flex', justify_content='center', width='100%')),
|
|
409
|
+
out
|
|
410
|
+
], layout=Layout(
|
|
411
|
+
width=f'{w_cell + 15}px',
|
|
412
|
+
align_items='center',
|
|
413
|
+
overflow='hidden',
|
|
414
|
+
margin='5px'
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
return grid
|
|
418
|
+
|
|
419
|
+
def _get_ase_atoms(self, content, fmt):
|
|
420
|
+
"""Helper to convert string content to ASE Atoms and apply supercell."""
|
|
421
|
+
# Use ASE to parse the structure (more robust for symmetry)
|
|
422
|
+
atoms = read(io.StringIO(content), format=fmt)
|
|
423
|
+
if self.supercell != (1, 1, 1):
|
|
424
|
+
atoms = atoms * self.supercell
|
|
425
|
+
return atoms
|
|
426
|
+
|
|
427
|
+
def _draw_cell_vectors(self, cell, origin=(0, 0, 0),
|
|
428
|
+
radius=0.12, head_radius=0.25, head_length=0.6,
|
|
429
|
+
label_offset=0.15):
|
|
430
|
+
"""
|
|
431
|
+
Draw crystallographic vectors a, b, c as colored arrows
|
|
432
|
+
and add labels a, b, c at their tips.
|
|
433
|
+
|
|
434
|
+
a = red, b = blue, c = green
|
|
435
|
+
"""
|
|
436
|
+
a, b, c = np.array(cell, dtype=float)
|
|
437
|
+
o = np.array(origin, dtype=float)
|
|
438
|
+
|
|
439
|
+
vectors = {
|
|
440
|
+
"a": (a, "red"),
|
|
441
|
+
"b": (b, "blue"),
|
|
442
|
+
"c": (c, "green")
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for name, (vec, color) in vectors.items():
|
|
446
|
+
end = o + vec
|
|
447
|
+
|
|
448
|
+
# Arrow
|
|
449
|
+
self.v.addArrow({
|
|
450
|
+
"start": {
|
|
451
|
+
"x": float(o[0]), "y": float(o[1]), "z": float(o[2])
|
|
452
|
+
},
|
|
453
|
+
"end": {
|
|
454
|
+
"x": float(end[0]), "y": float(end[1]), "z": float(end[2])
|
|
455
|
+
},
|
|
456
|
+
"radius": float(radius),
|
|
457
|
+
"radiusRatio": head_radius / radius,
|
|
458
|
+
"mid": 0.85,
|
|
459
|
+
"color": color
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
# Label slightly beyond the arrow tip
|
|
463
|
+
label_pos = end + label_offset * vec / np.linalg.norm(vec)
|
|
464
|
+
|
|
465
|
+
self.v.addLabel(
|
|
466
|
+
name,
|
|
467
|
+
{
|
|
468
|
+
"position": {
|
|
469
|
+
"x": float(label_pos[0]),
|
|
470
|
+
"y": float(label_pos[1]),
|
|
471
|
+
"z": float(label_pos[2])
|
|
472
|
+
},
|
|
473
|
+
"fontColor": color,
|
|
474
|
+
"backgroundColor": "white",
|
|
475
|
+
"backgroundOpacity": 0.,
|
|
476
|
+
"fontSize": 16,
|
|
477
|
+
"borderThickness": 0
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
def _draw_lattice_wireframe(self, cell, reps, color="black", radius=0.05):
|
|
481
|
+
"""
|
|
482
|
+
Draw all unit cells of a supercell lattice as wireframes.
|
|
483
|
+
|
|
484
|
+
Parameters
|
|
485
|
+
----------
|
|
486
|
+
cell : ase.Cell
|
|
487
|
+
Primitive cell.
|
|
488
|
+
reps : tuple(int,int,int)
|
|
489
|
+
Supercell repetitions (na, nb, nc).
|
|
490
|
+
"""
|
|
491
|
+
a, b, c = np.array(cell, dtype=float)
|
|
492
|
+
na, nb, nc = reps
|
|
493
|
+
|
|
494
|
+
for i in range(na):
|
|
495
|
+
for j in range(nb):
|
|
496
|
+
for k in range(nc):
|
|
497
|
+
origin = i*a + j*b + k*c
|
|
498
|
+
self._draw_cell_wireframe(
|
|
499
|
+
cell,
|
|
500
|
+
color=color,
|
|
501
|
+
radius=radius,
|
|
502
|
+
origin=origin
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def _draw_cell_wireframe(self, cell, color="black", radius=0.05, origin=(0, 0, 0)):
|
|
506
|
+
"""
|
|
507
|
+
Draw a unit cell as a wireframe using py3Dmol lines.
|
|
508
|
+
Works with XYZ or CIF models.
|
|
509
|
+
"""
|
|
510
|
+
a, b, c = np.array(cell)
|
|
511
|
+
o = np.array(origin)
|
|
512
|
+
|
|
513
|
+
corners = [
|
|
514
|
+
o,
|
|
515
|
+
o + a,
|
|
516
|
+
o + b,
|
|
517
|
+
o + c,
|
|
518
|
+
o + a + b,
|
|
519
|
+
o + a + c,
|
|
520
|
+
o + b + c,
|
|
521
|
+
o + a + b + c
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
edges = [
|
|
525
|
+
(0,1), (0,2), (0,3),
|
|
526
|
+
(1,4), (1,5),
|
|
527
|
+
(2,4), (2,6),
|
|
528
|
+
(3,5), (3,6),
|
|
529
|
+
(4,7), (5,7), (6,7)
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
for i, j in edges:
|
|
533
|
+
self.v.addCylinder({
|
|
534
|
+
"start": {
|
|
535
|
+
"x": float(corners[i][0]),
|
|
536
|
+
"y": float(corners[i][1]),
|
|
537
|
+
"z": float(corners[i][2]),
|
|
538
|
+
},
|
|
539
|
+
"end": {
|
|
540
|
+
"x": float(corners[j][0]),
|
|
541
|
+
"y": float(corners[j][1]),
|
|
542
|
+
"z": float(corners[j][2]),
|
|
543
|
+
},
|
|
544
|
+
"color": color,
|
|
545
|
+
"radius": float(radius),
|
|
546
|
+
"fromCap": True,
|
|
547
|
+
"toCap": True
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
def _add_h_bonds(self, atoms, dist_max=2.5, angle_min=120):
|
|
551
|
+
"""
|
|
552
|
+
Detects and renders realistic H-bonds using ASE neighbor list.
|
|
553
|
+
Criteria: d(H...A) < dist_max & Angle(Donor-H...Acceptor) > angle_min
|
|
554
|
+
"""
|
|
555
|
+
from ase.neighborlist import neighbor_list
|
|
556
|
+
import numpy as np
|
|
557
|
+
|
|
558
|
+
# 1. Identify donors: Find Hydrogens covalently bonded to N or O
|
|
559
|
+
# i_cov: indices of H, j_cov: indices of parent atoms (N, O)
|
|
560
|
+
i_cov, j_cov = neighbor_list('ij', atoms, cutoff=1.2)
|
|
561
|
+
donors = {
|
|
562
|
+
idx_h: idx_d for idx_h, idx_d in zip(i_cov, j_cov)
|
|
563
|
+
if atoms[idx_h].symbol == 'H' and atoms[idx_d].symbol in ['N', 'O']
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
# 2. Search for potential acceptors near these Hydrogens
|
|
567
|
+
# i_h: indices of H, j_acc: indices of potential acceptors (N, O)
|
|
568
|
+
i_h, j_acc, d_ha = neighbor_list('ijd', atoms, cutoff=dist_max)
|
|
569
|
+
|
|
570
|
+
for idx_h, idx_acc, dist in zip(i_h, j_acc, d_ha):
|
|
571
|
+
# Validate: H is a known donor, Target is N or O, and not its own parent
|
|
572
|
+
if idx_h in donors and atoms[idx_acc].symbol in ['N', 'O']:
|
|
573
|
+
idx_d = donors[idx_h]
|
|
574
|
+
if idx_acc == idx_d:
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
# 3. Angle check: Donor-H...Acceptor
|
|
578
|
+
try:
|
|
579
|
+
# ASE get_angle returns the angle in degrees
|
|
580
|
+
angle = atoms.get_angle(idx_d, idx_h, idx_acc)
|
|
581
|
+
|
|
582
|
+
if angle >= angle_min:
|
|
583
|
+
p_h = atoms[idx_h].position
|
|
584
|
+
p_a = atoms[idx_acc].position
|
|
585
|
+
|
|
586
|
+
self.v.addCylinder({
|
|
587
|
+
'start': {'x': float(p_h[0]), 'y': float(p_h[1]), 'z': float(p_h[2])},
|
|
588
|
+
'end': {'x': float(p_a[0]), 'y': float(p_a[1]), 'z': float(p_a[2])},
|
|
589
|
+
'radius': 0.06,
|
|
590
|
+
'color': '#00FFFF', # Cyan
|
|
591
|
+
'dashed': True,
|
|
592
|
+
'fromCap': 1,
|
|
593
|
+
'toCap': 1
|
|
594
|
+
})
|
|
595
|
+
except Exception:
|
|
596
|
+
continue
|
|
597
|
+
|
|
598
|
+
def _load_and_display(self, show):
|
|
599
|
+
|
|
600
|
+
content = ""
|
|
601
|
+
fmt = "xyz"
|
|
602
|
+
|
|
603
|
+
# --- 1. Handle External API Sources ---
|
|
604
|
+
if self.source == 'cid':
|
|
605
|
+
if self.viewer: self.v = py3Dmol.view(query=f'cid:{self.mol}', width=self.w, height=self.h)
|
|
606
|
+
url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/{self.mol}/SDF?record_type=3d"
|
|
607
|
+
response = requests.get(url)
|
|
608
|
+
if response.status_code == 200:
|
|
609
|
+
content = response.text
|
|
610
|
+
fmt = "sdf"
|
|
611
|
+
|
|
612
|
+
elif self.source == 'rscb':
|
|
613
|
+
if self.viewer: self.v = py3Dmol.view(query=f'pdb:{self.mol}', width=self.w, height=self.h)
|
|
614
|
+
url = f"https://files.rcsb.org/view/{self.mol}.pdb"
|
|
615
|
+
response = requests.get(url)
|
|
616
|
+
if response.status_code == 200:
|
|
617
|
+
content = response.text
|
|
618
|
+
fmt = "pdb"
|
|
619
|
+
|
|
620
|
+
elif self.source == 'cod':
|
|
621
|
+
url = f"https://www.crystallography.net/cod/{self.mol}.cif"
|
|
622
|
+
response = requests.get(url)
|
|
623
|
+
if response.status_code == 200:
|
|
624
|
+
self.mol = response.text
|
|
625
|
+
self.source = 'cif'
|
|
626
|
+
else:
|
|
627
|
+
raise ValueError(f"Could not find COD ID: {self.mol}")
|
|
628
|
+
|
|
629
|
+
# --- FIX 1: Initialisation par défaut pour les sources non-API ---
|
|
630
|
+
if self.viewer and self.v is None:
|
|
631
|
+
self.v = py3Dmol.view(width=self.w, height=self.h)
|
|
632
|
+
|
|
633
|
+
# --- 2. Handle Logic for Files and Data ---
|
|
634
|
+
if self.source == 'file':
|
|
635
|
+
if not os.path.exists(self.mol):
|
|
636
|
+
raise FileNotFoundError(f"File not found: {self.mol}")
|
|
637
|
+
ext = os.path.splitext(self.mol)[1].lower().replace('.', '')
|
|
638
|
+
fmt = 'cif' if ext == 'cif' else ext
|
|
639
|
+
with open(self.mol, 'r') as f:
|
|
640
|
+
content = f.read()
|
|
641
|
+
|
|
642
|
+
elif self.source == 'cif':
|
|
643
|
+
content = self.mol
|
|
644
|
+
fmt = 'cif'
|
|
645
|
+
|
|
646
|
+
elif self.source == 'mol':
|
|
647
|
+
content = self.mol
|
|
648
|
+
fmt = 'xyz'
|
|
649
|
+
|
|
650
|
+
# --- EXTRACTION XYZData (Interne) ---
|
|
651
|
+
# On extrait les données ici avant toute modification (RDKit ou Supercell)
|
|
652
|
+
try:
|
|
653
|
+
if self.source == 'ase':
|
|
654
|
+
temp_atoms = self.mol
|
|
655
|
+
else:
|
|
656
|
+
temp_atoms = read(io.StringIO(content), format=fmt)
|
|
657
|
+
|
|
658
|
+
self.data = XYZData(
|
|
659
|
+
symbols=temp_atoms.get_chemical_symbols(),
|
|
660
|
+
positions=temp_atoms.get_positions()
|
|
661
|
+
)
|
|
662
|
+
except Exception as e:
|
|
663
|
+
print(f"Note: Extraction des coordonnées impossible ({e})")
|
|
664
|
+
self.data = None
|
|
665
|
+
|
|
666
|
+
# --- Modern Bond Perception with RDKit ---
|
|
667
|
+
if self.detect_bonds and self.source in ['file', 'mol', 'xyz'] and fmt == 'xyz':
|
|
668
|
+
try:
|
|
669
|
+
from rdkit import Chem
|
|
670
|
+
from rdkit.Chem import rdDetermineBonds
|
|
671
|
+
|
|
672
|
+
raw_mol = Chem.MolFromXYZBlock(content)
|
|
673
|
+
rdDetermineBonds.DetermineConnectivity(raw_mol)
|
|
674
|
+
rdDetermineBonds.DetermineBondOrders(raw_mol, charge=0)
|
|
675
|
+
|
|
676
|
+
content = Chem.MolToMolBlock(raw_mol)
|
|
677
|
+
fmt = "sdf"
|
|
678
|
+
except ImportError:
|
|
679
|
+
# Silent skip if RDKit is missing
|
|
680
|
+
pass
|
|
681
|
+
except Exception as e:
|
|
682
|
+
# Small warning if the geometry is the problem
|
|
683
|
+
print(f"Note: Bond perception failed for {self.mol}. Falling back to standard XYZ.")
|
|
684
|
+
|
|
685
|
+
# --- 3. Rendering Logic ---
|
|
686
|
+
if fmt == 'cif' or self.supercell != (1, 1, 1) or self.source == 'ase':
|
|
687
|
+
# Create ASE atoms object
|
|
688
|
+
if self.source == 'ase':
|
|
689
|
+
atoms = self.mol
|
|
690
|
+
else:
|
|
691
|
+
atoms = read(io.StringIO(content), format=fmt)
|
|
692
|
+
|
|
693
|
+
# --- CRYSTAL LOGIC (Jmol packed-like) ---
|
|
694
|
+
|
|
695
|
+
# 1. Read primitive cell (before supercell)
|
|
696
|
+
atoms0 = atoms.copy()
|
|
697
|
+
|
|
698
|
+
# 2. Apply supercell if requested
|
|
699
|
+
if self.supercell != (1, 1, 1):
|
|
700
|
+
atoms = atoms * self.supercell
|
|
701
|
+
|
|
702
|
+
# 3. Send atoms to py3Dmol (XYZ, robust)
|
|
703
|
+
xyz_buf = io.StringIO()
|
|
704
|
+
write(xyz_buf, atoms, format="xyz")
|
|
705
|
+
|
|
706
|
+
if self.viewer:
|
|
707
|
+
self.v.addModel(xyz_buf.getvalue(), "xyz")
|
|
708
|
+
|
|
709
|
+
# 4. Draw supercell (optional, thick & gray)
|
|
710
|
+
if self.supercell != (1, 1, 1):
|
|
711
|
+
self._draw_lattice_wireframe(
|
|
712
|
+
atoms0.cell,
|
|
713
|
+
self.supercell,
|
|
714
|
+
color="gray",
|
|
715
|
+
radius=0.015
|
|
716
|
+
)
|
|
717
|
+
self._draw_cell_wireframe(
|
|
718
|
+
atoms.cell,
|
|
719
|
+
color="gray",
|
|
720
|
+
radius=0.015
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# 5. Draw primitive cell (Jmol packed equivalent)
|
|
724
|
+
self._draw_cell_wireframe(
|
|
725
|
+
atoms0.cell,
|
|
726
|
+
color="black",
|
|
727
|
+
radius=0.03
|
|
728
|
+
)
|
|
729
|
+
# Vecteurs a, b, c
|
|
730
|
+
self._draw_cell_vectors(
|
|
731
|
+
atoms0.cell,
|
|
732
|
+
radius=0.04
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
else:
|
|
736
|
+
# Standard molecule (non-crystal)
|
|
737
|
+
if self.viewer:
|
|
738
|
+
self.v.addModel(content, fmt)
|
|
739
|
+
# FIX: Create the atoms object for standard molecules here
|
|
740
|
+
atoms = read(io.StringIO(content), format=fmt)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# Finalize
|
|
744
|
+
if self.viewer:
|
|
745
|
+
self._apply_style()
|
|
746
|
+
self._add_interactions()
|
|
747
|
+
# Detect and add H-bonds if hydrogens are present
|
|
748
|
+
symbols = atoms.get_chemical_symbols()
|
|
749
|
+
if 'H' in symbols and self.displayHbonds:
|
|
750
|
+
self._add_h_bonds(atoms)
|
|
751
|
+
self.v.zoomTo()
|
|
752
|
+
if self.zoom is not None:
|
|
753
|
+
self.v.zoom(self.zoom)
|
|
754
|
+
elif self.source != 'cif':
|
|
755
|
+
self.v.zoom(0.9) # Zoom par défaut pour ne pas coller aux bords
|
|
756
|
+
if show: self.v.show()
|
|
757
|
+
|
|
758
|
+
def _apply_element_colors(self, color_table):
|
|
759
|
+
"""
|
|
760
|
+
Override element colors without breaking the current style (bs / cpk).
|
|
761
|
+
"""
|
|
762
|
+
for elem, color in color_table.items():
|
|
763
|
+
if self.style == 'bs':
|
|
764
|
+
self.v.setStyle(
|
|
765
|
+
{'elem': elem},
|
|
766
|
+
{
|
|
767
|
+
'sphere': {'color': color, 'scale': 0.25},
|
|
768
|
+
'stick': {'color': color, 'radius': 0.15}
|
|
769
|
+
}
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
elif self.style == 'cpk':
|
|
773
|
+
self.v.setStyle(
|
|
774
|
+
{'elem': elem},
|
|
775
|
+
{
|
|
776
|
+
'sphere': {'color': color, 'scale': self.cpk_scale}
|
|
777
|
+
}
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def _apply_style(self):
|
|
781
|
+
"""Apply either ball-and-stick, cartoon or CPK style."""
|
|
782
|
+
|
|
783
|
+
if self.style == 'bs':
|
|
784
|
+
self.v.setStyle({'sphere': {'scale': 0.25, 'colorscheme': 'element'},
|
|
785
|
+
'stick': {'radius': 0.15, 'multibond': True}})
|
|
786
|
+
self._apply_element_colors(JMOL_COLORS)
|
|
787
|
+
elif self.style == 'cpk':
|
|
788
|
+
self.v.setStyle({'sphere': {'scale': self.cpk_scale,
|
|
789
|
+
'colorscheme': 'element'}})
|
|
790
|
+
self._apply_element_colors(JMOL_COLORS)
|
|
791
|
+
elif self.style == 'cartoon':
|
|
792
|
+
self.v.setStyle({'cartoon': {'color': 'spectrum', 'style': 'rectangle', 'arrows': True}})
|
|
793
|
+
else:
|
|
794
|
+
raise ValueError("style must be 'bs', 'cpk' or 'cartoon'")
|
|
795
|
+
|
|
796
|
+
def _add_interactions(self):
|
|
797
|
+
"""Add basic JavaScript Hover labels for atom identification."""
|
|
798
|
+
label_js = "function(atom,viewer) { viewer.addLabel(atom.elem+atom.serial,{position:atom, backgroundColor:'black'}); }"
|
|
799
|
+
reset_js = "function(atom,viewer) { viewer.removeAllLabels(); }"
|
|
800
|
+
self.v.setHoverable({}, True, label_js, reset_js)
|
|
801
|
+
|
|
802
|
+
def show_bounding_sphere(self, color='gray', opacity=0.2, scale=1.0):
|
|
803
|
+
"""Calculates and displays the VdW bounding sphere in one go."""
|
|
804
|
+
if self.data:
|
|
805
|
+
center, radius = self.data.get_bounding_sphere(include_vdw=True, scale=scale)
|
|
806
|
+
self.v.addSphere({
|
|
807
|
+
'center': {'x': float(center[0]), 'y': float(center[1]), 'z': float(center[2])},
|
|
808
|
+
'radius': float(radius),
|
|
809
|
+
'color': color,
|
|
810
|
+
'opacity': opacity
|
|
811
|
+
})
|
|
812
|
+
print(f"Bounding Sphere: Radius = {radius:.2f} Š| Volume = {(4/3)*np.pi*radius**3:.2f} ų")
|
|
813
|
+
return self.v.show()
|
|
814
|
+
|
|
815
|
+
def show_cage_cavity(self, grid_spacing=0.5, color='cyan', opacity=0.5):
|
|
816
|
+
"""Calculates cavity with CageCavityCalc and displays it as a single model."""
|
|
817
|
+
if self.data:
|
|
818
|
+
result = self.data.get_cage_volume(grid_spacing=grid_spacing, return_spheres=True)
|
|
819
|
+
if result:
|
|
820
|
+
volume, spheres = result
|
|
821
|
+
L, W, H = self.data.get_cavity_dimensions(spheres)
|
|
822
|
+
# Création du modèle optimisé pour éviter le gel du navigateur
|
|
823
|
+
xyz_cavity = f"{len(spheres)}\nCavity points\n"
|
|
824
|
+
for pos in spheres.get_positions():
|
|
825
|
+
xyz_cavity += f"He {pos[0]:.3f} {pos[1]:.3f} {pos[2]:.3f}\n"
|
|
826
|
+
|
|
827
|
+
self.v.addModel(xyz_cavity, "xyz")
|
|
828
|
+
# On applique le style au dernier modèle ajouté
|
|
829
|
+
self.v.setStyle({'model': -1}, {
|
|
830
|
+
'sphere': {'radius': grid_spacing/2, 'color': color, 'opacity': opacity}
|
|
831
|
+
})
|
|
832
|
+
print(f"Cavity Volume (CageCavityCalc): {volume:.2f} ų")
|
|
833
|
+
print(f"Dimensions: {L:.2f} x {W:.2f} x {H:.2f} Å")
|
|
834
|
+
print(f"Aspect Ratio (L/W): {L/W:.2f}")
|
|
835
|
+
return self.v.show()
|