molvector 1.0.4__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.
- molvector/__init__.py +3 -0
- molvector/assets/icons/icon.svg +108 -0
- molvector/assets/icons/icon_align.svg +108 -0
- molvector/assets/icons/icon_draw.svg +56 -0
- molvector/assets/icons/icon_select.svg +56 -0
- molvector/assets/molvector_linux.png +0 -0
- molvector/assets/molvector_macos.png +0 -0
- molvector/assets/molvector_windows.png +0 -0
- molvector/gui.py +4834 -0
- molvector/render.py +2236 -0
- molvector-1.0.4.dist-info/METADATA +30 -0
- molvector-1.0.4.dist-info/RECORD +15 -0
- molvector-1.0.4.dist-info/WHEEL +5 -0
- molvector-1.0.4.dist-info/entry_points.txt +2 -0
- molvector-1.0.4.dist-info/top_level.txt +1 -0
molvector/render.py
ADDED
|
@@ -0,0 +1,2236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
molvector_render.py — Renderer + Parsers + Force Field library
|
|
3
|
+
==============================================================
|
|
4
|
+
Ball-and-stick SVG renderer for molecules.
|
|
5
|
+
|
|
6
|
+
Parsers:
|
|
7
|
+
parse_xyz(text, name) — standard XYZ format
|
|
8
|
+
parse_gaussian(text) — Gaussian .gjf / .com input
|
|
9
|
+
parse_gaussian_log(text) — Gaussian .log / .out output (last geometry)
|
|
10
|
+
parse_pdb(text) — standard PDB format
|
|
11
|
+
parse_mol(text, name) — MDL Molfile V2000 / V3000 format
|
|
12
|
+
infer_bonds(mol) — distance-threshold bond detection
|
|
13
|
+
|
|
14
|
+
Renderer:
|
|
15
|
+
render_molecule(mol, ...) — produces an SVG file
|
|
16
|
+
|
|
17
|
+
Writers:
|
|
18
|
+
save_xyz(mol) — returns XYZ string
|
|
19
|
+
save_gaussian_input(mol) — returns GJF string
|
|
20
|
+
save_pdb(mol) — returns PDB string
|
|
21
|
+
save_mol(mol) — returns MDL Molfile V2000 string
|
|
22
|
+
|
|
23
|
+
Force Field:
|
|
24
|
+
optimize_geometry(mol, ...) — OpenBabel-backed MMFF94s/UFF geometry optimization
|
|
25
|
+
|
|
26
|
+
Properties:
|
|
27
|
+
calculate_dipole_moment(mol, ...) — Gasteiger-charge dipole moment estimation
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import math, random, string, os, sys
|
|
31
|
+
import numpy as np
|
|
32
|
+
import svgwrite
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import List, Dict, Optional, Tuple
|
|
35
|
+
import mol_strudel as strudel
|
|
36
|
+
|
|
37
|
+
# ── OpenBabel data directory setup (cross-platform) ───────────────────────────
|
|
38
|
+
try:
|
|
39
|
+
import openbabel
|
|
40
|
+
|
|
41
|
+
_IS_WIN = sys.platform == 'win32'
|
|
42
|
+
_OB_VERSIONS = ('3.1.1', '3.1.0', '3.0.0')
|
|
43
|
+
|
|
44
|
+
_OB_CANDIDATES = []
|
|
45
|
+
|
|
46
|
+
# Detect if openbabel is a single-file module (e.g. openbabel.pyd on Windows)
|
|
47
|
+
# vs. a package (directory with __init__.py).
|
|
48
|
+
_ob_file = getattr(openbabel, '__file__', None) or ''
|
|
49
|
+
_OB_PKG_DIR = os.path.dirname(_ob_file)
|
|
50
|
+
_is_single_module = os.path.isfile(_ob_file) and not os.path.isdir(
|
|
51
|
+
os.path.join(_OB_PKG_DIR, os.path.splitext(os.path.basename(_ob_file))[0])
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# 1. Environment variable takes precedence
|
|
55
|
+
_ENV_DATADIR = os.environ.get('BABEL_DATADIR')
|
|
56
|
+
if _ENV_DATADIR:
|
|
57
|
+
_OB_CANDIDATES.append(_ENV_DATADIR)
|
|
58
|
+
# 2. Data bundled within the Python package (pip install openbabel-wheel)
|
|
59
|
+
for ver in _OB_VERSIONS:
|
|
60
|
+
_OB_CANDIDATES.append(os.path.join(_OB_PKG_DIR, 'share', 'openbabel', ver))
|
|
61
|
+
_OB_CANDIDATES += [
|
|
62
|
+
os.path.join(_OB_PKG_DIR, 'bin', 'data'),
|
|
63
|
+
os.path.join(_OB_PKG_DIR, 'data'),
|
|
64
|
+
]
|
|
65
|
+
# 2b. Single-file .pyd module: data lives in a subdirectory with the same
|
|
66
|
+
# basename alongside the module file itself.
|
|
67
|
+
if _is_single_module:
|
|
68
|
+
_mod_stem = os.path.splitext(os.path.basename(_ob_file))[0]
|
|
69
|
+
_mod_dir = os.path.join(_OB_PKG_DIR, _mod_stem)
|
|
70
|
+
for ver in _OB_VERSIONS:
|
|
71
|
+
_OB_CANDIDATES.append(os.path.join(_mod_dir, 'share', 'openbabel', ver))
|
|
72
|
+
_OB_CANDIDATES += [
|
|
73
|
+
os.path.join(_mod_dir, 'bin', 'data'),
|
|
74
|
+
os.path.join(_mod_dir, 'data'),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# 3. Walk up from package directory to find share/openbabel under the
|
|
78
|
+
# install prefix (catches Homebrew, Conda, Linux distro installs).
|
|
79
|
+
# On Windows conda, data lives under Library/share/openbabel/<ver>/.
|
|
80
|
+
_OB_PARENT = _OB_PKG_DIR
|
|
81
|
+
for _ in range(6):
|
|
82
|
+
_OB_PARENT = os.path.dirname(_OB_PARENT)
|
|
83
|
+
for ver in _OB_VERSIONS:
|
|
84
|
+
_OB_CANDIDATES.append(os.path.join(_OB_PARENT, 'share', 'openbabel', ver))
|
|
85
|
+
if _IS_WIN:
|
|
86
|
+
_OB_CANDIDATES.append(os.path.join(_OB_PARENT, 'Library', 'share', 'openbabel', ver))
|
|
87
|
+
# 4. Common absolute paths
|
|
88
|
+
# Unix: Homebrew /usr/local /usr
|
|
89
|
+
# Windows: Program Files, AppData, ProgramData
|
|
90
|
+
if _IS_WIN:
|
|
91
|
+
for pf_var in ('PROGRAMFILES', 'PROGRAMFILES(X86)'):
|
|
92
|
+
pf = os.environ.get(pf_var, '')
|
|
93
|
+
if pf:
|
|
94
|
+
for ver in _OB_VERSIONS:
|
|
95
|
+
_OB_CANDIDATES.append(os.path.join(pf, 'OpenBabel', ver, 'data'))
|
|
96
|
+
_OB_CANDIDATES.append(os.path.join(pf, 'OpenBabel', 'share', 'openbabel', ver))
|
|
97
|
+
for data_var in ('APPDATA', 'PROGRAMDATA'):
|
|
98
|
+
d = os.environ.get(data_var, '')
|
|
99
|
+
if d:
|
|
100
|
+
for ver in _OB_VERSIONS:
|
|
101
|
+
_OB_CANDIDATES.append(os.path.join(d, 'openbabel', ver))
|
|
102
|
+
else:
|
|
103
|
+
for prefix in ('/opt/homebrew', '/usr/local', '/usr'):
|
|
104
|
+
for ver in _OB_VERSIONS:
|
|
105
|
+
_OB_CANDIDATES.append(os.path.join(prefix, 'share', 'openbabel', ver))
|
|
106
|
+
|
|
107
|
+
_OB_DATA_DIR = None
|
|
108
|
+
for d in _OB_CANDIDATES:
|
|
109
|
+
if d and os.path.isfile(os.path.join(d, 'UFF.prm')):
|
|
110
|
+
_OB_DATA_DIR = d
|
|
111
|
+
break
|
|
112
|
+
if _OB_DATA_DIR and not os.environ.get('BABEL_DATADIR'):
|
|
113
|
+
os.environ['BABEL_DATADIR'] = _OB_DATA_DIR
|
|
114
|
+
HAS_OPENBABEL = _OB_DATA_DIR is not None
|
|
115
|
+
except ImportError:
|
|
116
|
+
HAS_OPENBABEL = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
# DATA MODEL
|
|
121
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Atom:
|
|
125
|
+
element: str
|
|
126
|
+
x: float
|
|
127
|
+
y: float
|
|
128
|
+
z: float
|
|
129
|
+
color: Optional[str] = None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def pos(self) -> np.ndarray:
|
|
133
|
+
return np.array([self.x, self.y, self.z])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Bond:
|
|
138
|
+
i: int
|
|
139
|
+
j: int
|
|
140
|
+
order: int = 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class ExcitedState:
|
|
145
|
+
index: int
|
|
146
|
+
energy_ev: float
|
|
147
|
+
wavelength_nm: float
|
|
148
|
+
oscillator_strength: float
|
|
149
|
+
symmetry: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class VibrationalMode:
|
|
154
|
+
index: int
|
|
155
|
+
frequency: float # cm^-1
|
|
156
|
+
intensity: float = 0.0 # IR Intensity
|
|
157
|
+
displacements: np.ndarray = None # (N, 3)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class Molecule:
|
|
163
|
+
name: str
|
|
164
|
+
atoms: List[Atom] = field(default_factory=list)
|
|
165
|
+
bonds: List[Bond] = field(default_factory=list)
|
|
166
|
+
charge: int = 0
|
|
167
|
+
excited_states: List[ExcitedState] = field(default_factory=list)
|
|
168
|
+
vibrational_modes: List[VibrationalMode] = field(default_factory=list)
|
|
169
|
+
g16_nproc: Optional[int] = None
|
|
170
|
+
g16_mem: Optional[str] = None
|
|
171
|
+
g16_route: Optional[str] = None
|
|
172
|
+
g16_chk: Optional[str] = None
|
|
173
|
+
g16_dipole: Optional[Tuple[float, float, float, float]] = None
|
|
174
|
+
g16_rotconst: Optional[Tuple[float, float, float]] = None
|
|
175
|
+
g16_scf_energy: Optional[float] = None
|
|
176
|
+
g16_opt_energy: Optional[float] = None
|
|
177
|
+
g16_point_group: Optional[str] = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
# CHEMISTRY HELPERS
|
|
183
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
# Standard atomic weights (g/mol), most common isotope / IUPAC 2021
|
|
186
|
+
ATOMIC_MASSES: Dict[str, float] = {
|
|
187
|
+
"H":1.008, "D":2.014, "T":3.016, "He":4.003, "Li":6.941, "Be":9.012, "B":10.811,
|
|
188
|
+
"C":12.011, "13C":13.003, "14C":14.003, "N":14.007, "15N":15.000, "O":15.999, "F":18.998, "Ne":20.180,
|
|
189
|
+
"Na":22.990,"Mg":24.305,"Al":26.982,"Si":28.086,"P":30.974,
|
|
190
|
+
"S":32.065, "Cl":35.453,"Ar":39.948,"K":39.098, "Ca":40.078,
|
|
191
|
+
"Fe":55.845,"Ni":58.693,"Cu":63.546,"Zn":65.38, "Br":79.904,
|
|
192
|
+
"I":126.904,"Au":196.967,"Hg":200.592,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Atomic number to element symbol (full periodic table, Z=1..118)
|
|
196
|
+
Z_TO_SYM: Dict[int, str] = {
|
|
197
|
+
1:"H", 2:"He", 3:"Li", 4:"Be", 5:"B", 6:"C", 7:"N", 8:"O",
|
|
198
|
+
9:"F", 10:"Ne", 11:"Na", 12:"Mg", 13:"Al", 14:"Si", 15:"P", 16:"S",
|
|
199
|
+
17:"Cl",18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr",
|
|
200
|
+
25:"Mn",26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge",
|
|
201
|
+
33:"As",34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr",
|
|
202
|
+
41:"Nb",42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd",
|
|
203
|
+
49:"In",50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba",
|
|
204
|
+
57:"La",58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd",
|
|
205
|
+
65:"Tb",66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf",
|
|
206
|
+
73:"Ta",74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg",
|
|
207
|
+
81:"Tl",82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra",
|
|
208
|
+
89:"Ac",90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm",
|
|
209
|
+
97:"Bk",98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr",104:"Rf",
|
|
210
|
+
105:"Db",106:"Sg",107:"Bh",108:"Hs",109:"Mt",110:"Ds",111:"Rg",112:"Cn",
|
|
211
|
+
113:"Nh",114:"Fl",115:"Mc",116:"Lv",117:"Ts",118:"Og",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Hill order: C first, H second, then alphabetical
|
|
215
|
+
_HILL_PRIORITY = {"C": 0, "H": 1}
|
|
216
|
+
|
|
217
|
+
def chemical_formula(mol: "Molecule") -> str:
|
|
218
|
+
"""Return Hill-order chemical formula, e.g. C60Ca."""
|
|
219
|
+
from collections import Counter
|
|
220
|
+
counts = Counter(a.element for a in mol.atoms)
|
|
221
|
+
elems = sorted(counts.keys(),
|
|
222
|
+
key=lambda e: (_HILL_PRIORITY.get(e, 2), e))
|
|
223
|
+
formula = "".join(f"{e}{counts[e] if counts[e] > 1 else ''}" for e in elems)
|
|
224
|
+
return formula
|
|
225
|
+
|
|
226
|
+
def molecular_mass(mol: "Molecule") -> float:
|
|
227
|
+
"""Return the monoisotopic-approximate molecular mass in Da (g/mol)."""
|
|
228
|
+
return sum(ATOMIC_MASSES.get(a.element, 0.0) for a in mol.atoms)
|
|
229
|
+
|
|
230
|
+
def calculate_rotational_constants(mol: "Molecule") -> Tuple[float, float, float]:
|
|
231
|
+
"""Return the rotational constants (A, B, C) in MHz for the molecule."""
|
|
232
|
+
coords = []
|
|
233
|
+
masses = []
|
|
234
|
+
for atom in mol.atoms:
|
|
235
|
+
coords.append((atom.x, atom.y, atom.z))
|
|
236
|
+
masses.append(ATOMIC_MASSES[atom.element])
|
|
237
|
+
|
|
238
|
+
coords = np.array(coords)
|
|
239
|
+
masses = np.array(masses)
|
|
240
|
+
|
|
241
|
+
coords_principal_axes = strudel.transform_to_principal_axes(coords, masses)
|
|
242
|
+
moments_of_inertia, _ = strudel.diagonalize_I_tensor(coords_principal_axes, masses)
|
|
243
|
+
rot_consts = strudel.moments_of_inertia_to_rotational_constants(moments_of_inertia)
|
|
244
|
+
return rot_consts
|
|
245
|
+
|
|
246
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
# PARSERS
|
|
248
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def parse_xyz(text: str, name: str = "molecule") -> Molecule:
|
|
251
|
+
"""Standard XYZ: <N>\n<comment>\n<elem x y z> x N"""
|
|
252
|
+
lines = [l.strip() for l in text.strip().splitlines()]
|
|
253
|
+
n = int(lines[0])
|
|
254
|
+
comment = lines[1] if len(lines) > 1 else ""
|
|
255
|
+
|
|
256
|
+
# Try to extract charge from comment line (e.g. "charge=1" or "charge 1")
|
|
257
|
+
import re
|
|
258
|
+
charge = 0
|
|
259
|
+
charge_match = re.search(r"charge\s*[:=]?\s*([+-]?\d+)", comment, re.IGNORECASE)
|
|
260
|
+
if charge_match:
|
|
261
|
+
try:
|
|
262
|
+
charge = int(charge_match.group(1))
|
|
263
|
+
except ValueError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
atoms = []
|
|
267
|
+
for line in lines[2:2 + n]:
|
|
268
|
+
p = line.split()
|
|
269
|
+
if len(p) >= 4:
|
|
270
|
+
atoms.append(Atom(p[0], float(p[1]), float(p[2]), float(p[3])))
|
|
271
|
+
|
|
272
|
+
if not atoms:
|
|
273
|
+
raise ValueError("No valid atoms found in XYZ.")
|
|
274
|
+
|
|
275
|
+
mol = Molecule(name=name, atoms=atoms, charge=charge)
|
|
276
|
+
mol.name = chemical_formula(mol)
|
|
277
|
+
return mol
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def parse_pdb(text: str, name: str = "PDB Molecule") -> Molecule:
|
|
281
|
+
"""Basic PDB parser for ATOM/HETATM and CONECT records."""
|
|
282
|
+
lines = text.splitlines()
|
|
283
|
+
atoms = []
|
|
284
|
+
bonds = []
|
|
285
|
+
serial_to_idx = {}
|
|
286
|
+
import re
|
|
287
|
+
|
|
288
|
+
for line in lines:
|
|
289
|
+
if line.startswith(("ATOM ", "HETATM")):
|
|
290
|
+
try:
|
|
291
|
+
# Fixed-width column parsing
|
|
292
|
+
serial_str = line[6:11].strip()
|
|
293
|
+
if not serial_str: continue
|
|
294
|
+
serial = int(serial_str)
|
|
295
|
+
|
|
296
|
+
atom_name = line[12:16].strip()
|
|
297
|
+
x_str = line[30:38].strip()
|
|
298
|
+
y_str = line[38:46].strip()
|
|
299
|
+
z_str = line[46:54].strip()
|
|
300
|
+
x, y, z = float(x_str), float(y_str), float(z_str)
|
|
301
|
+
|
|
302
|
+
# Element symbol at 77-78, or infer from name
|
|
303
|
+
elem = line[76:78].strip()
|
|
304
|
+
if not elem:
|
|
305
|
+
elem = re.sub(r'[^a-zA-Z]', '', atom_name)
|
|
306
|
+
if len(elem) > 1 and elem[1].islower():
|
|
307
|
+
elem = elem[:2]
|
|
308
|
+
else:
|
|
309
|
+
elem = elem[:1]
|
|
310
|
+
|
|
311
|
+
if len(elem) == 1: elem = elem.upper()
|
|
312
|
+
else: elem = elem.capitalize()
|
|
313
|
+
|
|
314
|
+
serial_to_idx[serial] = len(atoms)
|
|
315
|
+
atoms.append(Atom(elem, x, y, z))
|
|
316
|
+
except (ValueError, IndexError):
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
elif line.startswith("CONECT"):
|
|
320
|
+
try:
|
|
321
|
+
# CONECT serial1 serial2 serial3...
|
|
322
|
+
# Columns: 7-11, 12-16, 17-21, 22-26, 27-31
|
|
323
|
+
parts = []
|
|
324
|
+
for i in range(6, len(line), 5):
|
|
325
|
+
s = line[i:i+5].strip()
|
|
326
|
+
if s: parts.append(s)
|
|
327
|
+
|
|
328
|
+
if not parts: continue
|
|
329
|
+
src_serial = int(parts[0])
|
|
330
|
+
if src_serial not in serial_to_idx: continue
|
|
331
|
+
src_idx = serial_to_idx[src_serial]
|
|
332
|
+
|
|
333
|
+
for target_str in parts[1:]:
|
|
334
|
+
target_serial = int(target_str)
|
|
335
|
+
if target_serial not in serial_to_idx: continue
|
|
336
|
+
target_idx = serial_to_idx[target_serial]
|
|
337
|
+
|
|
338
|
+
if src_idx < target_idx:
|
|
339
|
+
bonds.append(Bond(src_idx, target_idx))
|
|
340
|
+
except (ValueError, IndexError):
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if not atoms:
|
|
344
|
+
raise ValueError("No valid ATOM or HETATM records found in PDB.")
|
|
345
|
+
|
|
346
|
+
mol = Molecule(name=name, atoms=atoms, bonds=bonds)
|
|
347
|
+
# Refine name from HEADER or TITLE
|
|
348
|
+
for line in lines:
|
|
349
|
+
if line.startswith("HEADER") and len(line) > 10:
|
|
350
|
+
mol.name = line[10:50].strip() or name
|
|
351
|
+
break
|
|
352
|
+
elif line.startswith("TITLE ") and len(line) > 10:
|
|
353
|
+
mol.name = line[10:70].strip() or name
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
return mol
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def parse_gaussian(text: str) -> Molecule:
|
|
360
|
+
"""
|
|
361
|
+
Gaussian .gjf / .com input file.
|
|
362
|
+
Sections separated by blank lines:
|
|
363
|
+
route -> title -> charge/mult + coords
|
|
364
|
+
"""
|
|
365
|
+
lines = text.strip().splitlines()
|
|
366
|
+
sections, cur = [], []
|
|
367
|
+
for l in lines:
|
|
368
|
+
if l.strip() == "":
|
|
369
|
+
if cur:
|
|
370
|
+
sections.append(cur)
|
|
371
|
+
cur = []
|
|
372
|
+
else:
|
|
373
|
+
cur.append(l.strip())
|
|
374
|
+
if cur:
|
|
375
|
+
sections.append(cur)
|
|
376
|
+
|
|
377
|
+
name = sections[1][0] if len(sections) > 1 else "molecule"
|
|
378
|
+
mol = Molecule(name=name)
|
|
379
|
+
|
|
380
|
+
# Extract header info from route section
|
|
381
|
+
if sections:
|
|
382
|
+
for line in sections[0]:
|
|
383
|
+
low = line.strip().lower()
|
|
384
|
+
if low.startswith("%nprocshared=") or low.startswith("%nproc="):
|
|
385
|
+
try:
|
|
386
|
+
mol.g16_nproc = int(line.split("=", 1)[1].strip())
|
|
387
|
+
except ValueError:
|
|
388
|
+
pass
|
|
389
|
+
elif low.startswith("%mem="):
|
|
390
|
+
mol.g16_mem = line.split("=", 1)[1].strip()
|
|
391
|
+
elif line.strip().startswith("#"):
|
|
392
|
+
mol.g16_route = line.strip()
|
|
393
|
+
|
|
394
|
+
if len(sections) >= 3:
|
|
395
|
+
# First line of section 3 is "charge multiplicity"
|
|
396
|
+
try:
|
|
397
|
+
charge_mult = sections[2][0].split()
|
|
398
|
+
mol.charge = int(charge_mult[0])
|
|
399
|
+
except (IndexError, ValueError):
|
|
400
|
+
pass
|
|
401
|
+
for line in sections[2][1:]:
|
|
402
|
+
p = line.split()
|
|
403
|
+
if len(p) >= 4:
|
|
404
|
+
try:
|
|
405
|
+
elem = p[0]
|
|
406
|
+
if elem.isdigit() or (elem.startswith('-') and elem[1:].isdigit()):
|
|
407
|
+
z = int(elem)
|
|
408
|
+
elem = Z_TO_SYM.get(z, f"X{z}")
|
|
409
|
+
mol.atoms.append(Atom(elem, float(p[1]), float(p[2]), float(p[3])))
|
|
410
|
+
except ValueError:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
if not mol.atoms:
|
|
414
|
+
raise ValueError("No valid atoms found in Gaussian input.")
|
|
415
|
+
|
|
416
|
+
# Override name with formula for consistency
|
|
417
|
+
mol.name = chemical_formula(mol)
|
|
418
|
+
return mol
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def parse_mol(text: str, name: str = "Molecule") -> Molecule:
|
|
422
|
+
"""
|
|
423
|
+
MDL Molfile V2000 / V3000 parser.
|
|
424
|
+
Supports both V2000 and V3000 formats.
|
|
425
|
+
"""
|
|
426
|
+
lines = text.splitlines()
|
|
427
|
+
if len(lines) < 4:
|
|
428
|
+
raise ValueError("Too few lines for a Molfile.")
|
|
429
|
+
|
|
430
|
+
name = lines[0].strip() or name
|
|
431
|
+
|
|
432
|
+
# Detect V3000: the "V3000" marker appears on the counts line (4th line),
|
|
433
|
+
# but files may have blank lines. Check first 10 lines for the marker.
|
|
434
|
+
is_v3000 = any("V3000" in line for line in lines[:10])
|
|
435
|
+
|
|
436
|
+
if is_v3000:
|
|
437
|
+
return _parse_mol_v3000(text, name)
|
|
438
|
+
return _parse_mol_v2000(text, name)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _parse_mol_v2000(text: str, name: str) -> Molecule:
|
|
442
|
+
lines = text.splitlines()
|
|
443
|
+
counts_line = ""
|
|
444
|
+
counts_idx = -1
|
|
445
|
+
for i, line in enumerate(lines):
|
|
446
|
+
s = line.strip()
|
|
447
|
+
if s and "V2000" in s:
|
|
448
|
+
counts_line = s
|
|
449
|
+
counts_idx = i
|
|
450
|
+
break
|
|
451
|
+
if counts_idx < 0:
|
|
452
|
+
raise ValueError("Could not find V2000 counts line in MOL file.")
|
|
453
|
+
counts_parts = counts_line.split()
|
|
454
|
+
if not counts_parts:
|
|
455
|
+
raise ValueError("Could not parse counts line in MOL file.")
|
|
456
|
+
n_atoms = int(counts_parts[0])
|
|
457
|
+
n_bonds = int(counts_parts[1]) if len(counts_parts) > 1 else 0
|
|
458
|
+
|
|
459
|
+
atom_lines = []
|
|
460
|
+
bond_lines = []
|
|
461
|
+
props_start = counts_idx + 1 + n_atoms + n_bonds
|
|
462
|
+
|
|
463
|
+
for i in range(counts_idx + 1, counts_idx + 1 + n_atoms):
|
|
464
|
+
if i < len(lines):
|
|
465
|
+
atom_lines.append(lines[i])
|
|
466
|
+
for i in range(counts_idx + 1 + n_atoms, counts_idx + 1 + n_atoms + n_bonds):
|
|
467
|
+
if i < len(lines):
|
|
468
|
+
bond_lines.append(lines[i])
|
|
469
|
+
|
|
470
|
+
atoms = []
|
|
471
|
+
for line in atom_lines:
|
|
472
|
+
try:
|
|
473
|
+
x = float(line[0:10].strip())
|
|
474
|
+
y = float(line[10:20].strip())
|
|
475
|
+
z = float(line[20:30].strip())
|
|
476
|
+
elem = line[31:34].strip()
|
|
477
|
+
if not elem:
|
|
478
|
+
elem = line[30:33].strip()
|
|
479
|
+
if not elem or not elem.isalpha():
|
|
480
|
+
raise ValueError(f"Invalid element in atom line: {line}")
|
|
481
|
+
elem = elem.capitalize()
|
|
482
|
+
atoms.append(Atom(elem, x, y, z))
|
|
483
|
+
except (ValueError, IndexError):
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
if not atoms:
|
|
487
|
+
raise ValueError("No valid atoms found in MOL file.")
|
|
488
|
+
|
|
489
|
+
bonds = []
|
|
490
|
+
seen_bonds = set()
|
|
491
|
+
for line in bond_lines:
|
|
492
|
+
try:
|
|
493
|
+
parts = line.split()
|
|
494
|
+
if len(parts) < 3:
|
|
495
|
+
continue
|
|
496
|
+
i = int(parts[0]) - 1
|
|
497
|
+
j = int(parts[1]) - 1
|
|
498
|
+
order = int(parts[2])
|
|
499
|
+
if order == 4:
|
|
500
|
+
order = 5 # aromatic -> our resonance bond type
|
|
501
|
+
if i < len(atoms) and j < len(atoms) and i != j:
|
|
502
|
+
a, b = min(i, j), max(i, j)
|
|
503
|
+
key = (a, b)
|
|
504
|
+
if key not in seen_bonds:
|
|
505
|
+
seen_bonds.add(key)
|
|
506
|
+
bonds.append(Bond(a, b, order))
|
|
507
|
+
except (ValueError, IndexError):
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
# Parse properties block for charge
|
|
511
|
+
import re
|
|
512
|
+
mol_charge = 0
|
|
513
|
+
for line in lines[props_start:]:
|
|
514
|
+
s = line.strip()
|
|
515
|
+
if s == "M END":
|
|
516
|
+
break
|
|
517
|
+
if s.startswith("M CHG"):
|
|
518
|
+
parts = s.split()
|
|
519
|
+
# M CHG n a1 c1 a2 c2 ...
|
|
520
|
+
try:
|
|
521
|
+
n = int(parts[2])
|
|
522
|
+
for k in range(n):
|
|
523
|
+
aidx = int(parts[3 + k * 2]) - 1
|
|
524
|
+
chg = int(parts[4 + k * 2])
|
|
525
|
+
mol_charge += chg
|
|
526
|
+
except (ValueError, IndexError):
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
mol = Molecule(name=name, atoms=atoms, bonds=bonds, charge=mol_charge)
|
|
530
|
+
mol.name = chemical_formula(mol)
|
|
531
|
+
return mol
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _parse_mol_v3000(text: str, name: str) -> Molecule:
|
|
535
|
+
lines = text.splitlines()
|
|
536
|
+
atoms, bonds = [], []
|
|
537
|
+
seen_bonds = set()
|
|
538
|
+
mol_charge = 0
|
|
539
|
+
in_atom_block = False
|
|
540
|
+
in_bond_block = False
|
|
541
|
+
|
|
542
|
+
for line in lines:
|
|
543
|
+
s = line.strip()
|
|
544
|
+
if "BEGIN ATOM" in s:
|
|
545
|
+
in_atom_block = True
|
|
546
|
+
continue
|
|
547
|
+
if "BEGIN BOND" in s:
|
|
548
|
+
in_bond_block = True
|
|
549
|
+
continue
|
|
550
|
+
if "END ATOM" in s:
|
|
551
|
+
in_atom_block = False
|
|
552
|
+
continue
|
|
553
|
+
if "END BOND" in s:
|
|
554
|
+
in_bond_block = False
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
if in_atom_block:
|
|
558
|
+
try:
|
|
559
|
+
parts = s.split()
|
|
560
|
+
if len(parts) >= 7:
|
|
561
|
+
elem = parts[3]
|
|
562
|
+
x = float(parts[4])
|
|
563
|
+
y = float(parts[5])
|
|
564
|
+
z = float(parts[6])
|
|
565
|
+
atoms.append(Atom(elem, x, y, z))
|
|
566
|
+
except (ValueError, IndexError):
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
if in_bond_block:
|
|
570
|
+
try:
|
|
571
|
+
parts = s.split()
|
|
572
|
+
if len(parts) >= 6:
|
|
573
|
+
order = int(parts[3])
|
|
574
|
+
i = int(parts[4]) - 1
|
|
575
|
+
j = int(parts[5]) - 1
|
|
576
|
+
if order == 4:
|
|
577
|
+
order = 5
|
|
578
|
+
if i < len(atoms) and j < len(atoms) and i != j:
|
|
579
|
+
a, b = min(i, j), max(i, j)
|
|
580
|
+
key = (a, b)
|
|
581
|
+
if key not in seen_bonds:
|
|
582
|
+
seen_bonds.add(key)
|
|
583
|
+
bonds.append(Bond(a, b, order))
|
|
584
|
+
except (ValueError, IndexError):
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
# Parse charge from V3000 properties
|
|
588
|
+
if "M CHG" in s or "M V30" in s:
|
|
589
|
+
import re
|
|
590
|
+
chg_match = re.search(r"CHG\s+(\d+)\s+(-?\d+)", s)
|
|
591
|
+
if chg_match:
|
|
592
|
+
try:
|
|
593
|
+
aidx = int(chg_match.group(1)) - 1
|
|
594
|
+
chg = int(chg_match.group(2))
|
|
595
|
+
mol_charge += chg
|
|
596
|
+
except (ValueError, IndexError):
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
if not atoms:
|
|
600
|
+
raise ValueError("No valid atoms found in V3000 MOL file.")
|
|
601
|
+
|
|
602
|
+
mol = Molecule(name=name, atoms=atoms, bonds=bonds, charge=mol_charge)
|
|
603
|
+
mol.name = chemical_formula(mol)
|
|
604
|
+
return mol
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def parse_gaussian_log(text: str) -> Molecule:
|
|
608
|
+
"""
|
|
609
|
+
Gaussian .log / .out output file.
|
|
610
|
+
|
|
611
|
+
Finds the LAST 'Standard orientation:' block (or 'Input orientation:'
|
|
612
|
+
if standard is absent) — this is the final / optimised geometry.
|
|
613
|
+
|
|
614
|
+
The coordinate table columns are:
|
|
615
|
+
Center# AtomicNum AtomicType X Y Z
|
|
616
|
+
Atomic number is converted to element symbol via a built-in table.
|
|
617
|
+
"""
|
|
618
|
+
lines = text.splitlines()
|
|
619
|
+
|
|
620
|
+
# Extract header info (nproc, mem, chk, route)
|
|
621
|
+
nproc = None
|
|
622
|
+
mem = None
|
|
623
|
+
chk = None
|
|
624
|
+
route_str = None
|
|
625
|
+
name = "Gaussian output"
|
|
626
|
+
for i, line in enumerate(lines):
|
|
627
|
+
stripped = line.strip()
|
|
628
|
+
if stripped.startswith("%nprocshared="):
|
|
629
|
+
try:
|
|
630
|
+
nproc = int(stripped.split("=", 1)[1])
|
|
631
|
+
except ValueError:
|
|
632
|
+
pass
|
|
633
|
+
elif stripped.startswith("%nproc="):
|
|
634
|
+
try:
|
|
635
|
+
nproc = int(stripped.split("=", 1)[1])
|
|
636
|
+
except ValueError:
|
|
637
|
+
pass
|
|
638
|
+
elif stripped.startswith("%mem="):
|
|
639
|
+
mem = stripped.split("=", 1)[1]
|
|
640
|
+
elif stripped.startswith("%chk="):
|
|
641
|
+
chk = stripped.split("=", 1)[1]
|
|
642
|
+
elif stripped.startswith("#"):
|
|
643
|
+
route_parts = [stripped.lstrip("#").strip()]
|
|
644
|
+
for j in range(i + 1, min(i + 30, len(lines))):
|
|
645
|
+
s = lines[j].strip()
|
|
646
|
+
if not s or s.startswith("-") or s.startswith("*"):
|
|
647
|
+
break
|
|
648
|
+
if s.startswith("#"):
|
|
649
|
+
route_parts.append(s.lstrip("#").strip())
|
|
650
|
+
elif s.startswith("%"):
|
|
651
|
+
break
|
|
652
|
+
else:
|
|
653
|
+
if s and route_parts[-1] and (s[0].isupper() or s[0] in "(+-*"):
|
|
654
|
+
route_parts[-1] = route_parts[-1] + s
|
|
655
|
+
else:
|
|
656
|
+
route_parts[-1] = route_parts[-1] + " " + s
|
|
657
|
+
route_str = "# " + " ".join(route_parts)
|
|
658
|
+
# Extract job title after route card
|
|
659
|
+
for j in range(i + 1, min(i + 30, len(lines))):
|
|
660
|
+
s = lines[j].strip()
|
|
661
|
+
if s and not s.startswith("-") and not s.startswith("#") \
|
|
662
|
+
and not s.startswith("%") and not s.startswith("*") \
|
|
663
|
+
and len(s) > 2:
|
|
664
|
+
name = s
|
|
665
|
+
break
|
|
666
|
+
break
|
|
667
|
+
|
|
668
|
+
# Find all orientation block header positions
|
|
669
|
+
MARKERS = ["Standard orientation:", "Input orientation:", "Z-Matrix orientation:"]
|
|
670
|
+
block_starts = []
|
|
671
|
+
for i, line in enumerate(lines):
|
|
672
|
+
for marker in MARKERS:
|
|
673
|
+
if marker in line:
|
|
674
|
+
block_starts.append((i, marker))
|
|
675
|
+
break
|
|
676
|
+
|
|
677
|
+
if not block_starts:
|
|
678
|
+
raise ValueError(
|
|
679
|
+
"No orientation block found in this file.\n"
|
|
680
|
+
"Make sure it is a Gaussian output (.log/.out) with geometry data."
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# Prefer last Standard orientation; fall back to last of any kind
|
|
684
|
+
std = [b for b in block_starts if "Standard" in b[1]]
|
|
685
|
+
chosen_idx = (std or block_starts)[-1][0]
|
|
686
|
+
|
|
687
|
+
# The table starts 5 lines after the marker:
|
|
688
|
+
# +0 "Standard orientation:"
|
|
689
|
+
# +1 "------..."
|
|
690
|
+
# +2 column header line 1
|
|
691
|
+
# +3 column header line 2
|
|
692
|
+
# +4 "------..."
|
|
693
|
+
# +5 first data row
|
|
694
|
+
data_start = chosen_idx + 5
|
|
695
|
+
|
|
696
|
+
atoms = []
|
|
697
|
+
for line in lines[data_start:]:
|
|
698
|
+
s = line.strip()
|
|
699
|
+
if s.startswith("-"):
|
|
700
|
+
break
|
|
701
|
+
parts = s.split()
|
|
702
|
+
if len(parts) >= 6:
|
|
703
|
+
try:
|
|
704
|
+
atomic_num = int(parts[1])
|
|
705
|
+
x, y, z = float(parts[3]), float(parts[4]), float(parts[5])
|
|
706
|
+
elem = Z_TO_SYM.get(atomic_num, f"X{atomic_num}")
|
|
707
|
+
atoms.append(Atom(elem, x, y, z))
|
|
708
|
+
except ValueError:
|
|
709
|
+
continue
|
|
710
|
+
|
|
711
|
+
if not atoms:
|
|
712
|
+
raise ValueError(
|
|
713
|
+
"Could not parse atom coordinates from the orientation block.\n"
|
|
714
|
+
"The file may be truncated or use an unexpected format."
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
# Extract charge from "Charge = X Multiplicity = Y" line
|
|
718
|
+
charge = 0
|
|
719
|
+
for line in lines:
|
|
720
|
+
if "Charge =" in line and "Multiplicity =" in line:
|
|
721
|
+
try:
|
|
722
|
+
charge = int(line.split("Charge =")[1].split()[0])
|
|
723
|
+
except (IndexError, ValueError):
|
|
724
|
+
pass
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
# Check if this is a SelectNormalModes output (non-standard freq format)
|
|
728
|
+
import re
|
|
729
|
+
has_select_modes = False
|
|
730
|
+
for line in lines[:200]:
|
|
731
|
+
if "SelectNormalModes" in line:
|
|
732
|
+
has_select_modes = True
|
|
733
|
+
break
|
|
734
|
+
|
|
735
|
+
# Extract excited states (TDDFT)
|
|
736
|
+
excited_states = []
|
|
737
|
+
for line in lines:
|
|
738
|
+
# Excited State 1: 2.002-?Sym 0.1463 eV 8473.13 nm f=0.0000 <S**2>=0.752
|
|
739
|
+
m = re.search(r"Excited State\s+(\d+):\s+(.+?)\s+([\d.]+) eV\s+([\d.]+) nm\s+f=([\d.]+)", line)
|
|
740
|
+
if m:
|
|
741
|
+
idx, sym, ev, nm, f = m.groups()
|
|
742
|
+
excited_states.append(ExcitedState(int(idx), float(ev.strip()), float(nm.strip()), float(f.strip()), sym.strip()))
|
|
743
|
+
|
|
744
|
+
# Extract vibrational modes
|
|
745
|
+
vibrational_modes = []
|
|
746
|
+
i = 0
|
|
747
|
+
while i < len(lines):
|
|
748
|
+
line = lines[i]
|
|
749
|
+
if line.strip().startswith("Frequencies --"):
|
|
750
|
+
# Handle cases with -- or ---
|
|
751
|
+
after_marker = line.split("--", 1)[1].lstrip("-")
|
|
752
|
+
freqs = [float(f) for f in after_marker.split()]
|
|
753
|
+
n_freqs = len(freqs)
|
|
754
|
+
|
|
755
|
+
# Find IR intensities if present
|
|
756
|
+
intensities = [0.0] * n_freqs
|
|
757
|
+
j = i + 1
|
|
758
|
+
while j < i + 15 and j < len(lines):
|
|
759
|
+
if "IR Inten --" in lines[j]:
|
|
760
|
+
try:
|
|
761
|
+
after_marker_ir = lines[j].split("--", 1)[1].lstrip("-")
|
|
762
|
+
intensities = [float(x) for x in after_marker_ir.split()]
|
|
763
|
+
except ValueError:
|
|
764
|
+
pass
|
|
765
|
+
break
|
|
766
|
+
j += 1
|
|
767
|
+
|
|
768
|
+
# Find the start of the displacement table
|
|
769
|
+
has_coord_atom = False
|
|
770
|
+
while i < len(lines):
|
|
771
|
+
if " Atom AN X Y Z" in lines[i]:
|
|
772
|
+
has_coord_atom = False
|
|
773
|
+
break
|
|
774
|
+
if " Coord Atom Element:" in lines[i]:
|
|
775
|
+
has_coord_atom = True
|
|
776
|
+
break
|
|
777
|
+
i += 1
|
|
778
|
+
i += 1 # skip header
|
|
779
|
+
|
|
780
|
+
disps = [[] for _ in range(n_freqs)]
|
|
781
|
+
if has_coord_atom:
|
|
782
|
+
# Format: 3 lines per atom (X, then Y, then Z)
|
|
783
|
+
for _ in range(len(atoms)):
|
|
784
|
+
# X line
|
|
785
|
+
parts_x = lines[i].split(); i += 1
|
|
786
|
+
# Y line
|
|
787
|
+
parts_y = lines[i].split(); i += 1
|
|
788
|
+
# Z line
|
|
789
|
+
parts_z = lines[i].split(); i += 1
|
|
790
|
+
|
|
791
|
+
for f_idx in range(n_freqs):
|
|
792
|
+
dx = float(parts_x[3 + f_idx])
|
|
793
|
+
dy = float(parts_y[3 + f_idx])
|
|
794
|
+
dz = float(parts_z[3 + f_idx])
|
|
795
|
+
disps[f_idx].append([dx, dy, dz])
|
|
796
|
+
else:
|
|
797
|
+
# Standard format: 1 line per atom
|
|
798
|
+
for _ in range(len(atoms)):
|
|
799
|
+
if i >= len(lines): break
|
|
800
|
+
parts = lines[i].split()
|
|
801
|
+
for f_idx in range(n_freqs):
|
|
802
|
+
dx = float(parts[2 + f_idx*3])
|
|
803
|
+
dy = float(parts[3 + f_idx*3])
|
|
804
|
+
dz = float(parts[4 + f_idx*3])
|
|
805
|
+
disps[f_idx].append([dx, dy, dz])
|
|
806
|
+
i += 1
|
|
807
|
+
|
|
808
|
+
# Clear existing modes if we found a "fresh" set (index 1 to 3/5)
|
|
809
|
+
# Gaussian usually restarts numbering for a new frequency block.
|
|
810
|
+
if vibrational_modes and vibrational_modes[-1].index >= freqs[0]:
|
|
811
|
+
vibrational_modes = []
|
|
812
|
+
|
|
813
|
+
for f_idx in range(n_freqs):
|
|
814
|
+
vibrational_modes.append(VibrationalMode(
|
|
815
|
+
index=len(vibrational_modes) + 1,
|
|
816
|
+
frequency=freqs[f_idx],
|
|
817
|
+
intensity=intensities[f_idx] if f_idx < len(intensities) else 0.0,
|
|
818
|
+
displacements=np.array(disps[f_idx])
|
|
819
|
+
))
|
|
820
|
+
continue
|
|
821
|
+
i += 1
|
|
822
|
+
|
|
823
|
+
# ── SelectNormalModes fallback ──────────────────────────────────────
|
|
824
|
+
if has_select_modes and not vibrational_modes:
|
|
825
|
+
try:
|
|
826
|
+
_parse_select_modes(text, atoms, vibrational_modes)
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
|
|
830
|
+
# ── Extract last-iteration results ──────────────────────────────────
|
|
831
|
+
dipole_moment = None # (X, Y, Z, Total) in Debye
|
|
832
|
+
rot_constants = None # (A, B, C) in MHz
|
|
833
|
+
scf_energy = None # in Hartree
|
|
834
|
+
opt_energy = None # from optimization ! line, in Hartree
|
|
835
|
+
point_group = None
|
|
836
|
+
for line in reversed(lines):
|
|
837
|
+
stripped = line.strip()
|
|
838
|
+
if dipole_moment is None and "Dipole moment (field-independent basis, Debye):" in line:
|
|
839
|
+
continue
|
|
840
|
+
if dipole_moment is None and "Dipole moment (Debye):" in line:
|
|
841
|
+
continue
|
|
842
|
+
if dipole_moment is None and "X=" in stripped and "Y=" in stripped and "Z=" in stripped and "Tot=" in stripped:
|
|
843
|
+
try:
|
|
844
|
+
parts = stripped.replace("=", " ").split()
|
|
845
|
+
x = float(parts[parts.index("X") + 1])
|
|
846
|
+
y = float(parts[parts.index("Y") + 1])
|
|
847
|
+
z = float(parts[parts.index("Z") + 1])
|
|
848
|
+
t = float(parts[parts.index("Tot") + 1])
|
|
849
|
+
dipole_moment = (x, y, z, t)
|
|
850
|
+
except (ValueError, IndexError):
|
|
851
|
+
pass
|
|
852
|
+
if rot_constants is None and "Rotational constants (MHz):" in stripped:
|
|
853
|
+
try:
|
|
854
|
+
vals = stripped.split(":")[1].strip().split()
|
|
855
|
+
a, b, c = float(vals[0]), float(vals[1]), float(vals[2])
|
|
856
|
+
rot_constants = (a, b, c)
|
|
857
|
+
except (ValueError, IndexError):
|
|
858
|
+
pass
|
|
859
|
+
if scf_energy is None and stripped.startswith("SCF Done:"):
|
|
860
|
+
try:
|
|
861
|
+
e_part = stripped.split("=")[1].strip().split()[0]
|
|
862
|
+
scf_energy = float(e_part)
|
|
863
|
+
except (ValueError, IndexError):
|
|
864
|
+
pass
|
|
865
|
+
if opt_energy is None and stripped.startswith("!") and "E=" in stripped:
|
|
866
|
+
try:
|
|
867
|
+
after_E = stripped.split("E=")[1].strip().split()[0]
|
|
868
|
+
opt_energy = float(after_E)
|
|
869
|
+
except (ValueError, IndexError):
|
|
870
|
+
pass
|
|
871
|
+
if dipole_moment is not None and rot_constants is not None and scf_energy is not None and opt_energy is not None:
|
|
872
|
+
break
|
|
873
|
+
|
|
874
|
+
# Scan forward for point group (single occurrence, typically near start)
|
|
875
|
+
for line in lines:
|
|
876
|
+
if "Full point group" in line:
|
|
877
|
+
try:
|
|
878
|
+
pg = line.split("Full point group")[1].strip().split()[0]
|
|
879
|
+
point_group = pg
|
|
880
|
+
except (IndexError, ValueError):
|
|
881
|
+
pass
|
|
882
|
+
break
|
|
883
|
+
|
|
884
|
+
mol = Molecule(name=name, atoms=atoms, charge=charge,
|
|
885
|
+
excited_states=excited_states,
|
|
886
|
+
vibrational_modes=vibrational_modes)
|
|
887
|
+
mol.g16_nproc = nproc
|
|
888
|
+
mol.g16_mem = mem
|
|
889
|
+
mol.g16_dipole = dipole_moment
|
|
890
|
+
mol.g16_rotconst = rot_constants
|
|
891
|
+
mol.g16_scf_energy = scf_energy
|
|
892
|
+
mol.g16_opt_energy = opt_energy
|
|
893
|
+
mol.g16_point_group = point_group
|
|
894
|
+
if chk:
|
|
895
|
+
mol.g16_chk = chk
|
|
896
|
+
if route_str:
|
|
897
|
+
mol.g16_route = route_str
|
|
898
|
+
mol.name = chemical_formula(mol)
|
|
899
|
+
return mol
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _parse_select_modes(text: str, atoms: list, vibrational_modes: list):
|
|
903
|
+
"""
|
|
904
|
+
Parse Gaussian log output produced with ``freq=(SelectNormalModes, SaveNormalModes)``.
|
|
905
|
+
|
|
906
|
+
This keyword suppresses the usual ``Frequencies --`` lines and packs mode
|
|
907
|
+
data into the route section. Frequencies are recovered from the
|
|
908
|
+
``Vibrational temperatures`` (Kelvin) block.
|
|
909
|
+
"""
|
|
910
|
+
import re
|
|
911
|
+
import numpy as np
|
|
912
|
+
|
|
913
|
+
n_atoms = len(atoms)
|
|
914
|
+
n_vib = 3 * n_atoms - 6
|
|
915
|
+
|
|
916
|
+
# ── 1. Vibrational temperatures → frequencies (cm⁻¹) ──────────────
|
|
917
|
+
K_TO_CM1 = 0.695028
|
|
918
|
+
freqs = []
|
|
919
|
+
lines = text.splitlines()
|
|
920
|
+
|
|
921
|
+
for i, line in enumerate(lines):
|
|
922
|
+
if "Vibrational temperatures:" not in line:
|
|
923
|
+
continue
|
|
924
|
+
vals = []
|
|
925
|
+
after = line.split(":", 1)[1].strip()
|
|
926
|
+
if after:
|
|
927
|
+
vals.extend(float(x) for x in after.split())
|
|
928
|
+
for j in range(i + 1, min(i + 20, len(lines))):
|
|
929
|
+
s = lines[j].strip()
|
|
930
|
+
if not s:
|
|
931
|
+
break
|
|
932
|
+
for tok in s.split():
|
|
933
|
+
try:
|
|
934
|
+
vals.append(float(tok))
|
|
935
|
+
except ValueError:
|
|
936
|
+
pass
|
|
937
|
+
if len(vals) >= n_vib:
|
|
938
|
+
break
|
|
939
|
+
freqs = [v * K_TO_CM1 for v in vals[:n_vib]]
|
|
940
|
+
break
|
|
941
|
+
|
|
942
|
+
if not freqs:
|
|
943
|
+
raise ValueError(
|
|
944
|
+
"Could not parse frequencies from Vibrational temperatures."
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
# ── 2. Route section: displacement vectors ────────────────────────
|
|
948
|
+
disp_data = None
|
|
949
|
+
start = text.find("NImag=0")
|
|
950
|
+
if start >= 0:
|
|
951
|
+
bs = text.find("\\\\", start)
|
|
952
|
+
if bs >= 0:
|
|
953
|
+
sep = text.find("\\\\", bs + 2)
|
|
954
|
+
if sep >= 0:
|
|
955
|
+
raw = text[bs + 2:sep]
|
|
956
|
+
flat = re.sub(r"\s+", "", raw)
|
|
957
|
+
parts = flat.split(",")
|
|
958
|
+
vals = []
|
|
959
|
+
for p in parts:
|
|
960
|
+
if p:
|
|
961
|
+
try:
|
|
962
|
+
vals.append(float(p))
|
|
963
|
+
except ValueError:
|
|
964
|
+
pass
|
|
965
|
+
if len(vals) == 1485:
|
|
966
|
+
disp_data = np.array(vals).reshape(27, 55)[:, :54]
|
|
967
|
+
|
|
968
|
+
# ── 3. Build mode objects ─────────────────────────────────────────
|
|
969
|
+
zero = np.zeros((n_atoms, 3))
|
|
970
|
+
n_avail = len(disp_data) if disp_data is not None else 0
|
|
971
|
+
|
|
972
|
+
for idx in range(len(freqs)):
|
|
973
|
+
if idx < n_avail:
|
|
974
|
+
displacements = disp_data[idx].reshape(n_atoms, 3)
|
|
975
|
+
else:
|
|
976
|
+
displacements = zero.copy()
|
|
977
|
+
vibrational_modes.append(
|
|
978
|
+
VibrationalMode(
|
|
979
|
+
index=idx + 1,
|
|
980
|
+
frequency=freqs[idx],
|
|
981
|
+
intensity=0.0,
|
|
982
|
+
displacements=displacements,
|
|
983
|
+
)
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
988
|
+
# BOND INFERENCE
|
|
989
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
990
|
+
|
|
991
|
+
COVALENT_RADII: Dict[str, float] = {
|
|
992
|
+
"H":0.31,"D":0.31,"T":0.31,"C":0.76,"13C":0.76,"14C":0.76,"N":0.71,"15N":0.71,"O":0.66,"F":0.57,
|
|
993
|
+
"S":1.05,"P":1.07,"Cl":1.02,"Br":1.20,"I":1.39,
|
|
994
|
+
"B":0.82,"Si":1.11,
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
def infer_bonds(mol: Molecule, tol: float = 0.40) -> None:
|
|
998
|
+
atoms = mol.atoms
|
|
999
|
+
existing = {(b.i, b.j) for b in mol.bonds}
|
|
1000
|
+
|
|
1001
|
+
new_bonds = []
|
|
1002
|
+
for i in range(len(atoms)):
|
|
1003
|
+
for j in range(i + 1, len(atoms)):
|
|
1004
|
+
ri = COVALENT_RADII.get(atoms[i].element, 0.77)
|
|
1005
|
+
rj = COVALENT_RADII.get(atoms[j].element, 0.77)
|
|
1006
|
+
dist = np.linalg.norm(atoms[i].pos - atoms[j].pos)
|
|
1007
|
+
if dist <= ri + rj + tol:
|
|
1008
|
+
r_single = ri + rj
|
|
1009
|
+
if dist <= r_single * 0.85:
|
|
1010
|
+
order = 3
|
|
1011
|
+
elif dist <= r_single * 0.95:
|
|
1012
|
+
order = 2
|
|
1013
|
+
else:
|
|
1014
|
+
order = 1
|
|
1015
|
+
if (i, j) in existing:
|
|
1016
|
+
for b in mol.bonds:
|
|
1017
|
+
if b.i == i and b.j == j:
|
|
1018
|
+
b.order = order
|
|
1019
|
+
break
|
|
1020
|
+
else:
|
|
1021
|
+
new_bonds.append(Bond(i, j, order))
|
|
1022
|
+
|
|
1023
|
+
mol.bonds.extend(new_bonds)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1027
|
+
# CPK COLOURS
|
|
1028
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1029
|
+
|
|
1030
|
+
CPK_BASE: Dict[str, str] = {
|
|
1031
|
+
"H":"#d4d4d4","D":"#d4d4d4","T":"#d4d4d4","C":"#444444","13C":"#444444","14C":"#444444","N":"#2050d0","15N":"#2050d0","O":"#cc1111",
|
|
1032
|
+
"F":"#66cc22","S":"#ddcc00","P":"#ff8800","Cl":"#11bb22",
|
|
1033
|
+
"Br":"#882200","I":"#660099","B":"#ffaa33","Si":"#8888aa",
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
CPK_DARK: Dict[str, str] = {
|
|
1037
|
+
"H":"#888888","D":"#888888","T":"#888888","C":"#111111","13C":"#111111","14C":"#111111","N":"#0a1a60","15N":"#0a1a60","O":"#550000",
|
|
1038
|
+
"F":"#224400","S":"#554400","P":"#441100","Cl":"#003300",
|
|
1039
|
+
"Br":"#330000","I":"#220033","B":"#664400","Si":"#333344",
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
DEFAULT_BASE = "#cc44aa"
|
|
1043
|
+
DEFAULT_DARK = "#440022"
|
|
1044
|
+
|
|
1045
|
+
VDW_RADII: Dict[str, float] = {
|
|
1046
|
+
"H":0.53,"D":0.53,"T":0.53,"C":0.77,"13C":0.77,"14C":0.77,"N":0.75,"15N":0.75,"O":0.73,"F":0.71,
|
|
1047
|
+
"S":1.02,"P":1.06,"Cl":0.99,"Br":1.14,"I":1.33,
|
|
1048
|
+
"B":0.87,"Si":1.10,
|
|
1049
|
+
}
|
|
1050
|
+
DEFAULT_VDW = 0.80
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1054
|
+
# COLOUR HELPERS
|
|
1055
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1056
|
+
|
|
1057
|
+
def hex_to_rgb(h: str) -> Tuple[int,int,int]:
|
|
1058
|
+
h = h.lstrip("#")
|
|
1059
|
+
return (int(h[0:2],16), int(h[2:4],16), int(h[4:6],16))
|
|
1060
|
+
|
|
1061
|
+
def rgb_to_hex(r, g, b) -> str:
|
|
1062
|
+
return "#{:02x}{:02x}{:02x}".format(max(0,min(255,int(r))), max(0,min(255,int(g))), max(0,min(255,int(b))))
|
|
1063
|
+
|
|
1064
|
+
def lighten(hex_color: str, factor: float) -> str:
|
|
1065
|
+
r,g,b = hex_to_rgb(hex_color)
|
|
1066
|
+
return rgb_to_hex(r+(255-r)*factor, g+(255-g)*factor, b+(255-b)*factor)
|
|
1067
|
+
|
|
1068
|
+
def darken(hex_color: str, factor: float) -> str:
|
|
1069
|
+
r,g,b = hex_to_rgb(hex_color)
|
|
1070
|
+
return rgb_to_hex(r*(1-factor), g*(1-factor), b*(1-factor))
|
|
1071
|
+
|
|
1072
|
+
def auto_dark(base: str) -> str:
|
|
1073
|
+
return darken(base, 0.65)
|
|
1074
|
+
|
|
1075
|
+
def interpolate_color(c1: str, c2: str, t: float) -> str:
|
|
1076
|
+
r1, g1, b1 = hex_to_rgb(c1)
|
|
1077
|
+
r2, g2, b2 = hex_to_rgb(c2)
|
|
1078
|
+
return rgb_to_hex(r1 + (r2-r1)*t, g1 + (g2-g1)*t, b1 + (b2-b1)*t)
|
|
1079
|
+
|
|
1080
|
+
def compute_gradient_stops(
|
|
1081
|
+
base: str, dark: str, li: float,
|
|
1082
|
+
roughness: float = 1.0,
|
|
1083
|
+
edge_fn=None,
|
|
1084
|
+
):
|
|
1085
|
+
if edge_fn is None:
|
|
1086
|
+
edge_fn = lambda b, d, l: darken(b, 0.65 * l)
|
|
1087
|
+
r = max(0.0, roughness)
|
|
1088
|
+
spread = 0.3 + r * 0.7
|
|
1089
|
+
hi = max(0.0, 1.0 - r * 0.4)
|
|
1090
|
+
ef = max(0.0, 1.0 - r * 0.35)
|
|
1091
|
+
p1 = min(0.18 * spread, 0.35)
|
|
1092
|
+
p2 = min(0.48 * spread, 0.65)
|
|
1093
|
+
p3 = min(0.78 * spread, 0.85)
|
|
1094
|
+
return [
|
|
1095
|
+
(0.00, lighten(base, 0.70 * li * hi)),
|
|
1096
|
+
(p1, lighten(base, 0.40 * li * hi)),
|
|
1097
|
+
(p2, lighten(base, 0.15 * li * hi)),
|
|
1098
|
+
(p3, base),
|
|
1099
|
+
(1.00, edge_fn(base, dark, li * ef)),
|
|
1100
|
+
]
|
|
1101
|
+
|
|
1102
|
+
# Light position presets: maps name -> (gradient_cx, gradient_cy, Lx, Ly, Lz)
|
|
1103
|
+
LIGHT_POSITIONS = {
|
|
1104
|
+
"top-left": ("0.33", "0.28", -0.34, -0.44, 0.83),
|
|
1105
|
+
"top": ("0.50", "0.10", 0.00, -0.50, 0.87),
|
|
1106
|
+
"top-right": ("0.67", "0.28", 0.34, -0.44, 0.83),
|
|
1107
|
+
"left": ("0.10", "0.50", -0.50, 0.00, 0.87),
|
|
1108
|
+
"center": ("0.50", "0.50", 0.00, 0.00, 1.00),
|
|
1109
|
+
"right": ("0.90", "0.50", 0.50, 0.00, 0.87),
|
|
1110
|
+
"bottom-left": ("0.33", "0.72", -0.34, 0.44, 0.83),
|
|
1111
|
+
"bottom": ("0.50", "0.90", 0.00, 0.50, 0.87),
|
|
1112
|
+
"bottom-right": ("0.67", "0.72", 0.34, 0.44, 0.83),
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1116
|
+
# WRITERS
|
|
1117
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
def save_xyz(mol: "Molecule") -> str:
|
|
1120
|
+
"""Produce standard XYZ string."""
|
|
1121
|
+
lines = [str(len(mol.atoms)), mol.name]
|
|
1122
|
+
for a in mol.atoms:
|
|
1123
|
+
lines.append(f"{a.element:3s} {a.x:12.6f} {a.y:12.6f} {a.z:12.6f}")
|
|
1124
|
+
return "\n".join(lines)
|
|
1125
|
+
|
|
1126
|
+
def save_gaussian_input(mol: "Molecule") -> str:
|
|
1127
|
+
"""Produce a basic Gaussian input (.gjf) string."""
|
|
1128
|
+
lines = [
|
|
1129
|
+
"%nprocshared=4",
|
|
1130
|
+
"%mem=4GB",
|
|
1131
|
+
f"# p opt freq b3lyp/6-31g(d)",
|
|
1132
|
+
"",
|
|
1133
|
+
mol.name,
|
|
1134
|
+
"",
|
|
1135
|
+
f"{mol.charge} 1"
|
|
1136
|
+
]
|
|
1137
|
+
for a in mol.atoms:
|
|
1138
|
+
lines.append(f"{a.element:3s} {a.x:12.6f} {a.y:12.6f} {a.z:12.6f}")
|
|
1139
|
+
lines.append("") # Gaussian needs trailing blank line
|
|
1140
|
+
return "\n".join(lines)
|
|
1141
|
+
|
|
1142
|
+
def save_pdb(mol: "Molecule") -> str:
|
|
1143
|
+
"""Produce a basic PDB string."""
|
|
1144
|
+
lines = [f"HEADER {mol.name[:40]:<40}", f"TITLE {mol.name}"]
|
|
1145
|
+
for i, a in enumerate(mol.atoms):
|
|
1146
|
+
serial = (i + 1) % 100000
|
|
1147
|
+
line = f"HETATM{serial:5d} {a.element:<3s} UNL A 1 {a.x:8.3f}{a.y:8.3f}{a.z:8.3f} 1.00 0.00 {a.element:>2s}"
|
|
1148
|
+
lines.append(line)
|
|
1149
|
+
|
|
1150
|
+
for b in mol.bonds:
|
|
1151
|
+
lines.append(f"CONECT{b.i+1:5d}{b.j+1:5d}")
|
|
1152
|
+
|
|
1153
|
+
lines.append("END")
|
|
1154
|
+
return "\n".join(lines)
|
|
1155
|
+
|
|
1156
|
+
def save_mol(mol: "Molecule") -> str:
|
|
1157
|
+
"""Produce an MDL Molfile V2000 string."""
|
|
1158
|
+
lines = [
|
|
1159
|
+
mol.name[:80],
|
|
1160
|
+
" Molvector",
|
|
1161
|
+
"",
|
|
1162
|
+
f" {len(mol.atoms):3d}{len(mol.bonds):3d} 0 0 0 0 0 0 0 0999 V2000",
|
|
1163
|
+
]
|
|
1164
|
+
for a in mol.atoms:
|
|
1165
|
+
lines.append(
|
|
1166
|
+
f"{a.x:10.4f}{a.y:10.4f}{a.z:10.4f} {a.element:<3s} 0 0 0 0 0 0 0 0 0 0 0 0"
|
|
1167
|
+
)
|
|
1168
|
+
for b in mol.bonds:
|
|
1169
|
+
bo = b.order if b.order in (1, 2, 3) else 1
|
|
1170
|
+
lines.append(f"{b.i+1:3d}{b.j+1:3d}{bo:3d} 0 0 0 0")
|
|
1171
|
+
if mol.charge != 0:
|
|
1172
|
+
# Find atoms with non-zero formal charge
|
|
1173
|
+
# Approximate by evenly distributing total charge
|
|
1174
|
+
chg_parts = []
|
|
1175
|
+
if len(mol.atoms) > 0:
|
|
1176
|
+
q = mol.charge // len(mol.atoms)
|
|
1177
|
+
r = mol.charge % len(mol.atoms)
|
|
1178
|
+
for i in range(len(mol.atoms)):
|
|
1179
|
+
c = q + (1 if i < r else 0)
|
|
1180
|
+
if c != 0:
|
|
1181
|
+
chg_parts.append(f"{i+1:4d}{c:4d}")
|
|
1182
|
+
if chg_parts:
|
|
1183
|
+
# Must write in pairs; M CHG n a1 c1 a2 c2 ...
|
|
1184
|
+
# Group in chunks of 8 pairs to fit line length
|
|
1185
|
+
n_per_line = 8
|
|
1186
|
+
for chunk_start in range(0, len(chg_parts), n_per_line):
|
|
1187
|
+
chunk = chg_parts[chunk_start:chunk_start + n_per_line]
|
|
1188
|
+
n = len(chunk)
|
|
1189
|
+
lines.append("M CHG" + f"{n:3d}" + "".join(chunk))
|
|
1190
|
+
lines.append("M END")
|
|
1191
|
+
return "\n".join(lines) + "\n"
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1195
|
+
# GEOMETRY HELPERS
|
|
1196
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1197
|
+
|
|
1198
|
+
def rotation_matrix(rx: float, ry: float, rz: float) -> np.ndarray:
|
|
1199
|
+
cx,sx = math.cos(rx),math.sin(rx)
|
|
1200
|
+
cy,sy = math.cos(ry),math.sin(ry)
|
|
1201
|
+
cz,sz = math.cos(rz),math.sin(rz)
|
|
1202
|
+
Rx = np.array([[1,0,0],[0,cx,-sx],[0,sx,cx]])
|
|
1203
|
+
Ry = np.array([[cy,0,sy],[0,1,0],[-sy,0,cy]])
|
|
1204
|
+
Rz = np.array([[cz,-sz,0],[sz,cz,0],[0,0,1]])
|
|
1205
|
+
return Rz @ Ry @ Rx
|
|
1206
|
+
def center_positions(atoms: List[Atom]) -> np.ndarray:
|
|
1207
|
+
if not atoms:
|
|
1208
|
+
return np.zeros((0, 3))
|
|
1209
|
+
pos = np.array([a.pos for a in atoms])
|
|
1210
|
+
return pos - pos.mean(axis=0)
|
|
1211
|
+
|
|
1212
|
+
def optimize_geometry(mol: Molecule, max_steps: int = 500, tol: float = 0.01,
|
|
1213
|
+
fixed_indices: List[int] = None, k_bond: float = None, k_rep: float = None):
|
|
1214
|
+
"""
|
|
1215
|
+
Geometry optimization using OpenBabel's MMFF94s or UFF force field.
|
|
1216
|
+
|
|
1217
|
+
Converts the Molvector molecule to OpenBabel's internal representation,
|
|
1218
|
+
runs conjugate-gradient minimization, and writes the optimized coordinates
|
|
1219
|
+
back. Automatically picks the best available force field:
|
|
1220
|
+
MMFF94s → best for organic elements (H,C,N,O,F,S,P,Cl,Br,I)
|
|
1221
|
+
UFF → fallback, covers the full periodic table
|
|
1222
|
+
|
|
1223
|
+
Parameters
|
|
1224
|
+
----------
|
|
1225
|
+
mol : Molecule
|
|
1226
|
+
max_steps : int
|
|
1227
|
+
Maximum conjugate-gradient iterations (default 500; ~0.3-0.7 ms total).
|
|
1228
|
+
tol : float
|
|
1229
|
+
Convergence threshold (kept for backward compatibility; OpenBabel uses
|
|
1230
|
+
its own internal convergence criteria).
|
|
1231
|
+
fixed_indices : list of int, optional
|
|
1232
|
+
Atom indices (0-based) that should remain frozen.
|
|
1233
|
+
k_bond, k_rep : ignored
|
|
1234
|
+
Legacy parameters accepted for backward-compatible call sites.
|
|
1235
|
+
|
|
1236
|
+
Returns
|
|
1237
|
+
-------
|
|
1238
|
+
int
|
|
1239
|
+
Number of iterations taken (approximate).
|
|
1240
|
+
"""
|
|
1241
|
+
if not mol.atoms or not HAS_OPENBABEL:
|
|
1242
|
+
return 0
|
|
1243
|
+
if len(mol.atoms) < 2:
|
|
1244
|
+
return 0
|
|
1245
|
+
|
|
1246
|
+
from contextlib import redirect_stderr
|
|
1247
|
+
import os
|
|
1248
|
+
from openbabel import openbabel as ob
|
|
1249
|
+
|
|
1250
|
+
n = len(mol.atoms)
|
|
1251
|
+
|
|
1252
|
+
# ── 1. Element symbol → atomic number ─────────────────────────────────
|
|
1253
|
+
_SYM_TO_Z = {
|
|
1254
|
+
"H":1, "He":2, "Li":3, "Be":4, "B":5, "C":6, "N":7, "O":8, "F":9,
|
|
1255
|
+
"Ne":10, "Na":11, "Mg":12, "Al":13, "Si":14, "P":15, "S":16,
|
|
1256
|
+
"Cl":17, "Ar":18, "K":19, "Ca":20, "Fe":26, "Ni":28, "Cu":29,
|
|
1257
|
+
"Zn":30, "Br":35, "I":53, "Au":79, "Hg":80,
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
# ── 2. Build OBMol ────────────────────────────────────────────────────
|
|
1261
|
+
obmol = ob.OBMol()
|
|
1262
|
+
obmol.SetDimension(3)
|
|
1263
|
+
|
|
1264
|
+
for a in mol.atoms:
|
|
1265
|
+
oba = obmol.NewAtom()
|
|
1266
|
+
oba.SetAtomicNum(_SYM_TO_Z.get(a.element, 6))
|
|
1267
|
+
oba.SetVector(a.x, a.y, a.z)
|
|
1268
|
+
|
|
1269
|
+
# Bond-order translation map
|
|
1270
|
+
# Molvector uses 5 for aromatic/resonance bonds.
|
|
1271
|
+
# For MMFF94s we convert to alternating 1/2 (Kekulé pattern) which OB
|
|
1272
|
+
# will auto-detect as aromatic during setup.
|
|
1273
|
+
_kekule_prepass(mol, obmol)
|
|
1274
|
+
|
|
1275
|
+
# Translate remaining bonds (order 1/2/3 directly)
|
|
1276
|
+
for b in mol.bonds:
|
|
1277
|
+
if b.order in (1, 2, 3):
|
|
1278
|
+
obmol.AddBond(int(b.i) + 1, int(b.j) + 1, int(b.order))
|
|
1279
|
+
|
|
1280
|
+
obmol.SetTotalCharge(mol.charge)
|
|
1281
|
+
|
|
1282
|
+
# ── 3. Select and set up force field ──────────────────────────────────
|
|
1283
|
+
ff = None
|
|
1284
|
+
ff_name = "MMFF94s"
|
|
1285
|
+
with open(os.devnull, "w") as _null, redirect_stderr(_null):
|
|
1286
|
+
ff = ob.OBForceField.FindForceField(ff_name)
|
|
1287
|
+
if not ff.Setup(obmol):
|
|
1288
|
+
ff = ob.OBForceField.FindForceField("UFF")
|
|
1289
|
+
ff_name = "UFF"
|
|
1290
|
+
if not ff.Setup(obmol):
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
if fixed_indices:
|
|
1294
|
+
for idx in fixed_indices:
|
|
1295
|
+
ff.SetFixAtom(int(idx) + 1) # OB is 1-indexed
|
|
1296
|
+
|
|
1297
|
+
# ── 4. Optimize ───────────────────────────────────────────────────
|
|
1298
|
+
ff.ConjugateGradients(int(max_steps))
|
|
1299
|
+
|
|
1300
|
+
# ── 5. Read back coordinates ──────────────────────────────────────────
|
|
1301
|
+
ff.GetCoordinates(obmol)
|
|
1302
|
+
for i in range(n):
|
|
1303
|
+
oba = obmol.GetAtom(i + 1)
|
|
1304
|
+
mol.atoms[i].x = oba.GetX()
|
|
1305
|
+
mol.atoms[i].y = oba.GetY()
|
|
1306
|
+
mol.atoms[i].z = oba.GetZ()
|
|
1307
|
+
|
|
1308
|
+
return int(max_steps)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def calculate_dipole_moment(mol: Molecule):
|
|
1312
|
+
"""
|
|
1313
|
+
Estimate the dipole moment using EEM partial charges via OpenBabel.
|
|
1314
|
+
|
|
1315
|
+
The Electronegativity Equalization Method (EEM) distributes partial
|
|
1316
|
+
charges so that each atom reaches a common electronegativity, then the
|
|
1317
|
+
dipole moment is computed relative to the centre of mass.
|
|
1318
|
+
|
|
1319
|
+
Returns
|
|
1320
|
+
-------
|
|
1321
|
+
tuple (dipole_vector, dipole_magnitude, atom_charges)
|
|
1322
|
+
dipole_vector : np.ndarray of shape (3,) — (μx, μy, μz) in Debye
|
|
1323
|
+
dipole_magnitude: float — |μ| in Debye
|
|
1324
|
+
atom_charges : list of (element, charge) tuples
|
|
1325
|
+
|
|
1326
|
+
Returns (None, None, None) when OpenBabel is unavailable or the
|
|
1327
|
+
molecule is empty.
|
|
1328
|
+
"""
|
|
1329
|
+
if not mol.atoms or not HAS_OPENBABEL:
|
|
1330
|
+
return None, None, None
|
|
1331
|
+
if len(mol.atoms) < 2:
|
|
1332
|
+
return None, None, None
|
|
1333
|
+
|
|
1334
|
+
from contextlib import redirect_stderr
|
|
1335
|
+
import os
|
|
1336
|
+
from openbabel import openbabel as ob
|
|
1337
|
+
|
|
1338
|
+
_SYM_TO_Z = {
|
|
1339
|
+
"H":1, "He":2, "Li":3, "Be":4, "B":5, "C":6, "N":7, "O":8, "F":9,
|
|
1340
|
+
"Ne":10, "Na":11, "Mg":12, "Al":13, "Si":14, "P":15, "S":16,
|
|
1341
|
+
"Cl":17, "Ar":18, "K":19, "Ca":20, "Fe":26, "Ni":28, "Cu":29,
|
|
1342
|
+
"Zn":30, "Br":35, "I":53, "Au":79, "Hg":80,
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
n = len(mol.atoms)
|
|
1346
|
+
|
|
1347
|
+
# ── 1. Build OBMol ────────────────────────────────────────────────────
|
|
1348
|
+
obmol = ob.OBMol()
|
|
1349
|
+
obmol.SetDimension(3)
|
|
1350
|
+
|
|
1351
|
+
for a in mol.atoms:
|
|
1352
|
+
oba = obmol.NewAtom()
|
|
1353
|
+
oba.SetAtomicNum(_SYM_TO_Z.get(a.element, 6))
|
|
1354
|
+
oba.SetVector(a.x, a.y, a.z)
|
|
1355
|
+
|
|
1356
|
+
_kekule_prepass(mol, obmol)
|
|
1357
|
+
|
|
1358
|
+
for b in mol.bonds:
|
|
1359
|
+
if b.order in (1, 2, 3):
|
|
1360
|
+
obmol.AddBond(int(b.i) + 1, int(b.j) + 1, int(b.order))
|
|
1361
|
+
|
|
1362
|
+
if obmol.NumBonds() == 0:
|
|
1363
|
+
obmol.ConnectTheDots()
|
|
1364
|
+
|
|
1365
|
+
obmol.SetTotalCharge(mol.charge)
|
|
1366
|
+
|
|
1367
|
+
# ── 2. Compute EEM partial charges ────────────────────────────────────
|
|
1368
|
+
with open(os.devnull, "w") as _null, redirect_stderr(_null):
|
|
1369
|
+
charge_model = ob.OBChargeModel.FindType("eem")
|
|
1370
|
+
if charge_model is None:
|
|
1371
|
+
return None, None, None
|
|
1372
|
+
if not charge_model.ComputeCharges(obmol):
|
|
1373
|
+
return None, None, None
|
|
1374
|
+
|
|
1375
|
+
# ── 3. Extract charges and centre of mass ────────────────────────────
|
|
1376
|
+
charges = []
|
|
1377
|
+
com = np.zeros(3)
|
|
1378
|
+
total_mass = 0.0
|
|
1379
|
+
for i in range(n):
|
|
1380
|
+
oba = obmol.GetAtom(i + 1)
|
|
1381
|
+
q = oba.GetPartialCharge()
|
|
1382
|
+
elem = mol.atoms[i].element
|
|
1383
|
+
charges.append((elem, q))
|
|
1384
|
+
mass = ATOMIC_MASSES.get(elem, 0.0)
|
|
1385
|
+
com += mass * np.array([mol.atoms[i].x, mol.atoms[i].y, mol.atoms[i].z])
|
|
1386
|
+
total_mass += mass
|
|
1387
|
+
if total_mass > 0:
|
|
1388
|
+
com /= total_mass
|
|
1389
|
+
|
|
1390
|
+
# ── 4. Dipole moment vector (Debye) ──────────────────────────────────
|
|
1391
|
+
DEBYE_PER_EANGSTROM = 4.803204
|
|
1392
|
+
mu = np.zeros(3)
|
|
1393
|
+
for i in range(n):
|
|
1394
|
+
r = np.array([mol.atoms[i].x, mol.atoms[i].y, mol.atoms[i].z]) - com
|
|
1395
|
+
mu += charges[i][1] * r
|
|
1396
|
+
mu *= DEBYE_PER_EANGSTROM
|
|
1397
|
+
|
|
1398
|
+
return mu, float(np.linalg.norm(mu)), charges
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def generate_inchi(mol: Molecule) -> Optional[str]:
|
|
1402
|
+
"""
|
|
1403
|
+
Generate an InChI identifier for *mol* using RDKit (preferred) or
|
|
1404
|
+
OpenBabel (fallback).
|
|
1405
|
+
|
|
1406
|
+
Returns the InChI string on success, or ``None`` if conversion fails.
|
|
1407
|
+
"""
|
|
1408
|
+
if not mol.atoms:
|
|
1409
|
+
return None
|
|
1410
|
+
|
|
1411
|
+
try:
|
|
1412
|
+
return _generate_inchi_rdkit(mol)
|
|
1413
|
+
except Exception:
|
|
1414
|
+
pass
|
|
1415
|
+
|
|
1416
|
+
if HAS_OPENBABEL:
|
|
1417
|
+
try:
|
|
1418
|
+
return _generate_inchi_openbabel(mol)
|
|
1419
|
+
except Exception:
|
|
1420
|
+
pass
|
|
1421
|
+
|
|
1422
|
+
return None
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
_EXPECTED_VALENCE: Dict[str, int] = {
|
|
1426
|
+
"H": 1, "D": 1, "T": 1, "He": 2, "Li": 1, "Be": 2, "B": 3, "C": 4, "13C": 4, "14C": 4, "N": 4, "15N": 4, "O": 2,
|
|
1427
|
+
"F": 1, "Ne": 8, "Na": 1, "Mg": 2, "Al": 3, "Si": 4, "P": 5, "S": 6,
|
|
1428
|
+
"Cl": 1, "Ar": 8, "K": 1, "Ca": 2, "Fe": 3, "Ni": 2, "Cu": 2,
|
|
1429
|
+
"Zn": 2, "Br": 1, "I": 1, "Au": 3, "Hg": 2,
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def check_valence_issues(mol: Molecule) -> List[str]:
|
|
1434
|
+
"""Return a list of valence warning strings for atoms in *mol*."""
|
|
1435
|
+
issues: List[str] = []
|
|
1436
|
+
for idx, a in enumerate(mol.atoms):
|
|
1437
|
+
bond_sum = 0.0
|
|
1438
|
+
for b in mol.bonds:
|
|
1439
|
+
if b.i == idx or b.j == idx:
|
|
1440
|
+
bond_sum += b.order
|
|
1441
|
+
expected = _EXPECTED_VALENCE.get(a.element, 4)
|
|
1442
|
+
if bond_sum > expected:
|
|
1443
|
+
issues.append(
|
|
1444
|
+
f"{a.element}#{idx + 1}: valence {int(bond_sum)} exceeds expected {expected}"
|
|
1445
|
+
)
|
|
1446
|
+
return issues
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _generate_inchi_rdkit(mol: Molecule) -> Optional[str]:
|
|
1450
|
+
from rdkit import Chem
|
|
1451
|
+
from rdkit.Geometry import Point3D
|
|
1452
|
+
|
|
1453
|
+
kek = _kekule_orders(mol)
|
|
1454
|
+
|
|
1455
|
+
rw = Chem.RWMol()
|
|
1456
|
+
for a in mol.atoms:
|
|
1457
|
+
rw.AddAtom(Chem.Atom(a.element))
|
|
1458
|
+
for b in mol.bonds:
|
|
1459
|
+
if b.order == 5:
|
|
1460
|
+
k = (b.i, b.j) if b.i < b.j else (b.j, b.i)
|
|
1461
|
+
order = kek.get(k, 1)
|
|
1462
|
+
else:
|
|
1463
|
+
order = b.order
|
|
1464
|
+
btype = {1: Chem.BondType.SINGLE, 2: Chem.BondType.DOUBLE,
|
|
1465
|
+
3: Chem.BondType.TRIPLE}.get(order, Chem.BondType.SINGLE)
|
|
1466
|
+
rw.AddBond(b.i, b.j, btype)
|
|
1467
|
+
|
|
1468
|
+
if mol.charge != 0:
|
|
1469
|
+
heavy = next((i for i, a in enumerate(mol.atoms) if a.element != "H"), 0)
|
|
1470
|
+
rw.GetAtomWithIdx(heavy).SetFormalCharge(mol.charge)
|
|
1471
|
+
|
|
1472
|
+
conf = Chem.Conformer(len(mol.atoms))
|
|
1473
|
+
for i, a in enumerate(mol.atoms):
|
|
1474
|
+
conf.SetAtomPosition(i, Point3D(a.x, a.y, a.z))
|
|
1475
|
+
rw.AddConformer(conf, assignId=True)
|
|
1476
|
+
|
|
1477
|
+
inchi = Chem.MolToInchi(rw)
|
|
1478
|
+
if inchi:
|
|
1479
|
+
return inchi
|
|
1480
|
+
return None
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _generate_inchi_openbabel(mol: Molecule) -> Optional[str]:
|
|
1484
|
+
from contextlib import redirect_stderr
|
|
1485
|
+
import os
|
|
1486
|
+
|
|
1487
|
+
from openbabel import openbabel as ob
|
|
1488
|
+
|
|
1489
|
+
_SYM_TO_Z = {
|
|
1490
|
+
"H":1, "He":2, "Li":3, "Be":4, "B":5, "C":6, "N":7, "O":8, "F":9,
|
|
1491
|
+
"Ne":10, "Na":11, "Mg":12, "Al":13, "Si":14, "P":15, "S":16,
|
|
1492
|
+
"Cl":17, "Ar":18, "K":19, "Ca":20, "Fe":26, "Ni":28, "Cu":29,
|
|
1493
|
+
"Zn":30, "Br":35, "I":53, "Au":79, "Hg":80,
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
obmol = ob.OBMol()
|
|
1497
|
+
obmol.SetDimension(3)
|
|
1498
|
+
|
|
1499
|
+
for a in mol.atoms:
|
|
1500
|
+
oba = obmol.NewAtom()
|
|
1501
|
+
oba.SetAtomicNum(_SYM_TO_Z.get(a.element, 6))
|
|
1502
|
+
oba.SetVector(a.x, a.y, a.z)
|
|
1503
|
+
|
|
1504
|
+
_kekule_prepass(mol, obmol)
|
|
1505
|
+
|
|
1506
|
+
for b in mol.bonds:
|
|
1507
|
+
if b.order == 5:
|
|
1508
|
+
continue
|
|
1509
|
+
obmol.AddBond(b.i + 1, b.j + 1, b.order)
|
|
1510
|
+
|
|
1511
|
+
if mol.charge != 0:
|
|
1512
|
+
obmol.SetTotalCharge(mol.charge)
|
|
1513
|
+
|
|
1514
|
+
obmol.ConnectTheDots()
|
|
1515
|
+
obmol.PerceiveBondOrders()
|
|
1516
|
+
|
|
1517
|
+
conv = ob.OBConversion()
|
|
1518
|
+
conv.SetOutFormat("inchi")
|
|
1519
|
+
with open(os.devnull, "w") as _null, redirect_stderr(_null):
|
|
1520
|
+
inchi = conv.WriteString(obmol).strip()
|
|
1521
|
+
return inchi if inchi else None
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _kekule_orders(mol: "Molecule") -> Dict[Tuple[int, int], int]:
|
|
1525
|
+
"""
|
|
1526
|
+
Return a Kekulé bond-order map for resonance (order-5) bonds.
|
|
1527
|
+
|
|
1528
|
+
For each connected component of resonance bonds:
|
|
1529
|
+
* **Simple cycle** (every node degree 2): walk around assigning
|
|
1530
|
+
alternating bond orders (2, 1, 2, 1, …).
|
|
1531
|
+
* **Otherwise** (fused rings, branched): fall back to all-single bonds.
|
|
1532
|
+
|
|
1533
|
+
Returns a dict ``{(i, j): bond_order}`` where ``i < j``.
|
|
1534
|
+
"""
|
|
1535
|
+
res_bonds = [(b.i, b.j) for b in mol.bonds if b.order == 5]
|
|
1536
|
+
if not res_bonds:
|
|
1537
|
+
return {}
|
|
1538
|
+
|
|
1539
|
+
adj: Dict[int, set] = {}
|
|
1540
|
+
for i, j in res_bonds:
|
|
1541
|
+
adj.setdefault(i, set()).add(j)
|
|
1542
|
+
adj.setdefault(j, set()).add(i)
|
|
1543
|
+
|
|
1544
|
+
def key(a: int, b: int) -> Tuple[int, int]:
|
|
1545
|
+
return (a, b) if a < b else (b, a)
|
|
1546
|
+
|
|
1547
|
+
orders: Dict[Tuple[int, int], int] = {}
|
|
1548
|
+
|
|
1549
|
+
visited: set = set()
|
|
1550
|
+
for start in adj:
|
|
1551
|
+
if start in visited:
|
|
1552
|
+
continue
|
|
1553
|
+
|
|
1554
|
+
comp_nodes: List[int] = []
|
|
1555
|
+
q = [start]
|
|
1556
|
+
visited.add(start)
|
|
1557
|
+
while q:
|
|
1558
|
+
node = q.pop(0)
|
|
1559
|
+
comp_nodes.append(node)
|
|
1560
|
+
for nb in adj[node]:
|
|
1561
|
+
if nb not in visited:
|
|
1562
|
+
visited.add(nb)
|
|
1563
|
+
q.append(nb)
|
|
1564
|
+
|
|
1565
|
+
is_simple_cycle = all(len(adj[n]) == 2 for n in comp_nodes) and len(comp_nodes) >= 3
|
|
1566
|
+
|
|
1567
|
+
if is_simple_cycle:
|
|
1568
|
+
cycle: List[int] = []
|
|
1569
|
+
prev = -1
|
|
1570
|
+
cur = comp_nodes[0]
|
|
1571
|
+
while True:
|
|
1572
|
+
cycle.append(cur)
|
|
1573
|
+
nbs = [nb for nb in adj[cur] if nb != prev]
|
|
1574
|
+
if not nbs:
|
|
1575
|
+
break
|
|
1576
|
+
nxt = nbs[0]
|
|
1577
|
+
if len(cycle) > 1 and nxt == cycle[0]:
|
|
1578
|
+
break
|
|
1579
|
+
prev, cur = cur, nxt
|
|
1580
|
+
|
|
1581
|
+
if len(cycle) == len(comp_nodes):
|
|
1582
|
+
for idx in range(len(cycle)):
|
|
1583
|
+
a, b = cycle[idx], cycle[(idx + 1) % len(cycle)]
|
|
1584
|
+
k = key(a, b)
|
|
1585
|
+
if k not in orders:
|
|
1586
|
+
if idx < len(cycle) - 1:
|
|
1587
|
+
orders[k] = 2 if idx % 2 == 0 else 1
|
|
1588
|
+
else:
|
|
1589
|
+
orders[k] = 1
|
|
1590
|
+
else:
|
|
1591
|
+
for a, b in res_bonds:
|
|
1592
|
+
if a in comp_nodes or b in comp_nodes:
|
|
1593
|
+
k = key(a, b)
|
|
1594
|
+
if k not in orders:
|
|
1595
|
+
orders[k] = 1
|
|
1596
|
+
|
|
1597
|
+
return orders
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def _kekule_prepass(mol: "Molecule", obmol) -> None:
|
|
1601
|
+
"""
|
|
1602
|
+
Convert Molvector order-5 (resonance) bonds into alternating
|
|
1603
|
+
single/double (Kekulé pattern) for OpenBabel.
|
|
1604
|
+
|
|
1605
|
+
For each connected component:
|
|
1606
|
+
* **Simple cycle** (every node degree 2): walk around assigning
|
|
1607
|
+
alternating bond orders (2, 1, 2, 1, …).
|
|
1608
|
+
* **Otherwise** (fused rings, branched): fall back to all-single bonds
|
|
1609
|
+
(still planar, still works with MMFF94s; bonds ≈1.45 Å instead of
|
|
1610
|
+
the delocalised 1.395 Å — acceptable for a builder tool).
|
|
1611
|
+
|
|
1612
|
+
OpenBabel's MMFF94s auto-detects aromaticity from the alternating
|
|
1613
|
+
pattern and uses proper delocalised parameters.
|
|
1614
|
+
"""
|
|
1615
|
+
orders = _kekule_orders(mol)
|
|
1616
|
+
if not orders:
|
|
1617
|
+
return
|
|
1618
|
+
|
|
1619
|
+
for (i, j), bo in orders.items():
|
|
1620
|
+
obmol.AddBond(i + 1, j + 1, bo)
|
|
1621
|
+
|
|
1622
|
+
def bond_half_line(
|
|
1623
|
+
ax:float, ay:float, bx:float, by:float,
|
|
1624
|
+
atom_r_px:float, offset:float = 0.0,
|
|
1625
|
+
) -> Tuple[Optional[Tuple[Tuple[float,float], Tuple[float,float]]], Tuple[float,float]]:
|
|
1626
|
+
dx, dy = bx-ax, by-ay
|
|
1627
|
+
length = math.hypot(dx, dy)
|
|
1628
|
+
if length < 1e-6:
|
|
1629
|
+
return None, (0.0, 0.0)
|
|
1630
|
+
ux,uy = dx/length, dy/length
|
|
1631
|
+
px,py = -uy, ux
|
|
1632
|
+
|
|
1633
|
+
cx_a = ax + px*offset; cy_a = ay + py*offset
|
|
1634
|
+
cx_b = bx + px*offset; cy_b = by + py*offset
|
|
1635
|
+
|
|
1636
|
+
x0 = cx_a + ux*atom_r_px; y0 = cy_a + uy*atom_r_px
|
|
1637
|
+
x1 = (cx_a+cx_b)/2; y1 = (cy_a+cy_b)/2
|
|
1638
|
+
return ((x0, y0), (x1, y1)), (px, py)
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1642
|
+
# MAIN RENDERER
|
|
1643
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def project_molecule(
|
|
1647
|
+
mol: Molecule,
|
|
1648
|
+
rot: np.ndarray,
|
|
1649
|
+
pan_x: float,
|
|
1650
|
+
pan_y: float,
|
|
1651
|
+
canvas_w: int,
|
|
1652
|
+
canvas_h: int,
|
|
1653
|
+
scale: float,
|
|
1654
|
+
atom_scale: float
|
|
1655
|
+
) -> Tuple[List[Tuple[float, float, float, float]], List[Tuple[float, float, float, float, float, int]]]:
|
|
1656
|
+
"""
|
|
1657
|
+
Project 3D molecule to 2D canvas coordinates.
|
|
1658
|
+
Returns:
|
|
1659
|
+
atom_projs: list of (px, py, pz, r_px)
|
|
1660
|
+
bond_projs: list of (ax, ay, bx, by, z_avg, bond_idx)
|
|
1661
|
+
"""
|
|
1662
|
+
centered = center_positions(mol.atoms)
|
|
1663
|
+
cx = canvas_w/2 + pan_x
|
|
1664
|
+
cy = canvas_h/2 + pan_y
|
|
1665
|
+
CAMERA_Z = 60.0
|
|
1666
|
+
|
|
1667
|
+
atom_projs = []
|
|
1668
|
+
for i, atom in enumerate(mol.atoms):
|
|
1669
|
+
rp = rot @ centered[i]
|
|
1670
|
+
elem = atom.element
|
|
1671
|
+
vdw = VDW_RADII.get(elem, DEFAULT_VDW)
|
|
1672
|
+
z_factor = CAMERA_Z / (CAMERA_Z - rp[2]) if (CAMERA_Z - rp[2]) != 0 else 1.0
|
|
1673
|
+
r_px = vdw * scale * atom_scale * z_factor
|
|
1674
|
+
px = cx + rp[0] * scale * z_factor
|
|
1675
|
+
py = cy - rp[1] * scale * z_factor
|
|
1676
|
+
atom_projs.append((px, py, rp[2], r_px))
|
|
1677
|
+
|
|
1678
|
+
bond_projs = []
|
|
1679
|
+
for i, bond in enumerate(mol.bonds):
|
|
1680
|
+
pA, pB = atom_projs[bond.i], atom_projs[bond.j]
|
|
1681
|
+
bond_projs.append((pA[0], pA[1], pB[0], pB[1], (pA[2] + pB[2])/2, i))
|
|
1682
|
+
|
|
1683
|
+
return atom_projs, bond_projs
|
|
1684
|
+
|
|
1685
|
+
def render_molecule(
|
|
1686
|
+
mol: Molecule,
|
|
1687
|
+
rot_x: float = 0.0,
|
|
1688
|
+
rot_y: float = 0.0,
|
|
1689
|
+
rot_z: float = 0.0,
|
|
1690
|
+
rot_matrix_override: Optional[np.ndarray] = None,
|
|
1691
|
+
pan_x: float = 0.0,
|
|
1692
|
+
pan_y: float = 0.0,
|
|
1693
|
+
canvas_w: int = 700,
|
|
1694
|
+
canvas_h: int = 600,
|
|
1695
|
+
scale: float = 110.0,
|
|
1696
|
+
atom_scale: float = 0.8,
|
|
1697
|
+
bond_width_px: float = 10.0,
|
|
1698
|
+
background: str = "#0a0a12",
|
|
1699
|
+
color_overrides: Optional[Dict[str, str]] = None,
|
|
1700
|
+
output_path: str = "molecule.svg",
|
|
1701
|
+
export_mode: bool = False,
|
|
1702
|
+
vectors: Optional[List[Tuple[int, float, float, float, str]]] = None,
|
|
1703
|
+
active_vectors: Optional[np.ndarray] = None,
|
|
1704
|
+
selected_indices: Optional[set] = None,
|
|
1705
|
+
animation_phase: float = 0.0,
|
|
1706
|
+
animation_amplitude: float = 0.0,
|
|
1707
|
+
bond_style: str = "gradient",
|
|
1708
|
+
bond_color: str = "#444444",
|
|
1709
|
+
atom_border_mode: str = "scaled",
|
|
1710
|
+
atom_border_scale: float = 1.04,
|
|
1711
|
+
atom_border_width: float = 2.0,
|
|
1712
|
+
lighting_intensity: float = 1.0,
|
|
1713
|
+
light_position: str = "top-left",
|
|
1714
|
+
roughness: float = 1.0,
|
|
1715
|
+
show_axes: bool = False,
|
|
1716
|
+
show_principal_axes: bool = False,
|
|
1717
|
+
axes_position: str = "bottom-left",
|
|
1718
|
+
principal_axes_position: str = "bottom-left",
|
|
1719
|
+
axes_rot_override: Optional[np.ndarray] = None,
|
|
1720
|
+
) -> str:
|
|
1721
|
+
|
|
1722
|
+
rot = rot_matrix_override if rot_matrix_override is not None \
|
|
1723
|
+
else rotation_matrix(rot_x, rot_y, rot_z)
|
|
1724
|
+
|
|
1725
|
+
if light_position not in LIGHT_POSITIONS:
|
|
1726
|
+
light_position = "top-left"
|
|
1727
|
+
grad_cx, grad_cy, Lx, Ly, Lz = LIGHT_POSITIONS[light_position]
|
|
1728
|
+
|
|
1729
|
+
base_colors = dict(CPK_BASE)
|
|
1730
|
+
dark_colors = dict(CPK_DARK)
|
|
1731
|
+
if color_overrides:
|
|
1732
|
+
for elem, col in color_overrides.items():
|
|
1733
|
+
base_colors[elem] = col
|
|
1734
|
+
dark_colors[elem] = auto_dark(col)
|
|
1735
|
+
|
|
1736
|
+
# Per-atom custom colors (overrides element & color_overrides)
|
|
1737
|
+
_atom_custom_keys: Dict[int, str] = {}
|
|
1738
|
+
for idx, atom in enumerate(mol.atoms):
|
|
1739
|
+
if atom.color:
|
|
1740
|
+
key = f"_cust{idx}"
|
|
1741
|
+
_atom_custom_keys[idx] = key
|
|
1742
|
+
base_colors[key] = atom.color
|
|
1743
|
+
dark_colors[key] = auto_dark(atom.color)
|
|
1744
|
+
|
|
1745
|
+
centered = center_positions(mol.atoms)
|
|
1746
|
+
cx = canvas_w/2 + pan_x
|
|
1747
|
+
cy = canvas_h/2 + pan_y
|
|
1748
|
+
|
|
1749
|
+
CAMERA_Z = 60.0
|
|
1750
|
+
|
|
1751
|
+
proj = []
|
|
1752
|
+
for i, atom in enumerate(mol.atoms):
|
|
1753
|
+
pos = centered[i]
|
|
1754
|
+
if animation_amplitude > 0 and active_vectors is not None:
|
|
1755
|
+
# Oscillation: pos + vector * amp * sin(phase)
|
|
1756
|
+
pos = pos + active_vectors[i] * animation_amplitude * math.sin(animation_phase)
|
|
1757
|
+
|
|
1758
|
+
rp = rot @ pos
|
|
1759
|
+
elem = atom.element
|
|
1760
|
+
vdw = VDW_RADII.get(elem, DEFAULT_VDW)
|
|
1761
|
+
|
|
1762
|
+
z_factor = CAMERA_Z / (CAMERA_Z - rp[2]) if (CAMERA_Z - rp[2]) != 0 else 1.0
|
|
1763
|
+
|
|
1764
|
+
r_px = vdw * scale * atom_scale * z_factor
|
|
1765
|
+
# Safety clipping
|
|
1766
|
+
r_px = max(0.1, min(1000.0, r_px))
|
|
1767
|
+
if not math.isfinite(r_px): r_px = 10.0
|
|
1768
|
+
|
|
1769
|
+
px_coord = cx + rp[0] * scale * z_factor
|
|
1770
|
+
py_coord = cy - rp[1] * scale * z_factor
|
|
1771
|
+
|
|
1772
|
+
proj.append((px_coord, py_coord, rp[2], r_px))
|
|
1773
|
+
|
|
1774
|
+
dwg = svgwrite.Drawing(output_path, size=(canvas_w, canvas_h))
|
|
1775
|
+
defs = dwg.defs
|
|
1776
|
+
|
|
1777
|
+
# Use a random prefix for all IDs to prevent collisions in Inkscape
|
|
1778
|
+
prefix = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
|
|
1779
|
+
if not export_mode:
|
|
1780
|
+
dwg.add(dwg.rect(insert=(0,0), size=(canvas_w,canvas_h), fill=background))
|
|
1781
|
+
|
|
1782
|
+
registered: set = set()
|
|
1783
|
+
|
|
1784
|
+
def ensure_grad(elem: str) -> str:
|
|
1785
|
+
gid = f"sph_{elem}_{prefix}"
|
|
1786
|
+
if gid in registered:
|
|
1787
|
+
return gid
|
|
1788
|
+
base = base_colors.get(elem, DEFAULT_BASE)
|
|
1789
|
+
dark = dark_colors.get(elem, DEFAULT_DARK)
|
|
1790
|
+
li = lighting_intensity
|
|
1791
|
+
g = dwg.radialGradient(id=gid, center=(grad_cx, grad_cy), r="0.68")
|
|
1792
|
+
g["fx"]=grad_cx; g["fy"]=grad_cy
|
|
1793
|
+
g["gradientUnits"] = "objectBoundingBox"
|
|
1794
|
+
for pct, color in compute_gradient_stops(
|
|
1795
|
+
base, dark, li, roughness):
|
|
1796
|
+
g.add_stop_color(f"{pct*100:.1f}%", color, 1.0)
|
|
1797
|
+
defs.add(g)
|
|
1798
|
+
registered.add(gid)
|
|
1799
|
+
return gid
|
|
1800
|
+
|
|
1801
|
+
for idx, atom in enumerate(mol.atoms):
|
|
1802
|
+
key = _atom_custom_keys.get(idx, atom.element)
|
|
1803
|
+
ensure_grad(key)
|
|
1804
|
+
|
|
1805
|
+
# SVD to find best-fit molecule plane normal
|
|
1806
|
+
if len(centered) >= 3:
|
|
1807
|
+
U, S, Vt = np.linalg.svd(centered)
|
|
1808
|
+
M_normal = Vt[-1]
|
|
1809
|
+
else:
|
|
1810
|
+
M_normal = np.array([0.0, 0.0, 1.0])
|
|
1811
|
+
|
|
1812
|
+
draw_list = []
|
|
1813
|
+
for bi, bond in enumerate(mol.bonds):
|
|
1814
|
+
ai,aj = bond.i,bond.j
|
|
1815
|
+
A_orig = centered[ai]
|
|
1816
|
+
B_orig = centered[aj]
|
|
1817
|
+
|
|
1818
|
+
if animation_amplitude > 0 and active_vectors is not None:
|
|
1819
|
+
A_orig = A_orig + active_vectors[ai] * animation_amplitude * math.sin(animation_phase)
|
|
1820
|
+
B_orig = B_orig + active_vectors[aj] * animation_amplitude * math.sin(animation_phase)
|
|
1821
|
+
|
|
1822
|
+
u_3D = B_orig - A_orig
|
|
1823
|
+
u_len = np.linalg.norm(u_3D)
|
|
1824
|
+
|
|
1825
|
+
rp_A_orig = rot @ A_orig
|
|
1826
|
+
rp_B_orig = rot @ B_orig
|
|
1827
|
+
orig_az = rp_A_orig[2]
|
|
1828
|
+
orig_bz = rp_B_orig[2]
|
|
1829
|
+
|
|
1830
|
+
dir_3D = np.cross(M_normal, u_3D)
|
|
1831
|
+
if np.linalg.norm(dir_3D) < 1e-6:
|
|
1832
|
+
fallback = np.array([1.0, 0.0, 0.0])
|
|
1833
|
+
if np.linalg.norm(np.cross(u_3D, fallback)) < 1e-6:
|
|
1834
|
+
fallback = np.array([0.0, 1.0, 0.0])
|
|
1835
|
+
dir_3D = np.cross(fallback, u_3D)
|
|
1836
|
+
dir_3D = dir_3D / np.linalg.norm(dir_3D)
|
|
1837
|
+
|
|
1838
|
+
# Bond surface offset in Angstroms (push to sphere surface in 3D)
|
|
1839
|
+
if u_len > 1e-6:
|
|
1840
|
+
u_hat = u_3D / u_len
|
|
1841
|
+
vdw_A = VDW_RADII.get(mol.atoms[ai].element, DEFAULT_VDW)
|
|
1842
|
+
vdw_B = VDW_RADII.get(mol.atoms[aj].element, DEFAULT_VDW)
|
|
1843
|
+
surf_off_A = vdw_A * atom_scale
|
|
1844
|
+
surf_off_B = vdw_B * atom_scale
|
|
1845
|
+
else:
|
|
1846
|
+
u_hat = np.array([0.0, 0.0, 0.0])
|
|
1847
|
+
surf_off_A = 0.0
|
|
1848
|
+
surf_off_B = 0.0
|
|
1849
|
+
|
|
1850
|
+
# Bond width offset in Angstroms
|
|
1851
|
+
hw_angstrom = bond_width_px / 110.0
|
|
1852
|
+
|
|
1853
|
+
if bond.order == 1:
|
|
1854
|
+
offsets = [0.0]
|
|
1855
|
+
indiv_hw_angstrom = hw_angstrom
|
|
1856
|
+
elif bond.order == 2:
|
|
1857
|
+
offsets = [-hw_angstrom * 1.1, hw_angstrom * 1.1]
|
|
1858
|
+
indiv_hw_angstrom = hw_angstrom * 0.6
|
|
1859
|
+
elif bond.order == 5:
|
|
1860
|
+
offsets = [0.0]
|
|
1861
|
+
indiv_hw_angstrom = hw_angstrom
|
|
1862
|
+
else:
|
|
1863
|
+
offsets = [-hw_angstrom * 2.0, 0.0, hw_angstrom * 2.0]
|
|
1864
|
+
indiv_hw_angstrom = hw_angstrom * 0.5
|
|
1865
|
+
|
|
1866
|
+
if u_len > 1e-6 and u_len <= surf_off_A + surf_off_B:
|
|
1867
|
+
continue
|
|
1868
|
+
|
|
1869
|
+
for o_idx, offset_A in enumerate(offsets):
|
|
1870
|
+
A_offset = A_orig + offset_A * dir_3D + u_hat * surf_off_A
|
|
1871
|
+
B_offset = B_orig + offset_A * dir_3D - u_hat * surf_off_B
|
|
1872
|
+
|
|
1873
|
+
rpA = rot @ A_offset
|
|
1874
|
+
rpB = rot @ B_offset
|
|
1875
|
+
|
|
1876
|
+
zA_factor = CAMERA_Z / (CAMERA_Z - rpA[2]) if (CAMERA_Z - rpA[2]) != 0 else 1.0
|
|
1877
|
+
zB_factor = CAMERA_Z / (CAMERA_Z - rpB[2]) if (CAMERA_Z - rpB[2]) != 0 else 1.0
|
|
1878
|
+
|
|
1879
|
+
ax, ay, az = cx + rpA[0]*scale*zA_factor, cy - rpA[1]*scale*zA_factor, rpA[2]
|
|
1880
|
+
bx, by, bz = cx + rpB[0]*scale*zB_factor, cy - rpB[1]*scale*zB_factor, rpB[2]
|
|
1881
|
+
|
|
1882
|
+
avg_z_factor = (zA_factor + zB_factor) / 2.0
|
|
1883
|
+
# Safety: Ensure width is positive and finite
|
|
1884
|
+
indiv_hw_px = max(0.1, min(100.0, indiv_hw_angstrom * scale * avg_z_factor))
|
|
1885
|
+
if not math.isfinite(indiv_hw_px): indiv_hw_px = 1.0
|
|
1886
|
+
|
|
1887
|
+
bdx = bx - ax
|
|
1888
|
+
bdy = by - ay
|
|
1889
|
+
bond_len_2d = math.hypot(bdx, bdy)
|
|
1890
|
+
if bond_len_2d < 0.01:
|
|
1891
|
+
continue
|
|
1892
|
+
ux_bond = bdx / bond_len_2d
|
|
1893
|
+
uy_bond = bdy / bond_len_2d
|
|
1894
|
+
px = -uy_bond
|
|
1895
|
+
py = ux_bond
|
|
1896
|
+
|
|
1897
|
+
if bond_style in ("grey", "unicolor"):
|
|
1898
|
+
base = bond_color
|
|
1899
|
+
dark = auto_dark(bond_color)
|
|
1900
|
+
z_sort = (orig_az + orig_bz) / 2.0
|
|
1901
|
+
pts = ((ax, ay), (bx, by))
|
|
1902
|
+
b_id = f"b_{bi}_{o_idx}_{prefix}"
|
|
1903
|
+
draw_list.append((z_sort, 0, ("bond_half", pts, px, py, indiv_hw_px, b_id, base, dark)))
|
|
1904
|
+
elif bond_style in ("match", "splitted"):
|
|
1905
|
+
keyA = _atom_custom_keys.get(ai, mol.atoms[ai].element)
|
|
1906
|
+
keyB = _atom_custom_keys.get(aj, mol.atoms[aj].element)
|
|
1907
|
+
base_A = base_colors.get(keyA, DEFAULT_BASE)
|
|
1908
|
+
base_B = base_colors.get(keyB, DEFAULT_BASE)
|
|
1909
|
+
dark_A = dark_colors.get(keyA, DEFAULT_DARK)
|
|
1910
|
+
dark_B = dark_colors.get(keyB, DEFAULT_DARK)
|
|
1911
|
+
z_sort = (orig_az + orig_bz) / 2.0
|
|
1912
|
+
overlap = 0.04
|
|
1913
|
+
# A-side half (extends slightly past midpoint)
|
|
1914
|
+
tA0, tA1 = 0.0, 0.5 + overlap * 0.5
|
|
1915
|
+
rpA_A = rpA * (1.0 - tA0) + rpB * tA0
|
|
1916
|
+
rpB_A = rpA * (1.0 - tA1) + rpB * tA1
|
|
1917
|
+
zA_A = CAMERA_Z / max(1e-6, CAMERA_Z - rpA_A[2]) if rpA_A[2] < CAMERA_Z else CAMERA_Z / 1e-6
|
|
1918
|
+
zB_A = CAMERA_Z / max(1e-6, CAMERA_Z - rpB_A[2]) if rpB_A[2] < CAMERA_Z else CAMERA_Z / 1e-6
|
|
1919
|
+
sAx = cx + rpA_A[0] * scale * zA_A; sAy = cy - rpA_A[1] * scale * zA_A
|
|
1920
|
+
eAx = cx + rpB_A[0] * scale * zB_A; eAy = cy - rpB_A[1] * scale * zB_A
|
|
1921
|
+
seg_bdx = eAx - sAx; seg_bdy = eAy - sAy
|
|
1922
|
+
seg_len = math.hypot(seg_bdx, seg_bdy)
|
|
1923
|
+
if seg_len >= 0.01:
|
|
1924
|
+
ux = seg_bdx / seg_len; uy = seg_bdy / seg_len
|
|
1925
|
+
ppx, ppy = -uy, ux
|
|
1926
|
+
avg_z = (zA_A + zB_A) * 0.5
|
|
1927
|
+
hw = max(0.1, min(100.0, indiv_hw_angstrom * scale * avg_z))
|
|
1928
|
+
if not math.isfinite(hw): hw = 1.0
|
|
1929
|
+
draw_list.append((z_sort, 0, ("bond_half", ((sAx,sAy),(eAx,eAy)), ppx, ppy, hw, f"b_{bi}_{o_idx}_A_{prefix}", base_A, dark_A)))
|
|
1930
|
+
# B-side half (starts slightly before midpoint)
|
|
1931
|
+
tB0, tB1 = 0.5 - overlap * 0.5, 1.0
|
|
1932
|
+
rpA_B = rpA * (1.0 - tB0) + rpB * tB0
|
|
1933
|
+
rpB_B = rpA * (1.0 - tB1) + rpB * tB1
|
|
1934
|
+
zA_B = CAMERA_Z / max(1e-6, CAMERA_Z - rpA_B[2]) if rpA_B[2] < CAMERA_Z else CAMERA_Z / 1e-6
|
|
1935
|
+
zB_B = CAMERA_Z / max(1e-6, CAMERA_Z - rpB_B[2]) if rpB_B[2] < CAMERA_Z else CAMERA_Z / 1e-6
|
|
1936
|
+
sBx = cx + rpA_B[0] * scale * zA_B; sBy = cy - rpA_B[1] * scale * zA_B
|
|
1937
|
+
eBx = cx + rpB_B[0] * scale * zB_B; eBy = cy - rpB_B[1] * scale * zB_B
|
|
1938
|
+
seg_bdx = eBx - sBx; seg_bdy = eBy - sBy
|
|
1939
|
+
seg_len = math.hypot(seg_bdx, seg_bdy)
|
|
1940
|
+
if seg_len >= 0.01:
|
|
1941
|
+
ux = seg_bdx / seg_len; uy = seg_bdy / seg_len
|
|
1942
|
+
ppx, ppy = -uy, ux
|
|
1943
|
+
avg_z = (zA_B + zB_B) * 0.5
|
|
1944
|
+
hw = max(0.1, min(100.0, indiv_hw_angstrom * scale * avg_z))
|
|
1945
|
+
if not math.isfinite(hw): hw = 1.0
|
|
1946
|
+
draw_list.append((z_sort, 0, ("bond_half", ((sBx,sBy),(eBx,eBy)), ppx, ppy, hw, f"b_{bi}_{o_idx}_B_{prefix}", base_B, dark_B)))
|
|
1947
|
+
else:
|
|
1948
|
+
keyA = _atom_custom_keys.get(ai, mol.atoms[ai].element)
|
|
1949
|
+
keyB = _atom_custom_keys.get(aj, mol.atoms[aj].element)
|
|
1950
|
+
base_A = base_colors.get(keyA, DEFAULT_BASE)
|
|
1951
|
+
base_B = base_colors.get(keyB, DEFAULT_BASE)
|
|
1952
|
+
dark_A = dark_colors.get(mol.atoms[ai].element, DEFAULT_DARK)
|
|
1953
|
+
dark_B = dark_colors.get(mol.atoms[aj].element, DEFAULT_DARK)
|
|
1954
|
+
z_sort = (orig_az + orig_bz) / 2.0
|
|
1955
|
+
pts = ((ax, ay), (bx, by))
|
|
1956
|
+
b_id = f"b_{bi}_{o_idx}_{prefix}"
|
|
1957
|
+
draw_list.append((z_sort, 0, ("bond_gradient", pts, px, py, indiv_hw_px, b_id, base_A, dark_A, base_B, dark_B)))
|
|
1958
|
+
|
|
1959
|
+
for idx,atom in enumerate(mol.atoms):
|
|
1960
|
+
ax,ay,az,ar = proj[idx]
|
|
1961
|
+
key = _atom_custom_keys.get(idx, atom.element)
|
|
1962
|
+
gid = ensure_grad(key)
|
|
1963
|
+
base = base_colors.get(key, DEFAULT_BASE)
|
|
1964
|
+
dark = dark_colors.get(key, DEFAULT_DARK)
|
|
1965
|
+
is_sel = selected_indices and idx in selected_indices
|
|
1966
|
+
draw_list.append((az, 1, ("atom",ax,ay,ar,gid,base,dark,atom.element,is_sel)))
|
|
1967
|
+
|
|
1968
|
+
# Add vectors (e.g. vibrational displacements)
|
|
1969
|
+
if vectors:
|
|
1970
|
+
# Vector format: (atom_idx, dx, dy, dz, color)
|
|
1971
|
+
for ai, vx, vy, vz, vcol in vectors:
|
|
1972
|
+
A_orig = centered[ai]
|
|
1973
|
+
if animation_amplitude > 0 and active_vectors is not None:
|
|
1974
|
+
A_orig = A_orig + active_vectors[ai] * animation_amplitude * math.sin(animation_phase)
|
|
1975
|
+
B_orig = A_orig + np.array([vx, vy, vz])
|
|
1976
|
+
|
|
1977
|
+
rpA = rot @ A_orig
|
|
1978
|
+
rpB = rot @ B_orig
|
|
1979
|
+
|
|
1980
|
+
zA_factor = CAMERA_Z / (CAMERA_Z - rpA[2]) if (CAMERA_Z - rpA[2]) != 0 else 1.0
|
|
1981
|
+
zB_factor = CAMERA_Z / (CAMERA_Z - rpB[2]) if (CAMERA_Z - rpB[2]) != 0 else 1.0
|
|
1982
|
+
|
|
1983
|
+
ax, ay, az = cx + rpA[0]*scale*zA_factor, cy - rpA[1]*scale*zA_factor, rpA[2]
|
|
1984
|
+
bx, by, bz = cx + rpB[0]*scale*zB_factor, cy - rpB[1]*scale*zB_factor, rpB[2]
|
|
1985
|
+
|
|
1986
|
+
# Simple arrow: line + triangle at the tip
|
|
1987
|
+
draw_list.append((max(az, bz), 2, ("vector", ax, ay, bx, by, vcol)))
|
|
1988
|
+
|
|
1989
|
+
draw_list.sort(key=lambda x:(x[0],x[1]))
|
|
1990
|
+
|
|
1991
|
+
molecule_group = dwg.g(id="molecule")
|
|
1992
|
+
for _,__,item in draw_list:
|
|
1993
|
+
kind = item[0]
|
|
1994
|
+
if kind == "bond_half":
|
|
1995
|
+
_, pts, px, py, indiv_hw, b_id, base, dark = item
|
|
1996
|
+
|
|
1997
|
+
A = px * Lx + py * Ly
|
|
1998
|
+
I_max = math.hypot(A, Lz)
|
|
1999
|
+
if I_max == 0: I_max = 1e-6
|
|
2000
|
+
|
|
2001
|
+
def get_color_from_ratio(r: float) -> str:
|
|
2002
|
+
d = 1.0 - r
|
|
2003
|
+
li = lighting_intensity
|
|
2004
|
+
stops = compute_gradient_stops(
|
|
2005
|
+
base, dark, li, roughness,
|
|
2006
|
+
edge_fn=lambda b, d, l: interpolate_color(b, d, l))
|
|
2007
|
+
if d <= 0: return stops[0][1]
|
|
2008
|
+
if d >= 1: return stops[-1][1]
|
|
2009
|
+
for i in range(len(stops)-1):
|
|
2010
|
+
if stops[i][0] <= d <= stops[i+1][0]:
|
|
2011
|
+
t_ratio = (d - stops[i][0]) / (stops[i+1][0] - stops[i][0])
|
|
2012
|
+
return interpolate_color(stops[i][1], stops[i+1][1], t_ratio)
|
|
2013
|
+
return dark
|
|
2014
|
+
|
|
2015
|
+
x0, y0 = pts[0]
|
|
2016
|
+
x1, y1 = pts[1]
|
|
2017
|
+
hw = indiv_hw
|
|
2018
|
+
|
|
2019
|
+
dx = x1 - x0
|
|
2020
|
+
dy = y1 - y0
|
|
2021
|
+
length = math.hypot(dx, dy)
|
|
2022
|
+
if length > 0:
|
|
2023
|
+
ux = dx / length
|
|
2024
|
+
uy = dy / length
|
|
2025
|
+
x0_r = x0 - ux * hw
|
|
2026
|
+
y0_r = y0 - uy * hw
|
|
2027
|
+
x1_r = x1 + ux * hw
|
|
2028
|
+
y1_r = y1 + uy * hw
|
|
2029
|
+
else:
|
|
2030
|
+
x0_r, y0_r = x0, y0
|
|
2031
|
+
x1_r, y1_r = x1, y1
|
|
2032
|
+
|
|
2033
|
+
rw = math.hypot(x1_r - x0_r, y1_r - y0_r)
|
|
2034
|
+
if rw < 0.01:
|
|
2035
|
+
continue
|
|
2036
|
+
rx = (x0_r + x1_r) / 2
|
|
2037
|
+
ry = (y0_r + y1_r) / 2
|
|
2038
|
+
angle = math.degrees(math.atan2(-px, py))
|
|
2039
|
+
|
|
2040
|
+
g = dwg.linearGradient(
|
|
2041
|
+
id=b_id,
|
|
2042
|
+
start=(0.5, 1), end=(0.5, 0),
|
|
2043
|
+
gradientUnits="objectBoundingBox"
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
for i in range(11):
|
|
2047
|
+
v = i / 10.0
|
|
2048
|
+
s = 1.0 - 2.0 * v
|
|
2049
|
+
intensity = max(0.0, s * A + math.sqrt(max(0.0, 1.0 - s**2)) * Lz)
|
|
2050
|
+
ratio = intensity / I_max
|
|
2051
|
+
g.add_stop_color(f"{v*100:.1f}%", get_color_from_ratio(ratio), 1.0)
|
|
2052
|
+
defs.add(g)
|
|
2053
|
+
|
|
2054
|
+
molecule_group.add(dwg.rect(
|
|
2055
|
+
insert=(-rw / 2, -hw),
|
|
2056
|
+
size=(rw, hw * 2),
|
|
2057
|
+
rx=hw, ry=hw,
|
|
2058
|
+
fill=f"url(#{b_id})",
|
|
2059
|
+
stroke="none",
|
|
2060
|
+
transform=f"translate({rx:.1f},{ry:.1f}) rotate({angle:.1f})"
|
|
2061
|
+
))
|
|
2062
|
+
elif kind == "bond_gradient":
|
|
2063
|
+
_, pts, px, py, indiv_hw, b_id, base_A, dark_A, base_B, dark_B = item
|
|
2064
|
+
|
|
2065
|
+
def mat_color(base, dark, ratio):
|
|
2066
|
+
d = 1.0 - ratio
|
|
2067
|
+
li = lighting_intensity
|
|
2068
|
+
stops = compute_gradient_stops(
|
|
2069
|
+
base, dark, li, roughness,
|
|
2070
|
+
edge_fn=lambda b, d, l: interpolate_color(b, d, l))
|
|
2071
|
+
if d <= 0: return stops[0][1]
|
|
2072
|
+
if d >= 1: return stops[-1][1]
|
|
2073
|
+
for i in range(len(stops)-1):
|
|
2074
|
+
if stops[i][0] <= d <= stops[i+1][0]:
|
|
2075
|
+
return interpolate_color(stops[i][1], stops[i+1][1], (d - stops[i][0]) / (stops[i+1][0] - stops[i][0]))
|
|
2076
|
+
return dark
|
|
2077
|
+
|
|
2078
|
+
x0, y0 = pts[0]; x1, y1 = pts[1]
|
|
2079
|
+
hw = indiv_hw
|
|
2080
|
+
dx = x1 - x0; dy = y1 - y0
|
|
2081
|
+
length = math.hypot(dx, dy)
|
|
2082
|
+
if length > 0:
|
|
2083
|
+
ux = dx / length; uy = dy / length
|
|
2084
|
+
x0_r = x0 - ux * hw; y0_r = y0 - uy * hw
|
|
2085
|
+
x1_r = x1 + ux * hw; y1_r = y1 + uy * hw
|
|
2086
|
+
else:
|
|
2087
|
+
x0_r, y0_r = x0, y0; x1_r, y1_r = x1, y1
|
|
2088
|
+
rw = math.hypot(x1_r - x0_r, y1_r - y0_r)
|
|
2089
|
+
if rw < 0.01: continue
|
|
2090
|
+
rx = (x0_r + x1_r) / 2; ry = (y0_r + y1_r) / 2
|
|
2091
|
+
angle = math.degrees(math.atan2(-px, py))
|
|
2092
|
+
|
|
2093
|
+
A_sh = px * Lx + py * Ly
|
|
2094
|
+
I_max = math.hypot(A_sh, Lz)
|
|
2095
|
+
if I_max == 0: I_max = 1e-6
|
|
2096
|
+
|
|
2097
|
+
# Base layer: gradient along bond axis (A→B at peak brightness)
|
|
2098
|
+
g = dwg.linearGradient(
|
|
2099
|
+
id=b_id, start=(0, 0.5), end=(1, 0.5),
|
|
2100
|
+
gradientUnits="objectBoundingBox"
|
|
2101
|
+
)
|
|
2102
|
+
num_stops = 21
|
|
2103
|
+
for i in range(num_stops):
|
|
2104
|
+
t = i / (num_stops - 1)
|
|
2105
|
+
ca = mat_color(base_A, dark_A, 1.0)
|
|
2106
|
+
cb = mat_color(base_B, dark_B, 1.0)
|
|
2107
|
+
g.add_stop_color(f"{t*100:.1f}%", interpolate_color(ca, cb, t), 1.0)
|
|
2108
|
+
defs.add(g)
|
|
2109
|
+
|
|
2110
|
+
molecule_group.add(dwg.rect(
|
|
2111
|
+
insert=(-rw / 2, -hw), size=(rw, hw * 2),
|
|
2112
|
+
rx=hw, ry=hw, fill=f"url(#{b_id})", stroke="none",
|
|
2113
|
+
transform=f"translate({rx:.1f},{ry:.1f}) rotate({angle:.1f})"
|
|
2114
|
+
))
|
|
2115
|
+
|
|
2116
|
+
# Overlay: perpendicular 3D shading (darken edges)
|
|
2117
|
+
sh_id = f"{b_id}_sh"
|
|
2118
|
+
sh_g = dwg.linearGradient(
|
|
2119
|
+
id=sh_id, start=(0.5, 1), end=(0.5, 0),
|
|
2120
|
+
gradientUnits="objectBoundingBox"
|
|
2121
|
+
)
|
|
2122
|
+
for i in range(11):
|
|
2123
|
+
v = i / 10.0
|
|
2124
|
+
s = 1.0 - 2.0 * v
|
|
2125
|
+
intensity = max(0.0, s * A_sh + math.sqrt(max(0.0, 1.0 - s**2)) * Lz)
|
|
2126
|
+
ratio = intensity / I_max
|
|
2127
|
+
sh_g.add_stop_color(f"{v*100:.1f}%", "#000000", (1.0 - ratio) * 0.75 * lighting_intensity)
|
|
2128
|
+
defs.add(sh_g)
|
|
2129
|
+
|
|
2130
|
+
molecule_group.add(dwg.rect(
|
|
2131
|
+
insert=(-rw / 2, -hw), size=(rw, hw * 2),
|
|
2132
|
+
rx=hw, ry=hw, fill=f"url(#{sh_id})", stroke="none",
|
|
2133
|
+
transform=f"translate({rx:.1f},{ry:.1f}) rotate({angle:.1f})"
|
|
2134
|
+
))
|
|
2135
|
+
elif kind == "atom":
|
|
2136
|
+
_, ax, ay, ar, gid, base, dark, elem, is_sel = item
|
|
2137
|
+
if is_sel:
|
|
2138
|
+
molecule_group.add(dwg.circle(center=(ax,ay), r=ar*1.35, fill="none", stroke="#00aaff", stroke_width=2.0, opacity="0.45"))
|
|
2139
|
+
molecule_group.add(dwg.circle(center=(ax,ay), r=ar*1.15, fill="none", stroke="#44ddff", stroke_width=1.2, opacity="0.75"))
|
|
2140
|
+
if atom_border_mode == "none":
|
|
2141
|
+
pass
|
|
2142
|
+
elif atom_border_mode == "constant":
|
|
2143
|
+
molecule_group.add(dwg.circle(center=(ax,ay),r=ar+atom_border_width,fill=dark,stroke="none"))
|
|
2144
|
+
else: # "scaled"
|
|
2145
|
+
molecule_group.add(dwg.circle(center=(ax,ay),r=ar*atom_border_scale,fill=dark,stroke="none"))
|
|
2146
|
+
molecule_group.add(dwg.circle(center=(ax,ay),r=ar,fill=f"url(#{gid})",stroke="none"))
|
|
2147
|
+
elif kind == "vector":
|
|
2148
|
+
_, x0, y0, x1, y1, col = item
|
|
2149
|
+
# Main line
|
|
2150
|
+
molecule_group.add(dwg.line(start=(x0, y0), end=(x1, y1), stroke=col, stroke_width=2.5, stroke_linecap="round"))
|
|
2151
|
+
# Arrowhead
|
|
2152
|
+
dx, dy = x1-x0, y1-y0
|
|
2153
|
+
L = math.hypot(dx, dy)
|
|
2154
|
+
if L > 1e-3:
|
|
2155
|
+
ux, uy = dx/L, dy/L
|
|
2156
|
+
px, py = -uy, ux
|
|
2157
|
+
# Points for triangle
|
|
2158
|
+
p1 = (x1, y1)
|
|
2159
|
+
p2 = (x1 - ux*6 + px*3.5, y1 - uy*6 + py*3.5)
|
|
2160
|
+
p3 = (x1 - ux*6 - px*3.5, y1 - uy*6 - py*3.5)
|
|
2161
|
+
molecule_group.add(dwg.polygon(points=[p1, p2, p3], fill=col))
|
|
2162
|
+
|
|
2163
|
+
molecule_group['id'] = f"mol_{prefix}"
|
|
2164
|
+
dwg.add(molecule_group)
|
|
2165
|
+
|
|
2166
|
+
if not export_mode:
|
|
2167
|
+
dwg.add(dwg.text(
|
|
2168
|
+
mol.name, insert=(16,26),
|
|
2169
|
+
font_size=15,font_family="monospace",
|
|
2170
|
+
fill="#88ccff",opacity=0.75,
|
|
2171
|
+
))
|
|
2172
|
+
|
|
2173
|
+
if show_axes and not export_mode:
|
|
2174
|
+
_ax_rot = axes_rot_override if axes_rot_override is not None else rot
|
|
2175
|
+
axis_len = 17.0
|
|
2176
|
+
margin = 30
|
|
2177
|
+
_ax_pos = axes_position.lower().replace(" ", "-")
|
|
2178
|
+
if _ax_pos == "bottom-right":
|
|
2179
|
+
origin_x, origin_y = canvas_w - margin, canvas_h - margin
|
|
2180
|
+
elif _ax_pos == "top-left":
|
|
2181
|
+
origin_x, origin_y = margin, margin
|
|
2182
|
+
elif _ax_pos == "top-right":
|
|
2183
|
+
origin_x, origin_y = canvas_w - margin, margin
|
|
2184
|
+
else:
|
|
2185
|
+
origin_x, origin_y = margin, canvas_h - margin
|
|
2186
|
+
for vec, col, lbl in [
|
|
2187
|
+
(np.array([1,0,0]), "#ff4444", "X"),
|
|
2188
|
+
(np.array([0,1,0]), "#44cc44", "Y"),
|
|
2189
|
+
(np.array([0,0,1]), "#4488ff", "Z"),
|
|
2190
|
+
]:
|
|
2191
|
+
rv = _ax_rot @ vec
|
|
2192
|
+
ex = origin_x + rv[0] * axis_len
|
|
2193
|
+
ey = origin_y - rv[1] * axis_len
|
|
2194
|
+
dwg.add(dwg.line(start=(origin_x, origin_y), end=(ex, ey),
|
|
2195
|
+
stroke="#222222", stroke_width=1.0, stroke_linecap="round"))
|
|
2196
|
+
tx = origin_x + rv[0] * (axis_len + 6) - 3
|
|
2197
|
+
ty = origin_y - rv[1] * (axis_len + 6) + 3
|
|
2198
|
+
dwg.add(dwg.text(lbl, insert=(tx, ty),
|
|
2199
|
+
font_size=8, font_family="monospace",
|
|
2200
|
+
fill=col, font_weight="bold"))
|
|
2201
|
+
|
|
2202
|
+
if show_principal_axes and not export_mode and len(mol.atoms) >= 2:
|
|
2203
|
+
coords = np.array([[a.x, a.y, a.z] for a in mol.atoms])
|
|
2204
|
+
masses = np.array([ATOMIC_MASSES.get(a.element, 0.0) for a in mol.atoms])
|
|
2205
|
+
_, eigvecs = strudel.diagonalize_I_tensor(coords, masses)
|
|
2206
|
+
axis_len = 17.0
|
|
2207
|
+
margin = 30
|
|
2208
|
+
_pa_pos = principal_axes_position.lower().replace(" ", "-")
|
|
2209
|
+
if _pa_pos == "bottom-right":
|
|
2210
|
+
origin_x, origin_y = canvas_w - margin, canvas_h - margin
|
|
2211
|
+
elif _pa_pos == "top-left":
|
|
2212
|
+
origin_x, origin_y = margin, margin
|
|
2213
|
+
elif _pa_pos == "top-right":
|
|
2214
|
+
origin_x, origin_y = canvas_w - margin, margin
|
|
2215
|
+
else:
|
|
2216
|
+
origin_x, origin_y = margin, canvas_h - margin
|
|
2217
|
+
for i, col, lbl in [
|
|
2218
|
+
(0, "#ff8800", "a"),
|
|
2219
|
+
(1, "#cc44cc", "b"),
|
|
2220
|
+
(2, "#00bbbb", "c"),
|
|
2221
|
+
]:
|
|
2222
|
+
vec = eigvecs[:, i]
|
|
2223
|
+
rv = rot @ vec
|
|
2224
|
+
ex = origin_x + rv[0] * axis_len
|
|
2225
|
+
ey = origin_y - rv[1] * axis_len
|
|
2226
|
+
dwg.add(dwg.line(start=(origin_x, origin_y), end=(ex, ey),
|
|
2227
|
+
stroke="#222222", stroke_width=1.0, stroke_linecap="round",
|
|
2228
|
+
stroke_dasharray="3,2"))
|
|
2229
|
+
tx = origin_x + rv[0] * (axis_len + 6) - 3
|
|
2230
|
+
ty = origin_y - rv[1] * (axis_len + 6) + 3
|
|
2231
|
+
dwg.add(dwg.text(lbl, insert=(tx, ty),
|
|
2232
|
+
font_size=8, font_family="monospace",
|
|
2233
|
+
fill=col, font_weight="bold"))
|
|
2234
|
+
|
|
2235
|
+
dwg.save()
|
|
2236
|
+
return output_path
|