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/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