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/gui.py
ADDED
|
@@ -0,0 +1,4834 @@
|
|
|
1
|
+
"""
|
|
2
|
+
molvector_gui.py — Interactive 3D Molecule Viewer
|
|
3
|
+
==============================================
|
|
4
|
+
PyQt6 GUI with full menu bar, appearance controls, and atom colour editor.
|
|
5
|
+
|
|
6
|
+
Default controls:
|
|
7
|
+
Left-drag Rotate molecule
|
|
8
|
+
Right-drag Pan
|
|
9
|
+
Scroll Zoom
|
|
10
|
+
|
|
11
|
+
Build mode (B):
|
|
12
|
+
Left-click Add atom
|
|
13
|
+
Drag Create bond
|
|
14
|
+
Click bond Change bond order
|
|
15
|
+
|
|
16
|
+
Selection mode (S):
|
|
17
|
+
Click Select atom
|
|
18
|
+
Drag Rectangle-select
|
|
19
|
+
|
|
20
|
+
Align mode (A):
|
|
21
|
+
Click bond Align molecule vertically
|
|
22
|
+
Shift+click bond Align molecule horizontally
|
|
23
|
+
|
|
24
|
+
Menus:
|
|
25
|
+
File Open / Save As / Export SVG / Export View / Quick SVG Export / Quit
|
|
26
|
+
Edit Settings / Style / Shortcuts / Info / Edit Charge / InChI / Selection Mode / Build Mode / Align Mode
|
|
27
|
+
View Reset View / Preset orientations / Overlay Settings
|
|
28
|
+
Calculations Generate G16 Input / Calculate Rotational Constants / Calculate Dipole Moment / Calculation Results
|
|
29
|
+
Build Clean / Undo / Redo / Optimize / Disable Bond Order
|
|
30
|
+
Help Open Test Molecule / About
|
|
31
|
+
|
|
32
|
+
Note: On Windows/Linux use Ctrl for shortcuts; on macOS use Cmd.
|
|
33
|
+
|
|
34
|
+
Dependencies:
|
|
35
|
+
pip install PyQt6 numpy svgwrite
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import sys, os, math, json, tempfile, platform
|
|
39
|
+
from typing import List, Tuple, Optional, Dict
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from PyQt6.QtWidgets import (
|
|
43
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
44
|
+
QLabel, QFileDialog, QSlider, QStatusBar, QFrame, QSizePolicy,
|
|
45
|
+
QGroupBox, QMessageBox, QDialog, QDialogButtonBox, QFormLayout,
|
|
46
|
+
QSpinBox, QDoubleSpinBox, QColorDialog, QPushButton, QGridLayout,
|
|
47
|
+
QScrollArea, QToolBar, QMenu, QCheckBox, QTableWidget, QTableWidgetItem,
|
|
48
|
+
QHeaderView, QTabWidget, QComboBox, QPlainTextEdit, QLineEdit,
|
|
49
|
+
QButtonGroup, QRadioButton, QKeySequenceEdit, QToolButton,
|
|
50
|
+
)
|
|
51
|
+
from PyQt6.QtSvgWidgets import QSvgWidget
|
|
52
|
+
from PyQt6.QtSvg import QSvgRenderer
|
|
53
|
+
from PyQt6.QtCore import Qt, QByteArray, QPoint, QPointF, pyqtSignal, QTimer, QSize, QRect, QRectF, QUrl, QMimeData, QEvent
|
|
54
|
+
from PyQt6.QtGui import QAction, QActionGroup, QColor, QPalette, QFont, QCursor, QIcon, QPixmap, QImage, QPainter, QPdfWriter, QPageSize, QKeySequence, QDesktopServices
|
|
55
|
+
from PyQt6.QtGui import QDrag
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
59
|
+
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
|
|
60
|
+
from matplotlib.figure import Figure
|
|
61
|
+
HAS_MPL = True
|
|
62
|
+
except ImportError:
|
|
63
|
+
HAS_MPL = False
|
|
64
|
+
|
|
65
|
+
# ── renderer / parsers ───────────────────────────────────────────────────────
|
|
66
|
+
from molvector.render import (
|
|
67
|
+
parse_xyz, parse_gaussian, parse_gaussian_log, parse_pdb, parse_mol,
|
|
68
|
+
infer_bonds,
|
|
69
|
+
render_molecule, Molecule, CPK_BASE, CPK_DARK, VDW_RADII,
|
|
70
|
+
lighten, darken, hex_to_rgb, rgb_to_hex, auto_dark,
|
|
71
|
+
chemical_formula, molecular_mass, VibrationalMode, ExcitedState,
|
|
72
|
+
save_xyz, save_gaussian_input, save_pdb, save_mol, project_molecule,
|
|
73
|
+
Atom, Bond,
|
|
74
|
+
optimize_geometry, HAS_OPENBABEL, calculate_rotational_constants, calculate_dipole_moment, generate_inchi, check_valence_issues,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ── shortcut modifier prefix ─────────────────────────────────────────────────
|
|
78
|
+
def _mod(s: str) -> str:
|
|
79
|
+
"""Qt already maps 'Ctrl' → ⌘ on macOS; pass through unchanged."""
|
|
80
|
+
return s
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_colored_icon(svg_path: str, color: str, size: int = 22) -> QIcon:
|
|
84
|
+
"""Load an SVG file, replace #000000 fills with color, return QIcon."""
|
|
85
|
+
with open(svg_path, "r", encoding="utf-8") as f:
|
|
86
|
+
svg_content = f.read()
|
|
87
|
+
svg_content = svg_content.replace('#000000', color)
|
|
88
|
+
renderer = QSvgRenderer(QByteArray(svg_content.encode("utf-8")))
|
|
89
|
+
default_size = renderer.defaultSize()
|
|
90
|
+
if default_size.isValid() and default_size.width() > 0 and default_size.height() > 0:
|
|
91
|
+
aspect = default_size.width() / default_size.height()
|
|
92
|
+
if aspect >= 1.0:
|
|
93
|
+
w, h = size, int(size / aspect)
|
|
94
|
+
else:
|
|
95
|
+
w, h = int(size * aspect), size
|
|
96
|
+
else:
|
|
97
|
+
w = h = size
|
|
98
|
+
pix = QPixmap(size, size)
|
|
99
|
+
pix.fill(Qt.GlobalColor.transparent)
|
|
100
|
+
painter = QPainter(pix)
|
|
101
|
+
x = (size - w) // 2
|
|
102
|
+
y = (size - h) // 2
|
|
103
|
+
renderer.render(painter, QRectF(x, y, w, h))
|
|
104
|
+
painter.end()
|
|
105
|
+
return QIcon(pix)
|
|
106
|
+
|
|
107
|
+
def set_icons(app, win):
|
|
108
|
+
system = platform.system()
|
|
109
|
+
if system == "Darwin":
|
|
110
|
+
icon_path = os.path.join(os.path.dirname(__file__), "assets", "molvector_macos.png")
|
|
111
|
+
elif system == "Linux":
|
|
112
|
+
icon_path = os.path.join(os.path.dirname(__file__), "assets", "molvector_linux.png")
|
|
113
|
+
elif system == "Windows":
|
|
114
|
+
icon_path = os.path.join(os.path.dirname(__file__), "assets", "molvector_windows.png")
|
|
115
|
+
else:
|
|
116
|
+
icon_path = os.path.join(os.path.dirname(__file__), "assets", "icons", "icon.svg")
|
|
117
|
+
|
|
118
|
+
if not os.path.isfile(icon_path):
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
icon = QIcon(icon_path)
|
|
122
|
+
app.setWindowIcon(icon)
|
|
123
|
+
win.setWindowIcon(icon)
|
|
124
|
+
|
|
125
|
+
def get_safe_filename(name: str) -> str:
|
|
126
|
+
"""C60+ -> C60p, removes special characters."""
|
|
127
|
+
s = name.replace("+", "p").replace("-", "m")
|
|
128
|
+
import re
|
|
129
|
+
return re.sub(r'[^a-zA-Z0-9_-]', '', s)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
# CONSTANTS
|
|
134
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
ELEM_FULL_NAME = {
|
|
137
|
+
"H":"Hydrogen", "D":"Deuterium (2H)", "T":"Tritium (3H)", "He":"Helium", "Li":"Lithium", "Be":"Beryllium","B":"Boron",
|
|
138
|
+
"C":"Carbon", "13C":"Carbon-13", "14C":"Carbon-14", "N":"Nitrogen", "15N":"Nitrogen-15", "O":"Oxygen", "F":"Fluorine", "Ne":"Neon",
|
|
139
|
+
"Na":"Sodium", "Mg":"Magnesium","Al":"Aluminium","Si":"Silicon", "P":"Phosphorus",
|
|
140
|
+
"S":"Sulfur", "Cl":"Chlorine", "Ar":"Argon", "K":"Potassium", "Ca":"Calcium",
|
|
141
|
+
"Sc":"Scandium","Ti":"Titanium", "V":"Vanadium", "Cr":"Chromium", "Mn":"Manganese",
|
|
142
|
+
"Fe":"Iron", "Co":"Cobalt", "Ni":"Nickel", "Cu":"Copper", "Zn":"Zinc",
|
|
143
|
+
"Ga":"Gallium", "Ge":"Germanium","As":"Arsenic", "Se":"Selenium", "Br":"Bromine",
|
|
144
|
+
"Kr":"Krypton", "Rb":"Rubidium", "Sr":"Strontium","Y":"Yttrium", "Zr":"Zirconium",
|
|
145
|
+
"Nb":"Niobium", "Mo":"Molybdenum","Tc":"Technetium","Ru":"Ruthenium","Rh":"Rhodium",
|
|
146
|
+
"Pd":"Palladium","Ag":"Silver", "Cd":"Cadmium", "In":"Indium", "Sn":"Tin",
|
|
147
|
+
"Sb":"Antimony", "Te":"Tellurium","I":"Iodine", "Xe":"Xenon", "Cs":"Caesium",
|
|
148
|
+
"Ba":"Barium", "La":"Lanthanum", "Ce":"Cerium", "Pr":"Praseodymium","Nd":"Neodymium",
|
|
149
|
+
"Pm":"Promethium","Sm":"Samarium", "Eu":"Europium", "Gd":"Gadolinium","Tb":"Terbium",
|
|
150
|
+
"Dy":"Dysprosium","Ho":"Holmium", "Er":"Erbium", "Tm":"Thulium", "Yb":"Ytterbium",
|
|
151
|
+
"Lu":"Lutetium", "Hf":"Hafnium", "Ta":"Tantalum", "W":"Tungsten", "Re":"Rhenium",
|
|
152
|
+
"Os":"Osmium", "Ir":"Iridium", "Pt":"Platinum", "Au":"Gold", "Hg":"Mercury",
|
|
153
|
+
"Tl":"Thallium", "Pb":"Lead", "Bi":"Bismuth", "Po":"Polonium", "At":"Astatine",
|
|
154
|
+
"Rn":"Radon", "Fr":"Francium", "Ra":"Radium",
|
|
155
|
+
"Ac":"Actinium","Th":"Thorium", "Pa":"Protactinium","U":"Uranium",
|
|
156
|
+
"Np":"Neptunium","Pu":"Plutonium","Am":"Americium","Cm":"Curium",
|
|
157
|
+
"Bk":"Berkelium","Cf":"Californium","Es":"Einsteinium","Fm":"Fermium",
|
|
158
|
+
"Md":"Mendelevium","No":"Nobelium","Lr":"Lawrencium",
|
|
159
|
+
"Rf":"Rutherfordium","Db":"Dubnium","Sg":"Seaborgium","Bh":"Bohrium",
|
|
160
|
+
"Hs":"Hassium","Mt":"Meitnerium","Ds":"Darmstadtium","Rg":"Roentgenium",
|
|
161
|
+
"Cn":"Copernicium","Nh":"Nihonium","Fl":"Flerovium","Mc":"Moscovium",
|
|
162
|
+
"Lv":"Livermorium","Ts":"Tennessine","Og":"Oganesson",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Theme Colors
|
|
166
|
+
THEMES = {
|
|
167
|
+
"dark": {
|
|
168
|
+
"DARK_BG": "#0f0f1a",
|
|
169
|
+
"PANEL_BG": "#0d0d18",
|
|
170
|
+
"CARD_BG": "#13131f",
|
|
171
|
+
"BORDER": "#2a2a44",
|
|
172
|
+
"FG": "#ccd6f6",
|
|
173
|
+
"FG_DIM": "#99aacc",
|
|
174
|
+
"ACCENT": "#4488cc",
|
|
175
|
+
"CANVAS": "#0a0a12"
|
|
176
|
+
},
|
|
177
|
+
"light": {
|
|
178
|
+
"DARK_BG": "#f0f2f5",
|
|
179
|
+
"PANEL_BG": "#e4e6eb",
|
|
180
|
+
"CARD_BG": "#ffffff",
|
|
181
|
+
"BORDER": "#ced4da",
|
|
182
|
+
"FG": "#1c1e21",
|
|
183
|
+
"FG_DIM": "#65676b",
|
|
184
|
+
"ACCENT": "#007bff",
|
|
185
|
+
"CANVAS": "#ffffff"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def get_stylesheet(theme_name: str) -> str:
|
|
190
|
+
t = THEMES[theme_name]
|
|
191
|
+
return f"""
|
|
192
|
+
QMainWindow, QDialog {{ background:{t['DARK_BG']}; }}
|
|
193
|
+
QWidget {{ color:{t['FG']}; }}
|
|
194
|
+
QMenuBar {{
|
|
195
|
+
background:{t['CARD_BG']}; color:{t['FG']};
|
|
196
|
+
border-bottom:1px solid {t['BORDER']}; padding:2px 4px;
|
|
197
|
+
}}
|
|
198
|
+
QMenuBar::item:selected {{ background:{t['BORDER']}; border-radius:3px; }}
|
|
199
|
+
QMenu {{
|
|
200
|
+
background:{t['CARD_BG']}; color:{t['FG']};
|
|
201
|
+
border:1px solid {t['BORDER']}; border-radius:6px;
|
|
202
|
+
padding:4px;
|
|
203
|
+
}}
|
|
204
|
+
QMenu::item {{ padding:5px 24px 5px 10px; border-radius:4px; }}
|
|
205
|
+
QMenu::item:selected {{ background:{t['ACCENT']}; color:#fff; }}
|
|
206
|
+
QMenu::separator {{ height:1px; background:{t['BORDER']}; margin:3px 6px; }}
|
|
207
|
+
QToolBar {{
|
|
208
|
+
background:{t['CARD_BG']}; border-bottom:1px solid {t['BORDER']};
|
|
209
|
+
spacing:4px; padding:3px 8px;
|
|
210
|
+
}}
|
|
211
|
+
QToolBar QToolButton {{
|
|
212
|
+
background:transparent; color:{t['FG']};
|
|
213
|
+
border:1px solid transparent; border-radius:4px; padding:4px 8px;
|
|
214
|
+
}}
|
|
215
|
+
QToolBar QToolButton:hover {{ background:{t['BORDER']}; }}
|
|
216
|
+
QToolBar QToolButton:checked {{
|
|
217
|
+
background:{t['ACCENT']}; color:white;
|
|
218
|
+
border:1px solid {t['ACCENT']};
|
|
219
|
+
}}
|
|
220
|
+
QPushButton {{
|
|
221
|
+
background:{t['CARD_BG']}; color:{t['FG']};
|
|
222
|
+
border:1px solid {t['BORDER']}; border-radius:5px;
|
|
223
|
+
padding:5px 14px; font-size:12px;
|
|
224
|
+
}}
|
|
225
|
+
QPushButton:hover {{ background:{t['BORDER']}; border-color:{t['ACCENT']}; }}
|
|
226
|
+
QToolTip {{
|
|
227
|
+
background: {t['CARD_BG']}; color: {t['FG']};
|
|
228
|
+
border: 1px solid {t['ACCENT']}; border-radius: 4px;
|
|
229
|
+
padding: 4px;
|
|
230
|
+
}}
|
|
231
|
+
QPushButton:pressed{{ background:{t['ACCENT']}; }}
|
|
232
|
+
QPushButton#accent {{
|
|
233
|
+
background:#1e4080; border-color:{t['ACCENT']}; color:#fff;
|
|
234
|
+
}}
|
|
235
|
+
QPushButton#accent:hover {{ background:#2255aa; }}
|
|
236
|
+
QPushButton#color_btn {{
|
|
237
|
+
border-radius:4px; min-width:36px; min-height:24px;
|
|
238
|
+
padding:2px; border:2px solid {t['BORDER']};
|
|
239
|
+
}}
|
|
240
|
+
QPushButton#color_btn:hover {{ border-color:{t['ACCENT']}; }}
|
|
241
|
+
QLabel {{ color:{t['FG']}; }}
|
|
242
|
+
QSlider::groove:horizontal {{
|
|
243
|
+
height:4px; background:{t['BORDER']}; border-radius:2px;
|
|
244
|
+
}}
|
|
245
|
+
QSlider::handle:horizontal {{
|
|
246
|
+
background:{t['ACCENT']}; border-radius:6px;
|
|
247
|
+
width:14px; height:14px; margin:-5px 0;
|
|
248
|
+
}}
|
|
249
|
+
QSlider::sub-page:horizontal {{ background:{t['ACCENT']}; border-radius:2px; }}
|
|
250
|
+
QDoubleSpinBox, QSpinBox, QComboBox {{
|
|
251
|
+
background:{t['DARK_BG']}; border:1px solid {t['BORDER']};
|
|
252
|
+
border-radius:4px; padding:3px 6px; color:{t['FG']};
|
|
253
|
+
}}
|
|
254
|
+
QComboBox::drop-down {{
|
|
255
|
+
border:none; width:20px;
|
|
256
|
+
}}
|
|
257
|
+
QComboBox QAbstractItemView {{
|
|
258
|
+
background:{t['CARD_BG']}; color:{t['FG']};
|
|
259
|
+
border:1px solid {t['BORDER']}; selection-background-color:{t['ACCENT']};
|
|
260
|
+
selection-color:#fff; outline:none;
|
|
261
|
+
}}
|
|
262
|
+
QCheckBox {{
|
|
263
|
+
spacing:6px; color:{t['FG']};
|
|
264
|
+
}}
|
|
265
|
+
QCheckBox::indicator {{
|
|
266
|
+
width:14px; height:14px; border:1px solid {t['BORDER']};
|
|
267
|
+
border-radius:3px; background:{t['DARK_BG']};
|
|
268
|
+
}}
|
|
269
|
+
QCheckBox::indicator:checked {{
|
|
270
|
+
background:{t['ACCENT']}; border-color:{t['ACCENT']};
|
|
271
|
+
}}
|
|
272
|
+
QGroupBox {{
|
|
273
|
+
color:{t['ACCENT']}; border:1px solid {t['BORDER']};
|
|
274
|
+
border-radius:6px; margin-top:10px; font-size:11px;
|
|
275
|
+
}}
|
|
276
|
+
QGroupBox::title {{ subcontrol-origin:margin; left:8px; padding:0 4px; }}
|
|
277
|
+
QStatusBar {{ background:{t['PANEL_BG']}; color:{t['FG_DIM']}; font-size:11px; }}
|
|
278
|
+
QScrollArea {{ background:transparent; border:none; }}
|
|
279
|
+
QScrollArea > QWidget > QWidget {{ background:transparent; }}
|
|
280
|
+
QDialogButtonBox QPushButton {{ min-width:80px; }}
|
|
281
|
+
QTabWidget::pane {{ border: 1px solid {t['BORDER']}; background: transparent; }}
|
|
282
|
+
QTabBar::tab {{
|
|
283
|
+
background: {t['DARK_BG']}; border: 1px solid {t['BORDER']};
|
|
284
|
+
padding: 5px 12px; border-top-left-radius: 4px; border-top-right-radius: 4px;
|
|
285
|
+
}}
|
|
286
|
+
QTabBar::tab:selected {{ background: {t['CARD_BG']}; border-bottom-color: {t['CARD_BG']}; }}
|
|
287
|
+
QTableWidget {{ background: {t['DARK_BG']}; gridline-color: {t['BORDER']}; border: 1px solid {t['BORDER']}; }}
|
|
288
|
+
QHeaderView::section {{ background: {t['CARD_BG']}; color: {t['FG']}; border: 1px solid {t['BORDER']}; }}
|
|
289
|
+
|
|
290
|
+
QLabel#dim {{
|
|
291
|
+
color:{t['FG_DIM']}; font-size:11px;
|
|
292
|
+
}}
|
|
293
|
+
QLabel#zoom_label {{
|
|
294
|
+
color:{t['ACCENT']}; font-size:11px; padding:0 4px;
|
|
295
|
+
}}
|
|
296
|
+
QWidget#sidebar {{
|
|
297
|
+
background:{t['PANEL_BG']}; border-right:1px solid {t['BORDER']};
|
|
298
|
+
}}
|
|
299
|
+
QLabel#mol_name {{
|
|
300
|
+
color:{t['FG']}; font-weight:bold; font-size:13px; letter-spacing:1px;
|
|
301
|
+
}}
|
|
302
|
+
QLabel#hint {{
|
|
303
|
+
color:{t['FG_DIM']}; font-size:10px; line-height:160%;
|
|
304
|
+
}}
|
|
305
|
+
QToolBar {{
|
|
306
|
+
background:{t['PANEL_BG']}; border-bottom:1px solid {t['BORDER']};
|
|
307
|
+
spacing: 4px; padding: 2px;
|
|
308
|
+
}}
|
|
309
|
+
QToolButton {{
|
|
310
|
+
border-radius: 4px; padding: 4px; color: {t['FG']};
|
|
311
|
+
border: 1px solid transparent;
|
|
312
|
+
}}
|
|
313
|
+
QToolButton:hover {{ background: {t['BORDER']}; }}
|
|
314
|
+
QToolButton:checked {{
|
|
315
|
+
background: {t['ACCENT']}; color: white;
|
|
316
|
+
border: 1px solid {t['ACCENT']};
|
|
317
|
+
}}
|
|
318
|
+
QToolButton:checked {{
|
|
319
|
+
background: {t['ACCENT']}; color: white;
|
|
320
|
+
border: 1px solid {t['ACCENT']};
|
|
321
|
+
}}
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
326
|
+
# COLOUR SWATCH BUTTON
|
|
327
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
def color_pixmap(hex_color: str, size: int = 22) -> QPixmap:
|
|
330
|
+
px = QPixmap(size, size)
|
|
331
|
+
px.fill(QColor(hex_color))
|
|
332
|
+
return px
|
|
333
|
+
|
|
334
|
+
class ColorButton(QPushButton):
|
|
335
|
+
"""Push-button that shows a solid colour swatch and opens a colour dialog."""
|
|
336
|
+
colorChanged = pyqtSignal(str) # emits new hex string
|
|
337
|
+
|
|
338
|
+
def __init__(self, initial_color: str = "#ffffff", parent=None):
|
|
339
|
+
super().__init__(parent)
|
|
340
|
+
self.setObjectName("color_btn")
|
|
341
|
+
self.setFixedSize(36, 26)
|
|
342
|
+
self._color = initial_color
|
|
343
|
+
self._update_swatch()
|
|
344
|
+
self.clicked.connect(self._pick)
|
|
345
|
+
|
|
346
|
+
def color(self) -> str:
|
|
347
|
+
return self._color
|
|
348
|
+
|
|
349
|
+
def set_color(self, hex_color: str, emit: bool = True):
|
|
350
|
+
orig = self._color
|
|
351
|
+
self._color = hex_color
|
|
352
|
+
self._update_swatch()
|
|
353
|
+
if emit and orig != hex_color:
|
|
354
|
+
self.colorChanged.emit(self._color)
|
|
355
|
+
|
|
356
|
+
def _update_swatch(self):
|
|
357
|
+
# The border/size is handled by the main stylesheet via #color_btn
|
|
358
|
+
self.setStyleSheet(f"background:{self._color};")
|
|
359
|
+
|
|
360
|
+
def _pick(self):
|
|
361
|
+
col = QColorDialog.getColor(QColor(self._color), self, "Pick colour")
|
|
362
|
+
if col.isValid():
|
|
363
|
+
self._color = col.name()
|
|
364
|
+
self._update_swatch()
|
|
365
|
+
self.colorChanged.emit(self._color)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
369
|
+
# SETTINGS DIALOG (Tabbed: General, Overlay, Atoms & Bonds)
|
|
370
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
POSITION_LABELS = {
|
|
373
|
+
"top-left": "Top left",
|
|
374
|
+
"top": "Top",
|
|
375
|
+
"top-right": "Top right",
|
|
376
|
+
"left": "Left",
|
|
377
|
+
"center": "Center",
|
|
378
|
+
"right": "Right",
|
|
379
|
+
"bottom-left": "Bottom left",
|
|
380
|
+
"bottom": "Bottom",
|
|
381
|
+
"bottom-right": "Bottom right",
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
class LightPositionDialog(QDialog):
|
|
385
|
+
def __init__(self, current="top-left", parent=None):
|
|
386
|
+
super().__init__(parent)
|
|
387
|
+
self.setWindowTitle("Select Light Position")
|
|
388
|
+
self.setFixedSize(320, 260)
|
|
389
|
+
self._selected = current
|
|
390
|
+
|
|
391
|
+
layout = QVBoxLayout(self)
|
|
392
|
+
layout.setSpacing(10)
|
|
393
|
+
|
|
394
|
+
grid = QGridLayout()
|
|
395
|
+
grid.setSpacing(6)
|
|
396
|
+
|
|
397
|
+
positions = [
|
|
398
|
+
("top-left", 0, 0), ("top", 0, 1), ("top-right", 0, 2),
|
|
399
|
+
("left", 1, 0), ("center", 1, 1), ("right", 1, 2),
|
|
400
|
+
("bottom-left", 2, 0), ("bottom", 2, 1), ("bottom-right", 2, 2),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
self._btns = {}
|
|
404
|
+
for key, r, c in positions:
|
|
405
|
+
label = POSITION_LABELS[key]
|
|
406
|
+
btn = QPushButton(label)
|
|
407
|
+
btn.setFixedSize(90, 40)
|
|
408
|
+
btn.setCheckable(True)
|
|
409
|
+
btn.setChecked(key == current)
|
|
410
|
+
if key == current:
|
|
411
|
+
btn.setStyleSheet(
|
|
412
|
+
"QPushButton { background-color: #4488cc; color: white; "
|
|
413
|
+
"font-weight: bold; border: 2px solid #66aaff; border-radius: 4px; }"
|
|
414
|
+
)
|
|
415
|
+
btn.clicked.connect(lambda checked, k=key: self._select(k))
|
|
416
|
+
grid.addWidget(btn, r, c)
|
|
417
|
+
self._btns[key] = btn
|
|
418
|
+
|
|
419
|
+
layout.addLayout(grid)
|
|
420
|
+
|
|
421
|
+
btn_row = QHBoxLayout()
|
|
422
|
+
btn_row.addStretch()
|
|
423
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
424
|
+
btns.accepted.connect(self.accept)
|
|
425
|
+
btns.rejected.connect(self.reject)
|
|
426
|
+
btn_row.addWidget(btns)
|
|
427
|
+
layout.addLayout(btn_row)
|
|
428
|
+
|
|
429
|
+
def _select(self, key: str):
|
|
430
|
+
old = self._selected
|
|
431
|
+
self._selected = key
|
|
432
|
+
if old in self._btns:
|
|
433
|
+
self._btns[old].setChecked(False)
|
|
434
|
+
self._btns[old].setStyleSheet("")
|
|
435
|
+
self._btns[key].setChecked(True)
|
|
436
|
+
self._btns[key].setStyleSheet(
|
|
437
|
+
"QPushButton { background-color: #4488cc; color: white; "
|
|
438
|
+
"font-weight: bold; border: 2px solid #66aaff; border-radius: 4px; }"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def position(self) -> str:
|
|
443
|
+
return self._selected
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class SettingsDialog(QDialog):
|
|
447
|
+
CONFIG_FILE = os.path.join(os.path.dirname(__file__), "molvector_config.json")
|
|
448
|
+
|
|
449
|
+
def __init__(self, theme, bg_color, atom_scale, bond_width, bond_style, color_overrides,
|
|
450
|
+
atom_border_mode="scaled", atom_border_scale=1.04, atom_border_width=2.0,
|
|
451
|
+
bond_color="#444444", lighting_intensity=1.0,
|
|
452
|
+
light_position="top-left", roughness=1.0,
|
|
453
|
+
show_axes=False, show_principal_axes=False,
|
|
454
|
+
axes_position="bottom-left", principal_axes_position="bottom-left",
|
|
455
|
+
restore_molecule=False, live_callback=None, parent=None):
|
|
456
|
+
super().__init__(parent)
|
|
457
|
+
self.setWindowTitle("Settings")
|
|
458
|
+
self.setMinimumWidth(520)
|
|
459
|
+
self._live_callback = live_callback
|
|
460
|
+
self._color_overrides = color_overrides
|
|
461
|
+
|
|
462
|
+
layout = QVBoxLayout(self)
|
|
463
|
+
layout.setSpacing(10)
|
|
464
|
+
|
|
465
|
+
self.tabs = QTabWidget()
|
|
466
|
+
layout.addWidget(self.tabs)
|
|
467
|
+
|
|
468
|
+
self._build_general_tab(theme, restore_molecule)
|
|
469
|
+
self._build_overlay_tab(bg_color, show_axes, show_principal_axes, axes_position, principal_axes_position)
|
|
470
|
+
self._build_atoms_bonds_tab(atom_scale, bond_width, bond_style,
|
|
471
|
+
atom_border_mode, atom_border_scale, atom_border_width,
|
|
472
|
+
bond_color, lighting_intensity, light_position, roughness)
|
|
473
|
+
|
|
474
|
+
btn_row = QHBoxLayout()
|
|
475
|
+
btn_make_config = QPushButton("Make Default")
|
|
476
|
+
btn_make_config.clicked.connect(self._save_config)
|
|
477
|
+
btn_restore = QPushButton("Restore Defaults")
|
|
478
|
+
btn_restore.clicked.connect(self._restore_defaults)
|
|
479
|
+
btn_row.addWidget(btn_make_config)
|
|
480
|
+
btn_row.addWidget(btn_restore)
|
|
481
|
+
btn_row.addStretch()
|
|
482
|
+
dialog_btns = QDialogButtonBox(
|
|
483
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
484
|
+
)
|
|
485
|
+
dialog_btns.accepted.connect(self.accept)
|
|
486
|
+
dialog_btns.rejected.connect(self.reject)
|
|
487
|
+
btn_row.addWidget(dialog_btns)
|
|
488
|
+
layout.addLayout(btn_row)
|
|
489
|
+
|
|
490
|
+
def _build_general_tab(self, theme, restore_molecule):
|
|
491
|
+
page = QWidget()
|
|
492
|
+
form = QFormLayout(page)
|
|
493
|
+
form.setSpacing(8)
|
|
494
|
+
|
|
495
|
+
self._theme_combo = QComboBox()
|
|
496
|
+
self._theme_combo.addItems(["Dark", "Light"])
|
|
497
|
+
self._theme_combo.setCurrentText(theme.capitalize())
|
|
498
|
+
self._theme_combo.setMinimumWidth(100)
|
|
499
|
+
self._theme_combo.currentTextChanged.connect(self._on_change)
|
|
500
|
+
form.addRow("Theme:", self._theme_combo)
|
|
501
|
+
|
|
502
|
+
self._restore_cb = QCheckBox("Restore molecule at login")
|
|
503
|
+
self._restore_cb.setChecked(restore_molecule)
|
|
504
|
+
self._restore_cb.toggled.connect(self._on_change)
|
|
505
|
+
form.addRow("", self._restore_cb)
|
|
506
|
+
|
|
507
|
+
self.tabs.addTab(page, "&General")
|
|
508
|
+
|
|
509
|
+
def _build_overlay_tab(self, bg_color, show_axes, show_principal_axes, axes_position, principal_axes_position):
|
|
510
|
+
page = QWidget()
|
|
511
|
+
form = QFormLayout(page)
|
|
512
|
+
form.setSpacing(8)
|
|
513
|
+
|
|
514
|
+
self._bg_btn = ColorButton(bg_color)
|
|
515
|
+
self._bg_btn.colorChanged.connect(self._on_change)
|
|
516
|
+
form.addRow("Background:", self._bg_btn)
|
|
517
|
+
|
|
518
|
+
self._show_axes_cb = QCheckBox("Show XYZ Axes")
|
|
519
|
+
self._show_axes_cb.setChecked(show_axes)
|
|
520
|
+
self._show_axes_cb.toggled.connect(self._on_change)
|
|
521
|
+
form.addRow("XYZ axes:", self._show_axes_cb)
|
|
522
|
+
|
|
523
|
+
self._axes_pos_combo = QComboBox()
|
|
524
|
+
self._axes_pos_combo.addItems(["Bottom left", "Bottom right", "Top left", "Top right"])
|
|
525
|
+
self._axes_pos_combo.setCurrentText(axes_position.replace("-", " ").title())
|
|
526
|
+
self._axes_pos_combo.setFixedWidth(120)
|
|
527
|
+
self._axes_pos_combo.currentIndexChanged.connect(self._on_change)
|
|
528
|
+
form.addRow("Position:", self._axes_pos_combo)
|
|
529
|
+
|
|
530
|
+
self._show_principal_axes_cb = QCheckBox("Show a/b/c Axes")
|
|
531
|
+
self._show_principal_axes_cb.setChecked(show_principal_axes)
|
|
532
|
+
self._show_principal_axes_cb.toggled.connect(self._on_change)
|
|
533
|
+
form.addRow("a/b/c axes:", self._show_principal_axes_cb)
|
|
534
|
+
|
|
535
|
+
self._principal_axes_pos_combo = QComboBox()
|
|
536
|
+
self._principal_axes_pos_combo.addItems(["Bottom right", "Bottom left", "Top left", "Top right"])
|
|
537
|
+
self._principal_axes_pos_combo.setCurrentText(principal_axes_position.replace("-", " ").title())
|
|
538
|
+
self._principal_axes_pos_combo.setFixedWidth(120)
|
|
539
|
+
self._principal_axes_pos_combo.currentIndexChanged.connect(self._on_change)
|
|
540
|
+
form.addRow("Position:", self._principal_axes_pos_combo)
|
|
541
|
+
|
|
542
|
+
self.tabs.addTab(page, "&Overlay")
|
|
543
|
+
|
|
544
|
+
def _build_atoms_bonds_tab(self, atom_scale, bond_width, bond_style,
|
|
545
|
+
atom_border_mode, atom_border_scale, atom_border_width,
|
|
546
|
+
bond_color, lighting_intensity, light_position, roughness):
|
|
547
|
+
page = QWidget()
|
|
548
|
+
form = QFormLayout(page)
|
|
549
|
+
form.setSpacing(8)
|
|
550
|
+
|
|
551
|
+
self._edit_colors_btn = QPushButton("Edit Atom Colours…")
|
|
552
|
+
self._edit_colors_btn.clicked.connect(self._edit_atom_colors)
|
|
553
|
+
form.addRow("Atom color:", self._edit_colors_btn)
|
|
554
|
+
|
|
555
|
+
self._ball_slider = QSlider(Qt.Orientation.Horizontal)
|
|
556
|
+
self._ball_slider.setRange(20, 150)
|
|
557
|
+
self._ball_slider.setValue(int(atom_scale * 100))
|
|
558
|
+
self._ball_slider.setFixedWidth(160)
|
|
559
|
+
self._ball_lbl = QLabel(f"{atom_scale:.2f}")
|
|
560
|
+
self._ball_slider.valueChanged.connect(self._on_change)
|
|
561
|
+
ball_row = QHBoxLayout()
|
|
562
|
+
ball_row.addWidget(self._ball_slider)
|
|
563
|
+
ball_row.addWidget(self._ball_lbl)
|
|
564
|
+
form.addRow("Ball size:", ball_row)
|
|
565
|
+
|
|
566
|
+
self._bondw_slider = QSlider(Qt.Orientation.Horizontal)
|
|
567
|
+
self._bondw_slider.setRange(2, 30)
|
|
568
|
+
self._bondw_slider.setValue(int(bond_width))
|
|
569
|
+
self._bondw_slider.setFixedWidth(160)
|
|
570
|
+
self._bondw_lbl = QLabel(f"{bond_width:.0f}")
|
|
571
|
+
self._bondw_slider.valueChanged.connect(self._on_change)
|
|
572
|
+
bondw_row = QHBoxLayout()
|
|
573
|
+
bondw_row.addWidget(self._bondw_slider)
|
|
574
|
+
bondw_row.addWidget(self._bondw_lbl)
|
|
575
|
+
form.addRow("Bond size:", bondw_row)
|
|
576
|
+
|
|
577
|
+
self._style_combo = QComboBox()
|
|
578
|
+
self._style_combo.addItems(["Splitted", "Unicolor", "Gradient"])
|
|
579
|
+
self._style_combo.setMinimumWidth(90)
|
|
580
|
+
self._bond_color = bond_color
|
|
581
|
+
style_row = QHBoxLayout()
|
|
582
|
+
style_row.addWidget(self._style_combo)
|
|
583
|
+
self._unicolor_btn = QPushButton()
|
|
584
|
+
self._unicolor_btn.setFixedSize(28, 22)
|
|
585
|
+
self._unicolor_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
|
586
|
+
self._unicolor_btn.clicked.connect(self._pick_unicolor)
|
|
587
|
+
self._update_unicolor_btn()
|
|
588
|
+
style_row.addWidget(self._unicolor_btn)
|
|
589
|
+
self._style_combo.currentTextChanged.connect(self._on_style_change)
|
|
590
|
+
current = bond_style.capitalize() if bond_style != "grey" else "Unicolor"
|
|
591
|
+
self._style_combo.setCurrentText(current)
|
|
592
|
+
self._unicolor_btn.setVisible(current == "Unicolor")
|
|
593
|
+
style_row.addStretch()
|
|
594
|
+
form.addRow("Bond Style:", style_row)
|
|
595
|
+
|
|
596
|
+
self._roughness_slider = QSlider(Qt.Orientation.Horizontal)
|
|
597
|
+
self._roughness_slider.setRange(0, 200)
|
|
598
|
+
self._roughness_slider.setValue(int(roughness * 100))
|
|
599
|
+
self._roughness_slider.setFixedWidth(160)
|
|
600
|
+
self._roughness_lbl = QLabel(f"{roughness:.2f}")
|
|
601
|
+
self._roughness_slider.valueChanged.connect(self._on_change)
|
|
602
|
+
roughness_row = QHBoxLayout()
|
|
603
|
+
roughness_row.addWidget(self._roughness_slider)
|
|
604
|
+
roughness_row.addWidget(self._roughness_lbl)
|
|
605
|
+
form.addRow("Roughness:", roughness_row)
|
|
606
|
+
|
|
607
|
+
self._lighting_slider = QSlider(Qt.Orientation.Horizontal)
|
|
608
|
+
self._lighting_slider.setRange(0, 200)
|
|
609
|
+
self._lighting_slider.setValue(int(lighting_intensity * 100))
|
|
610
|
+
self._lighting_slider.setFixedWidth(160)
|
|
611
|
+
self._lighting_lbl = QLabel(f"{lighting_intensity:.2f}")
|
|
612
|
+
self._lighting_slider.valueChanged.connect(self._on_change)
|
|
613
|
+
lighting_row = QHBoxLayout()
|
|
614
|
+
lighting_row.addWidget(self._lighting_slider)
|
|
615
|
+
lighting_row.addWidget(self._lighting_lbl)
|
|
616
|
+
form.addRow("Lighting:", lighting_row)
|
|
617
|
+
|
|
618
|
+
self._light_position = light_position
|
|
619
|
+
self._light_pos_btn = QPushButton(POSITION_LABELS.get(light_position, light_position))
|
|
620
|
+
self._light_pos_btn.setFixedWidth(160)
|
|
621
|
+
self._light_pos_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
|
622
|
+
self._light_pos_btn.clicked.connect(self._pick_light_position)
|
|
623
|
+
form.addRow("Lighting Position:", self._light_pos_btn)
|
|
624
|
+
|
|
625
|
+
self._border_mode = QComboBox()
|
|
626
|
+
self._border_mode.addItems(["None", "Scaled", "Constant"])
|
|
627
|
+
self._border_mode.setMinimumWidth(90)
|
|
628
|
+
self._border_mode.setCurrentText(atom_border_mode.capitalize())
|
|
629
|
+
self._border_mode.currentTextChanged.connect(self._on_mode_change)
|
|
630
|
+
|
|
631
|
+
self._border_slider = QSlider(Qt.Orientation.Horizontal)
|
|
632
|
+
self._border_slider.setFixedWidth(160)
|
|
633
|
+
self._border_slider.valueChanged.connect(self._on_change)
|
|
634
|
+
|
|
635
|
+
border_row = QHBoxLayout()
|
|
636
|
+
border_row.addWidget(self._border_mode)
|
|
637
|
+
border_row.addWidget(self._border_slider)
|
|
638
|
+
form.addRow("Border:", border_row)
|
|
639
|
+
|
|
640
|
+
self._set_border_slider_for_mode(atom_border_mode, atom_border_scale, atom_border_width)
|
|
641
|
+
self._update_border_visibility()
|
|
642
|
+
|
|
643
|
+
self.tabs.addTab(page, "Atoms && Bonds")
|
|
644
|
+
|
|
645
|
+
self._ball_slider.installEventFilter(self)
|
|
646
|
+
self._bondw_slider.installEventFilter(self)
|
|
647
|
+
self._roughness_slider.installEventFilter(self)
|
|
648
|
+
self._lighting_slider.installEventFilter(self)
|
|
649
|
+
self._border_slider.installEventFilter(self)
|
|
650
|
+
|
|
651
|
+
def _set_border_slider_for_mode(self, mode, scale, width):
|
|
652
|
+
if mode == "constant":
|
|
653
|
+
self._border_slider.setRange(1, 20)
|
|
654
|
+
self._border_slider.setValue(int(width))
|
|
655
|
+
else:
|
|
656
|
+
self._border_slider.setRange(100, 150)
|
|
657
|
+
self._border_slider.setValue(int(scale * 100))
|
|
658
|
+
|
|
659
|
+
def _update_border_visibility(self):
|
|
660
|
+
visible = self._border_mode.currentText().lower() != "none"
|
|
661
|
+
if hasattr(self, '_border_slider'):
|
|
662
|
+
self._border_slider.setVisible(visible)
|
|
663
|
+
|
|
664
|
+
def _on_mode_change(self):
|
|
665
|
+
mode = self._border_mode.currentText().lower()
|
|
666
|
+
if mode == "constant":
|
|
667
|
+
self._border_slider.setRange(1, 20)
|
|
668
|
+
self._border_slider.setValue(2)
|
|
669
|
+
else:
|
|
670
|
+
self._border_slider.setRange(100, 150)
|
|
671
|
+
self._border_slider.setValue(104)
|
|
672
|
+
self._on_change()
|
|
673
|
+
|
|
674
|
+
def _on_style_change(self, text: str):
|
|
675
|
+
self._unicolor_btn.setVisible(text == "Unicolor")
|
|
676
|
+
self._on_change()
|
|
677
|
+
|
|
678
|
+
def _on_change(self):
|
|
679
|
+
if hasattr(self, '_ball_lbl'):
|
|
680
|
+
self._ball_lbl.setText(f"{self.ball_scale:.2f}")
|
|
681
|
+
self._bondw_lbl.setText(f"{self.bond_width:.0f}")
|
|
682
|
+
self._lighting_lbl.setText(f"{self.lighting_intensity:.2f}")
|
|
683
|
+
self._roughness_lbl.setText(f"{self.roughness:.2f}")
|
|
684
|
+
self._update_border_visibility()
|
|
685
|
+
if self._live_callback:
|
|
686
|
+
self._live_callback(
|
|
687
|
+
self.theme, self.bg_color,
|
|
688
|
+
self.ball_scale, self.bond_width, self.bond_style,
|
|
689
|
+
self._color_overrides, self.border_mode, self.border_scale,
|
|
690
|
+
self.border_width, self.bond_color,
|
|
691
|
+
self.lighting_intensity, self.light_position,
|
|
692
|
+
self.roughness,
|
|
693
|
+
self.show_axes, self.show_principal_axes,
|
|
694
|
+
self.axes_position, self.principal_axes_position,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
def eventFilter(self, obj, event):
|
|
698
|
+
if event.type() == QEvent.Type.MouseButtonDblClick:
|
|
699
|
+
cfg = self.load_config() or {}
|
|
700
|
+
acfg = cfg.get("atoms_bonds", {})
|
|
701
|
+
if obj is self._ball_slider:
|
|
702
|
+
self._ball_slider.setValue(int(acfg.get("atom_scale", 0.7) * 100))
|
|
703
|
+
elif obj is self._bondw_slider:
|
|
704
|
+
self._bondw_slider.setValue(int(acfg.get("bond_width_px", 10)))
|
|
705
|
+
elif obj is self._roughness_slider:
|
|
706
|
+
self._roughness_slider.setValue(int(acfg.get("roughness", 1.0) * 100))
|
|
707
|
+
elif obj is self._lighting_slider:
|
|
708
|
+
self._lighting_slider.setValue(int(acfg.get("lighting_intensity", 1.0) * 100))
|
|
709
|
+
elif obj is self._border_slider:
|
|
710
|
+
mode = self._border_mode.currentText().lower()
|
|
711
|
+
if mode == "constant":
|
|
712
|
+
self._border_slider.setValue(int(acfg.get("atom_border_width", 2)))
|
|
713
|
+
else:
|
|
714
|
+
self._border_slider.setValue(int(acfg.get("atom_border_scale", 1.04) * 100))
|
|
715
|
+
return True
|
|
716
|
+
return super().eventFilter(obj, event)
|
|
717
|
+
|
|
718
|
+
def _pick_unicolor(self):
|
|
719
|
+
col = QColorDialog.getColor(QColor(self._bond_color), self, "Choose Bond Color")
|
|
720
|
+
if col.isValid():
|
|
721
|
+
self._bond_color = col.name()
|
|
722
|
+
self._update_unicolor_btn()
|
|
723
|
+
self._on_change()
|
|
724
|
+
|
|
725
|
+
def _update_unicolor_btn(self):
|
|
726
|
+
self._unicolor_btn.setStyleSheet(
|
|
727
|
+
f"background-color: {self._bond_color}; border: 1px solid #888; border-radius: 3px;"
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
def _pick_light_position(self):
|
|
731
|
+
dlg = LightPositionDialog(self._light_position, parent=self)
|
|
732
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
733
|
+
self._light_position = dlg.position
|
|
734
|
+
self._light_pos_btn.setText(POSITION_LABELS.get(self._light_position, self._light_position))
|
|
735
|
+
self._on_change()
|
|
736
|
+
|
|
737
|
+
def _edit_atom_colors(self):
|
|
738
|
+
mol = self.parent()._canvas.molecule if self.parent() else None
|
|
739
|
+
if mol is None:
|
|
740
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
741
|
+
return
|
|
742
|
+
elements = sorted({a.element for a in mol.atoms})
|
|
743
|
+
dlg = AtomColorDialog(elements, self._color_overrides, parent=self)
|
|
744
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
745
|
+
self._color_overrides = dlg.get_overrides()
|
|
746
|
+
self._on_change()
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def theme(self) -> str:
|
|
750
|
+
return self._theme_combo.currentText().lower()
|
|
751
|
+
|
|
752
|
+
@property
|
|
753
|
+
def bg_color(self) -> str:
|
|
754
|
+
return self._bg_btn.color()
|
|
755
|
+
|
|
756
|
+
@property
|
|
757
|
+
def restore_molecule(self) -> bool:
|
|
758
|
+
return self._restore_cb.isChecked()
|
|
759
|
+
|
|
760
|
+
@property
|
|
761
|
+
def ball_scale(self) -> float:
|
|
762
|
+
return self._ball_slider.value() / 100.0
|
|
763
|
+
|
|
764
|
+
@property
|
|
765
|
+
def bond_width(self) -> float:
|
|
766
|
+
return float(self._bondw_slider.value())
|
|
767
|
+
|
|
768
|
+
@property
|
|
769
|
+
def bond_style(self) -> str:
|
|
770
|
+
text = self._style_combo.currentText()
|
|
771
|
+
return "unicolor" if text == "Unicolor" else text.lower()
|
|
772
|
+
|
|
773
|
+
@property
|
|
774
|
+
def bond_color(self) -> str:
|
|
775
|
+
return self._bond_color
|
|
776
|
+
|
|
777
|
+
@property
|
|
778
|
+
def border_mode(self) -> str:
|
|
779
|
+
return self._border_mode.currentText().lower()
|
|
780
|
+
|
|
781
|
+
@property
|
|
782
|
+
def border_scale(self) -> float:
|
|
783
|
+
return self._border_slider.value() / 100.0
|
|
784
|
+
|
|
785
|
+
@property
|
|
786
|
+
def border_width(self) -> float:
|
|
787
|
+
return float(self._border_slider.value())
|
|
788
|
+
|
|
789
|
+
@property
|
|
790
|
+
def lighting_intensity(self) -> float:
|
|
791
|
+
return self._lighting_slider.value() / 100.0
|
|
792
|
+
|
|
793
|
+
@property
|
|
794
|
+
def light_position(self) -> str:
|
|
795
|
+
return self._light_position
|
|
796
|
+
|
|
797
|
+
@property
|
|
798
|
+
def roughness(self) -> float:
|
|
799
|
+
return self._roughness_slider.value() / 100.0
|
|
800
|
+
|
|
801
|
+
@property
|
|
802
|
+
def show_axes(self) -> bool:
|
|
803
|
+
return self._show_axes_cb.isChecked()
|
|
804
|
+
|
|
805
|
+
@property
|
|
806
|
+
def show_principal_axes(self) -> bool:
|
|
807
|
+
return self._show_principal_axes_cb.isChecked()
|
|
808
|
+
|
|
809
|
+
@property
|
|
810
|
+
def axes_position(self) -> str:
|
|
811
|
+
return self._axes_pos_combo.currentText().lower().replace(" ", "-")
|
|
812
|
+
|
|
813
|
+
@property
|
|
814
|
+
def principal_axes_position(self) -> str:
|
|
815
|
+
return self._principal_axes_pos_combo.currentText().lower().replace(" ", "-")
|
|
816
|
+
|
|
817
|
+
def _section_key(self) -> str:
|
|
818
|
+
return {0: "general", 1: "overlay", 2: "atoms_bonds"}.get(
|
|
819
|
+
self.tabs.currentIndex(), "general")
|
|
820
|
+
|
|
821
|
+
def _section_data(self, section: str) -> dict:
|
|
822
|
+
if section == "general":
|
|
823
|
+
return {"theme": self.theme, "restore_molecule": self.restore_molecule}
|
|
824
|
+
if section == "overlay":
|
|
825
|
+
return {
|
|
826
|
+
"bg_color": self.bg_color,
|
|
827
|
+
"show_axes": self.show_axes,
|
|
828
|
+
"show_principal_axes": self.show_principal_axes,
|
|
829
|
+
"axes_position": self.axes_position,
|
|
830
|
+
"principal_axes_position": self.principal_axes_position,
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
"atom_scale": self.ball_scale,
|
|
834
|
+
"bond_width_px": self.bond_width,
|
|
835
|
+
"bond_style": self.bond_style,
|
|
836
|
+
"bond_color": self.bond_color,
|
|
837
|
+
"atom_border_mode": self.border_mode,
|
|
838
|
+
"atom_border_scale": self.border_scale,
|
|
839
|
+
"atom_border_width": self.border_width,
|
|
840
|
+
"lighting_intensity": self.lighting_intensity,
|
|
841
|
+
"light_position": self.light_position,
|
|
842
|
+
"roughness": self.roughness,
|
|
843
|
+
"color_overrides": self._color_overrides,
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
def _save_config(self):
|
|
847
|
+
section = self._section_key()
|
|
848
|
+
config = {}
|
|
849
|
+
if os.path.isfile(self.CONFIG_FILE):
|
|
850
|
+
try:
|
|
851
|
+
with open(self.CONFIG_FILE) as f:
|
|
852
|
+
config = json.load(f)
|
|
853
|
+
except Exception:
|
|
854
|
+
pass
|
|
855
|
+
config[section] = self._section_data(section)
|
|
856
|
+
try:
|
|
857
|
+
with open(self.CONFIG_FILE, "w") as f:
|
|
858
|
+
json.dump(config, f, indent=2)
|
|
859
|
+
QMessageBox.information(self, "Config Saved",
|
|
860
|
+
f"{section.title()} settings saved.")
|
|
861
|
+
except Exception as e:
|
|
862
|
+
QMessageBox.critical(self, "Error", f"Could not save config:\n{e}")
|
|
863
|
+
|
|
864
|
+
def _restore_section(self, section: str):
|
|
865
|
+
if section == "general":
|
|
866
|
+
self._theme_combo.setCurrentText("Light")
|
|
867
|
+
self._restore_cb.setChecked(False)
|
|
868
|
+
elif section == "overlay":
|
|
869
|
+
self._bg_btn.set_color("#ffffff")
|
|
870
|
+
self._show_axes_cb.setChecked(False)
|
|
871
|
+
self._show_principal_axes_cb.setChecked(False)
|
|
872
|
+
self._axes_pos_combo.setCurrentText("Bottom left")
|
|
873
|
+
self._principal_axes_pos_combo.setCurrentText("Bottom right")
|
|
874
|
+
else:
|
|
875
|
+
self._ball_slider.setValue(70)
|
|
876
|
+
self._bondw_slider.setValue(10)
|
|
877
|
+
self._style_combo.setCurrentText("Splitted")
|
|
878
|
+
self._bond_color = "#444444"
|
|
879
|
+
self._update_unicolor_btn()
|
|
880
|
+
self._roughness_slider.setValue(100)
|
|
881
|
+
self._lighting_slider.setValue(100)
|
|
882
|
+
self._light_position = "top-left"
|
|
883
|
+
self._light_pos_btn.setText("Top left")
|
|
884
|
+
self._border_mode.setCurrentText("Scaled")
|
|
885
|
+
self._border_slider.setValue(104)
|
|
886
|
+
self._color_overrides = {}
|
|
887
|
+
|
|
888
|
+
def _restore_defaults(self):
|
|
889
|
+
section = self._section_key()
|
|
890
|
+
self._restore_section(section)
|
|
891
|
+
self._on_change()
|
|
892
|
+
|
|
893
|
+
@staticmethod
|
|
894
|
+
def load_config():
|
|
895
|
+
path = SettingsDialog.CONFIG_FILE
|
|
896
|
+
if not os.path.isfile(path):
|
|
897
|
+
return None
|
|
898
|
+
try:
|
|
899
|
+
with open(path) as f:
|
|
900
|
+
data = json.load(f)
|
|
901
|
+
except Exception:
|
|
902
|
+
return None
|
|
903
|
+
|
|
904
|
+
TAB_KEYS = {"general", "overlay", "atoms_bonds"}
|
|
905
|
+
if not data.keys() & TAB_KEYS:
|
|
906
|
+
data = {
|
|
907
|
+
"general": {
|
|
908
|
+
"theme": data.get("theme", "light"),
|
|
909
|
+
"restore_molecule": data.get("restore_molecule", False),
|
|
910
|
+
},
|
|
911
|
+
"overlay": {
|
|
912
|
+
"bg_color": data.get("bg_color", "#ffffff"),
|
|
913
|
+
"show_axes": data.get("show_axes", False),
|
|
914
|
+
"show_principal_axes": data.get("show_principal_axes", False),
|
|
915
|
+
"axes_position": data.get("axes_position", "bottom-left"),
|
|
916
|
+
"principal_axes_position": data.get("principal_axes_position", "bottom-right"),
|
|
917
|
+
},
|
|
918
|
+
"atoms_bonds": {
|
|
919
|
+
"atom_scale": data.get("atom_scale", 0.7),
|
|
920
|
+
"bond_width_px": data.get("bond_width_px", 10.0),
|
|
921
|
+
"bond_style": data.get("bond_style", "splitted"),
|
|
922
|
+
"bond_color": data.get("bond_color", "#444444"),
|
|
923
|
+
"atom_border_mode": data.get("atom_border_mode", "scaled"),
|
|
924
|
+
"atom_border_scale": data.get("atom_border_scale", 1.04),
|
|
925
|
+
"atom_border_width": data.get("atom_border_width", 2.0),
|
|
926
|
+
"lighting_intensity": data.get("lighting_intensity", 1.0),
|
|
927
|
+
"light_position": data.get("light_position", "top-left"),
|
|
928
|
+
"roughness": data.get("roughness", 1.0),
|
|
929
|
+
"color_overrides": data.get("color_overrides", {}),
|
|
930
|
+
},
|
|
931
|
+
}
|
|
932
|
+
try:
|
|
933
|
+
with open(path, "w") as f:
|
|
934
|
+
json.dump(data, f, indent=2)
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
return data
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
941
|
+
# ATOM COLOUR EDITOR DIALOG
|
|
942
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
943
|
+
|
|
944
|
+
class AtomColorDialog(QDialog):
|
|
945
|
+
"""Shows one colour-picker row per element present in the molecule."""
|
|
946
|
+
|
|
947
|
+
def __init__(self, elements: list, current_overrides: dict, live_callback=None, parent=None):
|
|
948
|
+
super().__init__(parent)
|
|
949
|
+
self.setWindowTitle("Atom Colours")
|
|
950
|
+
self.setMinimumWidth(360)
|
|
951
|
+
self._live_callback = live_callback
|
|
952
|
+
|
|
953
|
+
layout = QVBoxLayout(self)
|
|
954
|
+
layout.setSpacing(10)
|
|
955
|
+
|
|
956
|
+
lbl = QLabel("Click a swatch to change an element's colour.")
|
|
957
|
+
lbl.setObjectName("dim")
|
|
958
|
+
lbl.setStyleSheet("font-size:11px;")
|
|
959
|
+
layout.addWidget(lbl)
|
|
960
|
+
|
|
961
|
+
scroll = QScrollArea()
|
|
962
|
+
scroll.setWidgetResizable(True)
|
|
963
|
+
inner = QWidget()
|
|
964
|
+
grid = QGridLayout(inner)
|
|
965
|
+
grid.setSpacing(10)
|
|
966
|
+
grid.setContentsMargins(6, 6, 6, 6)
|
|
967
|
+
scroll.setWidget(inner)
|
|
968
|
+
layout.addWidget(scroll)
|
|
969
|
+
|
|
970
|
+
self._buttons: dict[str, ColorButton] = {}
|
|
971
|
+
|
|
972
|
+
for row, elem in enumerate(sorted(elements)):
|
|
973
|
+
default = CPK_BASE.get(elem, "#cc44aa")
|
|
974
|
+
current = current_overrides.get(elem, default)
|
|
975
|
+
|
|
976
|
+
# Element symbol + name label
|
|
977
|
+
sym_lbl = QLabel(f"<b>{elem}</b>")
|
|
978
|
+
sym_lbl.setFixedWidth(28)
|
|
979
|
+
sym_lbl.setStyleSheet("font-size:13px;")
|
|
980
|
+
name_lbl = QLabel(ELEM_FULL_NAME.get(elem, elem))
|
|
981
|
+
name_lbl.setObjectName("dim")
|
|
982
|
+
|
|
983
|
+
btn = ColorButton(current)
|
|
984
|
+
btn.colorChanged.connect(self._trigger_live)
|
|
985
|
+
self._buttons[elem] = btn
|
|
986
|
+
|
|
987
|
+
# Reset to CPK button
|
|
988
|
+
reset_btn = QPushButton("CPK")
|
|
989
|
+
reset_btn.setFixedWidth(40)
|
|
990
|
+
reset_btn.setStyleSheet(f"font-size:10px; padding:2px 4px;")
|
|
991
|
+
reset_btn.setToolTip(f"Reset {elem} to CPK default")
|
|
992
|
+
reset_btn.clicked.connect(lambda _, e=elem, b=btn: b.set_color(CPK_BASE.get(e, "#cc44aa")))
|
|
993
|
+
|
|
994
|
+
grid.addWidget(sym_lbl, row, 0)
|
|
995
|
+
grid.addWidget(name_lbl, row, 1)
|
|
996
|
+
grid.addWidget(btn, row, 2)
|
|
997
|
+
grid.addWidget(reset_btn, row, 3)
|
|
998
|
+
|
|
999
|
+
grid.setColumnStretch(1, 1)
|
|
1000
|
+
|
|
1001
|
+
btns = QDialogButtonBox(
|
|
1002
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
1003
|
+
)
|
|
1004
|
+
btns.accepted.connect(self.accept)
|
|
1005
|
+
btns.rejected.connect(self.reject)
|
|
1006
|
+
layout.addWidget(btns)
|
|
1007
|
+
|
|
1008
|
+
def _trigger_live(self, *_):
|
|
1009
|
+
if self._live_callback:
|
|
1010
|
+
self._live_callback(self.get_overrides())
|
|
1011
|
+
|
|
1012
|
+
def get_overrides(self) -> dict:
|
|
1013
|
+
return {elem: btn.color() for elem, btn in self._buttons.items()}
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1017
|
+
# SPECTRUM DIALOG
|
|
1018
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1019
|
+
|
|
1020
|
+
class SpectrumDialog(QDialog):
|
|
1021
|
+
def __init__(self, x_data, y_data, x_label, y_label, title, metadata="", parent=None):
|
|
1022
|
+
super().__init__(parent)
|
|
1023
|
+
self.setWindowTitle(title)
|
|
1024
|
+
self.setMinimumSize(700, 500)
|
|
1025
|
+
self._x = np.array(x_data)
|
|
1026
|
+
self._y = np.array(y_data)
|
|
1027
|
+
self._meta = metadata
|
|
1028
|
+
self._xl, self._yl = x_label, y_label
|
|
1029
|
+
self._default_file = "spectrum.txt"
|
|
1030
|
+
|
|
1031
|
+
layout = QVBoxLayout(self)
|
|
1032
|
+
|
|
1033
|
+
if not HAS_MPL:
|
|
1034
|
+
layout.addWidget(QLabel("Matplotlib not found. Cannot display plot."))
|
|
1035
|
+
else:
|
|
1036
|
+
# Detect current theme from parent
|
|
1037
|
+
theme_name = "dark"
|
|
1038
|
+
p = parent
|
|
1039
|
+
while p:
|
|
1040
|
+
if hasattr(p, "_current_theme"):
|
|
1041
|
+
theme_name = p._current_theme
|
|
1042
|
+
break
|
|
1043
|
+
p = p.parent()
|
|
1044
|
+
|
|
1045
|
+
t = THEMES[theme_name]
|
|
1046
|
+
|
|
1047
|
+
self.fig = Figure(figsize=(6, 4), facecolor=t['CARD_BG'])
|
|
1048
|
+
self.canvas = FigureCanvas(self.fig)
|
|
1049
|
+
self.ax = self.fig.add_subplot(111)
|
|
1050
|
+
self.ax.set_facecolor(t['DARK_BG'])
|
|
1051
|
+
|
|
1052
|
+
# Plot stems/peaks
|
|
1053
|
+
self.ax.vlines(self._x, 0, self._y, color=t['ACCENT'], linewidth=2)
|
|
1054
|
+
self.ax.scatter(self._x, self._y, color=t['ACCENT'], s=20)
|
|
1055
|
+
|
|
1056
|
+
self.ax.set_xlabel(x_label, color=t['FG'])
|
|
1057
|
+
self.ax.set_ylabel(y_label, color=t['FG'])
|
|
1058
|
+
self.ax.tick_params(colors=t['FG_DIM'])
|
|
1059
|
+
for spine in self.ax.spines.values():
|
|
1060
|
+
spine.set_edgecolor(t['BORDER'])
|
|
1061
|
+
|
|
1062
|
+
self.fig.tight_layout()
|
|
1063
|
+
layout.addWidget(self.canvas)
|
|
1064
|
+
|
|
1065
|
+
self.toolbar = NavigationToolbar2QT(self.canvas, self)
|
|
1066
|
+
layout.addWidget(self.toolbar)
|
|
1067
|
+
|
|
1068
|
+
btns = QHBoxLayout()
|
|
1069
|
+
btn_export = QPushButton("Export Data (.txt)")
|
|
1070
|
+
btn_export.clicked.connect(self._export)
|
|
1071
|
+
btns.addStretch()
|
|
1072
|
+
btns.addWidget(btn_export)
|
|
1073
|
+
|
|
1074
|
+
close_btn = QPushButton("Close")
|
|
1075
|
+
close_btn.clicked.connect(self.accept)
|
|
1076
|
+
btns.addWidget(close_btn)
|
|
1077
|
+
layout.addLayout(btns)
|
|
1078
|
+
|
|
1079
|
+
def set_default_filename(self, name: str):
|
|
1080
|
+
self._default_file = name
|
|
1081
|
+
|
|
1082
|
+
def _export(self):
|
|
1083
|
+
path, _ = QFileDialog.getSaveFileName(self, "Export Spectrum", self._default_file, "Text files (*.txt)")
|
|
1084
|
+
if not path:
|
|
1085
|
+
return
|
|
1086
|
+
try:
|
|
1087
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1088
|
+
if self._meta:
|
|
1089
|
+
for line in self._meta.splitlines():
|
|
1090
|
+
f.write(f"# {line}\n")
|
|
1091
|
+
f.write(f"# {self._xl}\t{self._yl}\n")
|
|
1092
|
+
for xi, yi in zip(self._x, self._y):
|
|
1093
|
+
f.write(f"{xi:.6f}\t{yi:.6f}\n")
|
|
1094
|
+
QMessageBox.information(self, "Export Successful", f"Data saved to {path}")
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
QMessageBox.critical(self, "Error", f"Could not export: {e}")
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1100
|
+
# CALCULATIONS DIALOG
|
|
1101
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1102
|
+
|
|
1103
|
+
class CalculationsDialog(QDialog):
|
|
1104
|
+
modeSelected = pyqtSignal(object) # VibrationalMode
|
|
1105
|
+
stateSelected = pyqtSignal(object) # ExcitedState
|
|
1106
|
+
viewSpectrum = pyqtSignal(str) # "ir" or "uvvis"
|
|
1107
|
+
animationToggled = pyqtSignal(bool)
|
|
1108
|
+
vectorsToggled = pyqtSignal(bool)
|
|
1109
|
+
|
|
1110
|
+
def __init__(self, mol: Molecule, parent=None):
|
|
1111
|
+
super().__init__(parent)
|
|
1112
|
+
self.setWindowTitle(f"Calculations — {mol.name}")
|
|
1113
|
+
self.setMinimumSize(650, 500)
|
|
1114
|
+
self.mol = mol
|
|
1115
|
+
|
|
1116
|
+
layout = QVBoxLayout(self)
|
|
1117
|
+
self.tabs = QTabWidget()
|
|
1118
|
+
layout.addWidget(self.tabs)
|
|
1119
|
+
|
|
1120
|
+
# ── General Tab (first position) ──
|
|
1121
|
+
gen_page = QWidget()
|
|
1122
|
+
gpl = QVBoxLayout(gen_page)
|
|
1123
|
+
form = QFormLayout()
|
|
1124
|
+
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
1125
|
+
|
|
1126
|
+
def sel_label(text):
|
|
1127
|
+
lbl = QLabel(text)
|
|
1128
|
+
lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
1129
|
+
return lbl
|
|
1130
|
+
|
|
1131
|
+
if mol.g16_point_group is not None:
|
|
1132
|
+
form.addRow("Point Group:", sel_label(mol.g16_point_group))
|
|
1133
|
+
if mol.g16_opt_energy is not None:
|
|
1134
|
+
form.addRow("Energy:", sel_label(f"{mol.g16_opt_energy:.8f} Ha"))
|
|
1135
|
+
elif mol.g16_scf_energy is not None:
|
|
1136
|
+
form.addRow("Energy:", sel_label(f"{mol.g16_scf_energy:.8f} Ha"))
|
|
1137
|
+
if mol.g16_rotconst is not None:
|
|
1138
|
+
a, b, c = mol.g16_rotconst
|
|
1139
|
+
rc = sel_label(f"A = {a:.4f} MHz\nB = {b:.4f} MHz\nC = {c:.4f} MHz")
|
|
1140
|
+
form.addRow("Rotational Constants:", rc)
|
|
1141
|
+
if mol.g16_dipole is not None:
|
|
1142
|
+
x, y, z, t = mol.g16_dipole
|
|
1143
|
+
dm = sel_label(f"X = {x:.4f} D\nY = {y:.4f} D\nZ = {z:.4f} D\nTotal = {t:.4f} D")
|
|
1144
|
+
form.addRow("Dipole Moment:", dm)
|
|
1145
|
+
if mol.g16_nproc is not None:
|
|
1146
|
+
form.addRow("Processors:", sel_label(str(mol.g16_nproc)))
|
|
1147
|
+
if mol.g16_mem is not None:
|
|
1148
|
+
form.addRow("Memory:", sel_label(mol.g16_mem))
|
|
1149
|
+
if mol.g16_chk is not None:
|
|
1150
|
+
form.addRow("Checkpoint:", sel_label(mol.g16_chk))
|
|
1151
|
+
|
|
1152
|
+
if form.rowCount() > 0:
|
|
1153
|
+
gpl.addLayout(form)
|
|
1154
|
+
gpl.addStretch()
|
|
1155
|
+
self.tabs.addTab(gen_page, "General")
|
|
1156
|
+
|
|
1157
|
+
# ── Frequencies Tab ──
|
|
1158
|
+
if mol.vibrational_modes:
|
|
1159
|
+
freq_page = QWidget()
|
|
1160
|
+
fpl = QVBoxLayout(freq_page)
|
|
1161
|
+
|
|
1162
|
+
self._freq_table = QTableWidget(len(mol.vibrational_modes), 3)
|
|
1163
|
+
self._freq_table.setHorizontalHeaderLabels(["Mode", "Freq (cm⁻¹)", "Intensity (km/mol)"])
|
|
1164
|
+
self._freq_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
1165
|
+
self._freq_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
|
1166
|
+
self._freq_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
1167
|
+
self._freq_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
1168
|
+
|
|
1169
|
+
for i, m in enumerate(mol.vibrational_modes):
|
|
1170
|
+
self._freq_table.setItem(i, 0, QTableWidgetItem(str(m.index)))
|
|
1171
|
+
self._freq_table.setItem(i, 1, QTableWidgetItem(f"{m.frequency:.2f}"))
|
|
1172
|
+
self._freq_table.setItem(i, 2, QTableWidgetItem(f"{m.intensity:.2f}"))
|
|
1173
|
+
|
|
1174
|
+
self._freq_table.itemSelectionChanged.connect(lambda t=self._freq_table: self._on_freq_sel(t))
|
|
1175
|
+
fpl.addWidget(self._freq_table)
|
|
1176
|
+
|
|
1177
|
+
btn_ir = QPushButton("View IR Spectrum…")
|
|
1178
|
+
btn_ir.clicked.connect(lambda: self.viewSpectrum.emit("ir"))
|
|
1179
|
+
fpl.addWidget(btn_ir)
|
|
1180
|
+
|
|
1181
|
+
# Animation control
|
|
1182
|
+
self._anim_check = QCheckBox("Show animation")
|
|
1183
|
+
self._anim_check.setObjectName("dim")
|
|
1184
|
+
self._anim_check.toggled.connect(self.animationToggled.emit)
|
|
1185
|
+
fpl.addWidget(self._anim_check)
|
|
1186
|
+
|
|
1187
|
+
# Vector control
|
|
1188
|
+
self._vector_check = QCheckBox("Show displacement vectors")
|
|
1189
|
+
self._vector_check.setChecked(False)
|
|
1190
|
+
self._vector_check.setObjectName("dim")
|
|
1191
|
+
self._vector_check.toggled.connect(self.vectorsToggled.emit)
|
|
1192
|
+
fpl.addWidget(self._vector_check)
|
|
1193
|
+
|
|
1194
|
+
btn_export_freq = QPushButton("Export Data (.txt)")
|
|
1195
|
+
btn_export_freq.clicked.connect(lambda: self._export_table(self._freq_table, "frequencies"))
|
|
1196
|
+
fpl.addWidget(btn_export_freq)
|
|
1197
|
+
|
|
1198
|
+
self.tabs.addTab(freq_page, "Frequencies")
|
|
1199
|
+
|
|
1200
|
+
# ── TDDFT Tab ──
|
|
1201
|
+
if mol.excited_states:
|
|
1202
|
+
td_page = QWidget()
|
|
1203
|
+
tpl = QVBoxLayout(td_page)
|
|
1204
|
+
|
|
1205
|
+
self._state_table = QTableWidget(len(mol.excited_states), 4)
|
|
1206
|
+
self._state_table.setHorizontalHeaderLabels(["State", "Energy (eV)", "Wavelength (nm)", "f"])
|
|
1207
|
+
self._state_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
1208
|
+
self._state_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
|
1209
|
+
self._state_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
1210
|
+
self._state_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
1211
|
+
|
|
1212
|
+
for i, s in enumerate(mol.excited_states):
|
|
1213
|
+
self._state_table.setItem(i, 0, QTableWidgetItem(f"S{s.index} ({s.symmetry})"))
|
|
1214
|
+
self._state_table.setItem(i, 1, QTableWidgetItem(f"{s.energy_ev:.4f}"))
|
|
1215
|
+
self._state_table.setItem(i, 2, QTableWidgetItem(f"{s.wavelength_nm:.2f}"))
|
|
1216
|
+
self._state_table.setItem(i, 3, QTableWidgetItem(f"{s.oscillator_strength:.4f}"))
|
|
1217
|
+
|
|
1218
|
+
self._state_table.itemSelectionChanged.connect(lambda t=self._state_table: self._on_state_sel(t))
|
|
1219
|
+
tpl.addWidget(self._state_table)
|
|
1220
|
+
|
|
1221
|
+
btn_uv = QPushButton("View UV-Vis Spectrum…")
|
|
1222
|
+
btn_uv.clicked.connect(lambda: self.viewSpectrum.emit("uvvis"))
|
|
1223
|
+
tpl.addWidget(btn_uv)
|
|
1224
|
+
|
|
1225
|
+
btn_export_state = QPushButton("Export Data (.txt)")
|
|
1226
|
+
btn_export_state.clicked.connect(lambda: self._export_table(self._state_table, "excited_states"))
|
|
1227
|
+
tpl.addWidget(btn_export_state)
|
|
1228
|
+
|
|
1229
|
+
self.tabs.addTab(td_page, "Excited States (TDDFT)")
|
|
1230
|
+
|
|
1231
|
+
close_btn = QPushButton("Close")
|
|
1232
|
+
close_btn.clicked.connect(self.accept)
|
|
1233
|
+
layout.addWidget(close_btn)
|
|
1234
|
+
|
|
1235
|
+
def _on_freq_sel(self, table):
|
|
1236
|
+
rows = table.selectedItems()
|
|
1237
|
+
if not rows: return
|
|
1238
|
+
idx = int(rows[0].text()) - 1
|
|
1239
|
+
self.modeSelected.emit(self.mol.vibrational_modes[idx])
|
|
1240
|
+
|
|
1241
|
+
def _on_state_sel(self, table):
|
|
1242
|
+
rows = table.selectedItems()
|
|
1243
|
+
if not rows: return
|
|
1244
|
+
# Extract "S1" -> 1
|
|
1245
|
+
row_text = rows[0].text()
|
|
1246
|
+
idx = int(row_text.split()[0][1:]) - 1
|
|
1247
|
+
self.stateSelected.emit(self.mol.excited_states[idx])
|
|
1248
|
+
|
|
1249
|
+
def _export_table(self, table, kind):
|
|
1250
|
+
safe = get_safe_filename(self.mol.name)
|
|
1251
|
+
default_file = f"{safe}_{kind}.txt"
|
|
1252
|
+
path, _ = QFileDialog.getSaveFileName(self, "Export Table", default_file, "Text files (*.txt)")
|
|
1253
|
+
if not path:
|
|
1254
|
+
return
|
|
1255
|
+
try:
|
|
1256
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1257
|
+
f.write(f"# Molecule: {self.mol.name}\n")
|
|
1258
|
+
f.write(f"# Calculation: {kind}\n")
|
|
1259
|
+
headers = [table.horizontalHeaderItem(i).text() for i in range(table.columnCount())]
|
|
1260
|
+
f.write("# " + "\t".join(headers) + "\n")
|
|
1261
|
+
for row in range(table.rowCount()):
|
|
1262
|
+
vals = []
|
|
1263
|
+
for col in range(table.columnCount()):
|
|
1264
|
+
item = table.item(row, col)
|
|
1265
|
+
vals.append(item.text() if item else "")
|
|
1266
|
+
f.write("\t".join(vals) + "\n")
|
|
1267
|
+
QMessageBox.information(self, "Export Successful", f"Data saved to {path}")
|
|
1268
|
+
except Exception as e:
|
|
1269
|
+
QMessageBox.critical(self, "Error", f"Could not export: {e}")
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1273
|
+
# G16 INPUT GENERATOR DIALOG
|
|
1274
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1275
|
+
|
|
1276
|
+
class G16InputDialog(QDialog):
|
|
1277
|
+
def __init__(self, mol, parent=None):
|
|
1278
|
+
super().__init__(parent)
|
|
1279
|
+
self.setWindowTitle("Generate G16 Input")
|
|
1280
|
+
self.setMinimumSize(680, 540)
|
|
1281
|
+
self.setStyleSheet("""
|
|
1282
|
+
QComboBox:disabled, QSpinBox:disabled {
|
|
1283
|
+
background: #e8e8e8;
|
|
1284
|
+
color: #aaa;
|
|
1285
|
+
border: 1px solid #ccc;
|
|
1286
|
+
}
|
|
1287
|
+
""")
|
|
1288
|
+
self.mol = mol
|
|
1289
|
+
self._generating = False
|
|
1290
|
+
self._last_generated = ""
|
|
1291
|
+
self._loaded_route = None
|
|
1292
|
+
self._all_fields = []
|
|
1293
|
+
|
|
1294
|
+
layout = QVBoxLayout(self)
|
|
1295
|
+
|
|
1296
|
+
# ── Route card fields (each with custom override) ──
|
|
1297
|
+
form = QFormLayout()
|
|
1298
|
+
form.setSpacing(6)
|
|
1299
|
+
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
1300
|
+
|
|
1301
|
+
def combo_row(items):
|
|
1302
|
+
row = QHBoxLayout()
|
|
1303
|
+
combo = QComboBox()
|
|
1304
|
+
combo.addItems(items + ["Custom"])
|
|
1305
|
+
combo.setMinimumWidth(120)
|
|
1306
|
+
row.addWidget(combo)
|
|
1307
|
+
custom = QLineEdit()
|
|
1308
|
+
custom.setPlaceholderText("Enter custom value…")
|
|
1309
|
+
custom.setFixedWidth(180)
|
|
1310
|
+
custom.setVisible(False)
|
|
1311
|
+
row.addWidget(custom)
|
|
1312
|
+
row.addStretch()
|
|
1313
|
+
return combo, custom, row
|
|
1314
|
+
|
|
1315
|
+
self._job_combo, self._job_custom, j_row = combo_row(
|
|
1316
|
+
["opt", "freq", "opt freq", "sp", "scan"])
|
|
1317
|
+
form.addRow("Job type:", j_row)
|
|
1318
|
+
|
|
1319
|
+
self._method_combo, self._method_custom, m_row = combo_row(
|
|
1320
|
+
["b3lyp", "wb97xd", "m062x", "mp2", "hf", "ccsd(t)"])
|
|
1321
|
+
form.addRow("Method:", m_row)
|
|
1322
|
+
|
|
1323
|
+
self._basis_combo, self._basis_custom, b_row = combo_row(
|
|
1324
|
+
["6-31g(d)", "6-311+g(d,p)", "aug-cc-pvdz", "def2svp", "def2tzvp"])
|
|
1325
|
+
form.addRow("Basis set:", b_row)
|
|
1326
|
+
|
|
1327
|
+
# Toggle custom field visibility and reconnect signals
|
|
1328
|
+
for combo, custom in [(self._job_combo, self._job_custom),
|
|
1329
|
+
(self._method_combo, self._method_custom),
|
|
1330
|
+
(self._basis_combo, self._basis_custom)]:
|
|
1331
|
+
combo.currentTextChanged.connect(
|
|
1332
|
+
lambda t, c=custom: c.setVisible(t == "Custom"))
|
|
1333
|
+
combo.currentTextChanged.connect(self._on_field_changed)
|
|
1334
|
+
custom.textChanged.connect(self._on_field_changed)
|
|
1335
|
+
|
|
1336
|
+
btn_style = """
|
|
1337
|
+
QSpinBox::up-button, QSpinBox::down-button { width: 24px; }
|
|
1338
|
+
QSpinBox:disabled { background: #e8e8e8; color: #aaa; border: 1px solid #ccc; }
|
|
1339
|
+
"""
|
|
1340
|
+
|
|
1341
|
+
chg_layout = QHBoxLayout()
|
|
1342
|
+
self._charge_spin = QSpinBox()
|
|
1343
|
+
self._charge_spin.setRange(-10, 10)
|
|
1344
|
+
self._charge_spin.setValue(mol.charge)
|
|
1345
|
+
self._charge_spin.setMinimumWidth(90)
|
|
1346
|
+
self._charge_spin.setStyleSheet(btn_style)
|
|
1347
|
+
self._charge_spin.valueChanged.connect(self._on_field_changed)
|
|
1348
|
+
chg_layout.addWidget(self._charge_spin)
|
|
1349
|
+
chg_layout.addSpacing(4)
|
|
1350
|
+
chg_layout.addWidget(QLabel("Mult:"))
|
|
1351
|
+
self._mult_spin = QSpinBox()
|
|
1352
|
+
self._mult_spin.setRange(1, 10)
|
|
1353
|
+
self._mult_spin.setValue(1)
|
|
1354
|
+
self._mult_spin.setMinimumWidth(90)
|
|
1355
|
+
self._mult_spin.setStyleSheet(btn_style)
|
|
1356
|
+
self._mult_spin.valueChanged.connect(self._on_field_changed)
|
|
1357
|
+
chg_layout.addWidget(self._mult_spin)
|
|
1358
|
+
chg_layout.addStretch()
|
|
1359
|
+
form.addRow("Charge / Mult:", chg_layout)
|
|
1360
|
+
|
|
1361
|
+
proc_row = QHBoxLayout()
|
|
1362
|
+
self._nproc_spin = QSpinBox()
|
|
1363
|
+
self._nproc_spin.setRange(1, 64)
|
|
1364
|
+
self._nproc_spin.setValue(4)
|
|
1365
|
+
self._nproc_spin.setMinimumWidth(90)
|
|
1366
|
+
self._nproc_spin.setStyleSheet(btn_style)
|
|
1367
|
+
self._nproc_spin.valueChanged.connect(self._on_field_changed)
|
|
1368
|
+
proc_row.addWidget(self._nproc_spin)
|
|
1369
|
+
proc_row.addStretch()
|
|
1370
|
+
form.addRow("Processors:", proc_row)
|
|
1371
|
+
|
|
1372
|
+
mem_row = QHBoxLayout()
|
|
1373
|
+
self._mem_combo = QComboBox()
|
|
1374
|
+
self._mem_combo.addItems(["2", "4", "8", "16", "32", "64"])
|
|
1375
|
+
self._mem_combo.setEditable(True)
|
|
1376
|
+
self._mem_combo.setMinimumWidth(80)
|
|
1377
|
+
self._mem_combo.setCurrentText("8")
|
|
1378
|
+
self._mem_combo.currentTextChanged.connect(self._on_field_changed)
|
|
1379
|
+
mem_row.addWidget(self._mem_combo)
|
|
1380
|
+
mem_row.addStretch()
|
|
1381
|
+
form.addRow("Memory:", mem_row)
|
|
1382
|
+
|
|
1383
|
+
self._custom_pct = QLineEdit()
|
|
1384
|
+
self._custom_pct.setPlaceholderText("e.g. %chk=file.chk")
|
|
1385
|
+
self._custom_pct.textChanged.connect(self._on_field_changed)
|
|
1386
|
+
form.addRow("Custom % line:", self._custom_pct)
|
|
1387
|
+
|
|
1388
|
+
layout.addLayout(form)
|
|
1389
|
+
|
|
1390
|
+
# ── Preview (editable) ──
|
|
1391
|
+
layout.addWidget(QLabel("Preview (editable):"))
|
|
1392
|
+
self._preview = QPlainTextEdit()
|
|
1393
|
+
self._preview.setMinimumHeight(180)
|
|
1394
|
+
self._preview.setStyleSheet("font-family: monospace;")
|
|
1395
|
+
self._preview.textChanged.connect(self._on_preview_edited)
|
|
1396
|
+
layout.addWidget(self._preview)
|
|
1397
|
+
|
|
1398
|
+
# ── Buttons ──
|
|
1399
|
+
btn_layout = QHBoxLayout()
|
|
1400
|
+
btn_save = QPushButton("Save…")
|
|
1401
|
+
btn_save.clicked.connect(self._save)
|
|
1402
|
+
btn_layout.addWidget(btn_save)
|
|
1403
|
+
btn_copy_matrix = QPushButton("Copy matrix")
|
|
1404
|
+
btn_copy_matrix.clicked.connect(self._copy_matrix)
|
|
1405
|
+
btn_layout.addWidget(btn_copy_matrix)
|
|
1406
|
+
self._btn_sync = QPushButton("Sync from fields")
|
|
1407
|
+
self._btn_sync.clicked.connect(self._sync_from_fields)
|
|
1408
|
+
btn_layout.addWidget(self._btn_sync)
|
|
1409
|
+
btn_layout.addStretch()
|
|
1410
|
+
btn_cancel = QPushButton("Cancel")
|
|
1411
|
+
btn_cancel.clicked.connect(self.reject)
|
|
1412
|
+
btn_layout.addWidget(btn_cancel)
|
|
1413
|
+
layout.addLayout(btn_layout)
|
|
1414
|
+
|
|
1415
|
+
# Collect all form fields for enable/disable
|
|
1416
|
+
self._all_fields = [
|
|
1417
|
+
self._job_combo, self._method_combo, self._basis_combo,
|
|
1418
|
+
self._charge_spin, self._mult_spin, self._nproc_spin,
|
|
1419
|
+
self._mem_combo, self._custom_pct,
|
|
1420
|
+
]
|
|
1421
|
+
|
|
1422
|
+
# Pre-populate fields from loaded molecule header
|
|
1423
|
+
if mol.g16_nproc is not None:
|
|
1424
|
+
self._nproc_spin.blockSignals(True)
|
|
1425
|
+
self._nproc_spin.setValue(mol.g16_nproc)
|
|
1426
|
+
self._nproc_spin.blockSignals(False)
|
|
1427
|
+
if mol.g16_mem is not None:
|
|
1428
|
+
self._mem_combo.blockSignals(True)
|
|
1429
|
+
self._mem_combo.setCurrentText(mol.g16_mem.rstrip("GB"))
|
|
1430
|
+
self._mem_combo.blockSignals(False)
|
|
1431
|
+
if mol.g16_chk is not None:
|
|
1432
|
+
self._custom_pct.setText(f"%chk={mol.g16_chk}")
|
|
1433
|
+
if mol.g16_route is not None:
|
|
1434
|
+
self._loaded_route = mol.g16_route
|
|
1435
|
+
self._parse_route_into_fields(mol.g16_route)
|
|
1436
|
+
|
|
1437
|
+
self._sync_from_fields()
|
|
1438
|
+
|
|
1439
|
+
def _set_fields_enabled(self, enabled: bool):
|
|
1440
|
+
for w in self._all_fields:
|
|
1441
|
+
w.setEnabled(enabled)
|
|
1442
|
+
self._btn_sync.setEnabled(not enabled)
|
|
1443
|
+
|
|
1444
|
+
def _on_preview_edited(self):
|
|
1445
|
+
if self._generating:
|
|
1446
|
+
return
|
|
1447
|
+
if self._preview.toPlainText() == self._last_generated:
|
|
1448
|
+
return
|
|
1449
|
+
self._set_fields_enabled(False)
|
|
1450
|
+
|
|
1451
|
+
def _on_field_changed(self):
|
|
1452
|
+
self._loaded_route = None
|
|
1453
|
+
self._sync_from_fields()
|
|
1454
|
+
|
|
1455
|
+
def _field_val(self, combo, custom) -> str:
|
|
1456
|
+
if combo.currentText() == "Custom":
|
|
1457
|
+
txt = custom.text().strip()
|
|
1458
|
+
return txt if txt else "CUSTOM"
|
|
1459
|
+
return combo.currentText()
|
|
1460
|
+
|
|
1461
|
+
def _set_combo_field(self, combo, custom, value):
|
|
1462
|
+
if not value:
|
|
1463
|
+
return
|
|
1464
|
+
items = [combo.itemText(i).lower() for i in range(combo.count())]
|
|
1465
|
+
if value.lower() in items:
|
|
1466
|
+
idx = items.index(value.lower())
|
|
1467
|
+
combo.blockSignals(True)
|
|
1468
|
+
combo.setCurrentIndex(idx)
|
|
1469
|
+
combo.blockSignals(False)
|
|
1470
|
+
custom.setVisible(False)
|
|
1471
|
+
else:
|
|
1472
|
+
combo.blockSignals(True)
|
|
1473
|
+
combo.setCurrentText("Custom")
|
|
1474
|
+
combo.blockSignals(False)
|
|
1475
|
+
custom.blockSignals(True)
|
|
1476
|
+
custom.setText(value)
|
|
1477
|
+
custom.setVisible(True)
|
|
1478
|
+
custom.blockSignals(False)
|
|
1479
|
+
|
|
1480
|
+
def _parse_route_into_fields(self, route: str):
|
|
1481
|
+
text = route.lstrip("#").strip()
|
|
1482
|
+
tokens = text.split()
|
|
1483
|
+
if not tokens:
|
|
1484
|
+
return
|
|
1485
|
+
slash_idx = -1
|
|
1486
|
+
for i, tok in enumerate(tokens):
|
|
1487
|
+
if "/" in tok:
|
|
1488
|
+
slash_idx = i
|
|
1489
|
+
if slash_idx >= 0:
|
|
1490
|
+
method_basis = tokens[slash_idx]
|
|
1491
|
+
parts = method_basis.split("/", 1)
|
|
1492
|
+
method_val = parts[0]
|
|
1493
|
+
basis_val = parts[1] if len(parts) > 1 else ""
|
|
1494
|
+
if slash_idx + 1 < len(tokens):
|
|
1495
|
+
basis_val = basis_val + " " + " ".join(tokens[slash_idx + 1:])
|
|
1496
|
+
job_val = " ".join(tokens[:slash_idx])
|
|
1497
|
+
else:
|
|
1498
|
+
job_val = text
|
|
1499
|
+
method_val = ""
|
|
1500
|
+
basis_val = ""
|
|
1501
|
+
self._set_combo_field(self._job_combo, self._job_custom, job_val)
|
|
1502
|
+
self._set_combo_field(self._method_combo, self._method_custom, method_val)
|
|
1503
|
+
self._set_combo_field(self._basis_combo, self._basis_custom, basis_val)
|
|
1504
|
+
|
|
1505
|
+
def _build_route(self) -> str:
|
|
1506
|
+
job = self._field_val(self._job_combo, self._job_custom)
|
|
1507
|
+
method = self._field_val(self._method_combo, self._method_custom)
|
|
1508
|
+
basis = self._field_val(self._basis_combo, self._basis_custom)
|
|
1509
|
+
return f"# {job} {method}/{basis}"
|
|
1510
|
+
|
|
1511
|
+
def _build_text(self) -> str:
|
|
1512
|
+
nproc = self._nproc_spin.value()
|
|
1513
|
+
mem = self._mem_combo.currentText().rstrip("GB") + "GB"
|
|
1514
|
+
route = self._loaded_route if self._loaded_route else self._build_route()
|
|
1515
|
+
charge = self._charge_spin.value()
|
|
1516
|
+
mult = self._mult_spin.value()
|
|
1517
|
+
custom_pct = self._custom_pct.text().strip()
|
|
1518
|
+
|
|
1519
|
+
header = [f"%nprocshared={nproc}", f"%mem={mem}"]
|
|
1520
|
+
if custom_pct:
|
|
1521
|
+
header.append(custom_pct)
|
|
1522
|
+
header.append(route)
|
|
1523
|
+
|
|
1524
|
+
lines = [
|
|
1525
|
+
*header,
|
|
1526
|
+
"",
|
|
1527
|
+
self.mol.name,
|
|
1528
|
+
"",
|
|
1529
|
+
f"{charge} {mult}",
|
|
1530
|
+
]
|
|
1531
|
+
for a in self.mol.atoms:
|
|
1532
|
+
lines.append(f"{a.element:3s} {a.x:12.6f} {a.y:12.6f} {a.z:12.6f}")
|
|
1533
|
+
lines.extend(["", "", ""])
|
|
1534
|
+
return "\n".join(lines)
|
|
1535
|
+
|
|
1536
|
+
def _sync_from_fields(self):
|
|
1537
|
+
self._generating = True
|
|
1538
|
+
self._last_generated = self._build_text()
|
|
1539
|
+
self._preview.setPlainText(self._last_generated)
|
|
1540
|
+
self._generating = False
|
|
1541
|
+
self._set_fields_enabled(True)
|
|
1542
|
+
self._btn_sync.setEnabled(False)
|
|
1543
|
+
|
|
1544
|
+
def _copy_matrix(self):
|
|
1545
|
+
lines = []
|
|
1546
|
+
for a in self.mol.atoms:
|
|
1547
|
+
lines.append(f"{a.element:3s} {a.x:12.6f} {a.y:12.6f} {a.z:12.6f}")
|
|
1548
|
+
QApplication.clipboard().setText("\n".join(lines))
|
|
1549
|
+
|
|
1550
|
+
def _save(self):
|
|
1551
|
+
safe = self.mol.name.replace(" ", "_").replace("/", "_") or "input"
|
|
1552
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
1553
|
+
self, "Save Gaussian Input", f"{safe}.gjf",
|
|
1554
|
+
"Gaussian input (*.gjf *.com);;All files (*)"
|
|
1555
|
+
)
|
|
1556
|
+
if not path:
|
|
1557
|
+
return
|
|
1558
|
+
try:
|
|
1559
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1560
|
+
f.write(self._preview.toPlainText())
|
|
1561
|
+
QMessageBox.information(self, "Saved", f"Gaussian input saved to:\n{path}")
|
|
1562
|
+
except Exception as e:
|
|
1563
|
+
QMessageBox.critical(self, "Error", f"Could not save: {e}")
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1567
|
+
# CPK LEGEND PANEL (sidebar)
|
|
1568
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1569
|
+
|
|
1570
|
+
class LegendPanel(QGroupBox):
|
|
1571
|
+
elementColorChanged = pyqtSignal(str, str) # (element_symbol, hex_color)
|
|
1572
|
+
atomColorChanged = pyqtSignal(int, str) # (atom_index, hex_color)
|
|
1573
|
+
|
|
1574
|
+
def __init__(self, parent=None):
|
|
1575
|
+
super().__init__("Elements", parent)
|
|
1576
|
+
self._layout = QVBoxLayout(self)
|
|
1577
|
+
self._layout.setSpacing(4)
|
|
1578
|
+
self._layout.setContentsMargins(10,14,10,10)
|
|
1579
|
+
|
|
1580
|
+
self._rows: list = []
|
|
1581
|
+
self._custom_atom_map: Dict[str, int] = {}
|
|
1582
|
+
|
|
1583
|
+
def update_for(self, mol: Molecule, overrides: dict):
|
|
1584
|
+
for w in self._rows:
|
|
1585
|
+
w.setParent(None)
|
|
1586
|
+
self._rows.clear()
|
|
1587
|
+
self._custom_atom_map.clear()
|
|
1588
|
+
|
|
1589
|
+
# Standard entries — one per element for atoms without custom colors
|
|
1590
|
+
seen_elem: set = set()
|
|
1591
|
+
for atom in mol.atoms:
|
|
1592
|
+
if atom.color:
|
|
1593
|
+
continue
|
|
1594
|
+
e = atom.element
|
|
1595
|
+
if e in seen_elem:
|
|
1596
|
+
continue
|
|
1597
|
+
seen_elem.add(e)
|
|
1598
|
+
color = overrides.get(e, CPK_BASE.get(e, "#cc44aa"))
|
|
1599
|
+
name = ELEM_FULL_NAME.get(e, e)
|
|
1600
|
+
self._add_row(e, color, f"{e} - {name}", clickable=True)
|
|
1601
|
+
|
|
1602
|
+
# Custom entries — atoms with per-atom color override
|
|
1603
|
+
custom_counts: Dict[str, int] = {}
|
|
1604
|
+
for idx, atom in enumerate(mol.atoms):
|
|
1605
|
+
if not atom.color:
|
|
1606
|
+
continue
|
|
1607
|
+
e = atom.element
|
|
1608
|
+
custom_counts[e] = custom_counts.get(e, 0) + 1
|
|
1609
|
+
n = custom_counts[e]
|
|
1610
|
+
label = f"{e} - Custom {n}"
|
|
1611
|
+
self._custom_atom_map[label] = idx
|
|
1612
|
+
self._add_row(label, atom.color, label, clickable=True, custom=True)
|
|
1613
|
+
|
|
1614
|
+
def _add_row(self, key: str, color: str, label: str, clickable: bool = True, custom: bool = False):
|
|
1615
|
+
row = QWidget()
|
|
1616
|
+
rl = QHBoxLayout(row)
|
|
1617
|
+
rl.setContentsMargins(0,0,0,0)
|
|
1618
|
+
rl.setSpacing(7)
|
|
1619
|
+
|
|
1620
|
+
swatch = QPushButton()
|
|
1621
|
+
swatch.setFixedSize(14, 14)
|
|
1622
|
+
swatch.setStyleSheet(
|
|
1623
|
+
f"background:{color}; border-radius:7px; border:1px solid #555;"
|
|
1624
|
+
)
|
|
1625
|
+
if clickable:
|
|
1626
|
+
swatch.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
1627
|
+
swatch.setToolTip(f"Click to change {label} colour")
|
|
1628
|
+
if custom:
|
|
1629
|
+
swatch.clicked.connect(lambda _, lbl=label, c=color: self._pick_atom_color(lbl, c))
|
|
1630
|
+
else:
|
|
1631
|
+
swatch.clicked.connect(lambda _, e=key, c=color: self._pick_color(e, c))
|
|
1632
|
+
|
|
1633
|
+
lbl = QLabel(label)
|
|
1634
|
+
lbl.setObjectName("dim")
|
|
1635
|
+
rl.addWidget(swatch)
|
|
1636
|
+
rl.addWidget(lbl)
|
|
1637
|
+
rl.addStretch()
|
|
1638
|
+
|
|
1639
|
+
self._layout.addWidget(row)
|
|
1640
|
+
self._rows.append(row)
|
|
1641
|
+
|
|
1642
|
+
def _pick_color(self, elem: str, current_hex: str):
|
|
1643
|
+
dlg = QColorDialog(QColor(current_hex), self)
|
|
1644
|
+
dlg.setWindowTitle(f"Pick Colour for {ELEM_FULL_NAME.get(elem, elem)}")
|
|
1645
|
+
if dlg.exec():
|
|
1646
|
+
new_hex = dlg.selectedColor().name()
|
|
1647
|
+
self.elementColorChanged.emit(elem, new_hex)
|
|
1648
|
+
|
|
1649
|
+
self._layout.addStretch()
|
|
1650
|
+
|
|
1651
|
+
def _pick_atom_color(self, label: str, current_hex: str):
|
|
1652
|
+
idx = self._custom_atom_map.get(label)
|
|
1653
|
+
if idx is None:
|
|
1654
|
+
return
|
|
1655
|
+
dlg = QColorDialog(QColor(current_hex), self)
|
|
1656
|
+
dlg.setWindowTitle(f"Pick Colour for {label}")
|
|
1657
|
+
if dlg.exec():
|
|
1658
|
+
new_hex = dlg.selectedColor().name()
|
|
1659
|
+
self.atomColorChanged.emit(idx, new_hex)
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1663
|
+
# PERIODIC TABLE DIALOG
|
|
1664
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1665
|
+
|
|
1666
|
+
_PERIODIC_TABLE_LAYOUT = [
|
|
1667
|
+
# (symbol, Z, row, col)
|
|
1668
|
+
("H",1,0,0), ("D",0,0,4), ("T",-1,0,5), ("13C",600,0,6), ("14C",601,0,7), ("15N",700,0,8), ("He",2,0,17),
|
|
1669
|
+
("Li",3,1,0), ("Be",4,1,1), ("B",5,1,12), ("C",6,1,13), ("N",7,1,14), ("O",8,1,15), ("F",9,1,16), ("Ne",10,1,17),
|
|
1670
|
+
("Na",11,2,0), ("Mg",12,2,1), ("Al",13,2,12), ("Si",14,2,13), ("P",15,2,14), ("S",16,2,15), ("Cl",17,2,16), ("Ar",18,2,17),
|
|
1671
|
+
("K",19,3,0), ("Ca",20,3,1), ("Sc",21,3,2), ("Ti",22,3,3), ("V",23,3,4), ("Cr",24,3,5), ("Mn",25,3,6), ("Fe",26,3,7), ("Co",27,3,8), ("Ni",28,3,9), ("Cu",29,3,10), ("Zn",30,3,11), ("Ga",31,3,12), ("Ge",32,3,13), ("As",33,3,14), ("Se",34,3,15), ("Br",35,3,16), ("Kr",36,3,17),
|
|
1672
|
+
("Rb",37,4,0), ("Sr",38,4,1), ("Y",39,4,2), ("Zr",40,4,3), ("Nb",41,4,4), ("Mo",42,4,5), ("Tc",43,4,6), ("Ru",44,4,7), ("Rh",45,4,8), ("Pd",46,4,9), ("Ag",47,4,10), ("Cd",48,4,11), ("In",49,4,12), ("Sn",50,4,13), ("Sb",51,4,14), ("Te",52,4,15), ("I",53,4,16), ("Xe",54,4,17),
|
|
1673
|
+
("Cs",55,5,0), ("Ba",56,5,1), ("La",57,5,2), ("Hf",72,5,3), ("Ta",73,5,4), ("W",74,5,5), ("Re",75,5,6), ("Os",76,5,7), ("Ir",77,5,8), ("Pt",78,5,9), ("Au",79,5,10), ("Hg",80,5,11), ("Tl",81,5,12), ("Pb",82,5,13), ("Bi",83,5,14), ("Po",84,5,15), ("At",85,5,16), ("Rn",86,5,17),
|
|
1674
|
+
("Fr",87,6,0), ("Ra",88,6,1), ("Ac",89,6,2), ("Rf",104,6,3), ("Db",105,6,4), ("Sg",106,6,5), ("Bh",107,6,6), ("Hs",108,6,7), ("Mt",109,6,8), ("Ds",110,6,9), ("Rg",111,6,10), ("Cn",112,6,11), ("Nh",113,6,12), ("Fl",114,6,13), ("Mc",115,6,14), ("Lv",116,6,15), ("Ts",117,6,16), ("Og",118,6,17),
|
|
1675
|
+
# F-block: lanthanides
|
|
1676
|
+
("Ce",58,7,2), ("Pr",59,7,3), ("Nd",60,7,4), ("Pm",61,7,5), ("Sm",62,7,6), ("Eu",63,7,7), ("Gd",64,7,8), ("Tb",65,7,9), ("Dy",66,7,10), ("Ho",67,7,11), ("Er",68,7,12), ("Tm",69,7,13), ("Yb",70,7,14), ("Lu",71,7,15),
|
|
1677
|
+
# F-block: actinides
|
|
1678
|
+
("Th",90,8,2), ("Pa",91,8,3), ("U",92,8,4), ("Np",93,8,5), ("Pu",94,8,6), ("Am",95,8,7), ("Cm",96,8,8), ("Bk",97,8,9), ("Cf",98,8,10), ("Es",99,8,11), ("Fm",100,8,12), ("Md",101,8,13), ("No",102,8,14), ("Lr",103,8,15),
|
|
1679
|
+
]
|
|
1680
|
+
|
|
1681
|
+
_ELEM_CATEGORY_COLORS = {
|
|
1682
|
+
"nonmetal": "#4CAF50",
|
|
1683
|
+
"noble_gas": "#9C27B0",
|
|
1684
|
+
"alkali_metal": "#FF5722",
|
|
1685
|
+
"alkaline_earth": "#FF9800",
|
|
1686
|
+
"metalloid": "#00BCD4",
|
|
1687
|
+
"halogen": "#1E88E5",
|
|
1688
|
+
"transition": "#42A5F5",
|
|
1689
|
+
"post_transition":"#78909C",
|
|
1690
|
+
"lanthanide": "#E91E63",
|
|
1691
|
+
"actinide": "#D32F2F",
|
|
1692
|
+
"unknown": "#757575",
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
def _element_category(sym: str, Z: int) -> str:
|
|
1696
|
+
if sym in ("D", "T", "13C", "14C", "15N"): return "nonmetal"
|
|
1697
|
+
if Z == 1: return "nonmetal"
|
|
1698
|
+
if Z == 2: return "noble_gas"
|
|
1699
|
+
if 3 <= Z <= 4: return "alkali_metal" if Z == 3 else "alkaline_earth"
|
|
1700
|
+
if 5 <= Z <= 10:
|
|
1701
|
+
if Z == 5: return "metalloid"
|
|
1702
|
+
return "nonmetal" if Z in (6,7,8) else "halogen" if Z in (9,17,35,53,85,117) else "noble_gas"
|
|
1703
|
+
if 11 <= Z <= 18:
|
|
1704
|
+
if Z == 11: return "alkali_metal"
|
|
1705
|
+
if Z == 12: return "alkaline_earth"
|
|
1706
|
+
if Z in (13,): return "post_transition"
|
|
1707
|
+
if Z in (14,): return "metalloid"
|
|
1708
|
+
if Z in (15,16): return "nonmetal"
|
|
1709
|
+
if Z == 17: return "halogen"
|
|
1710
|
+
if Z == 18: return "noble_gas"
|
|
1711
|
+
if 19 <= Z <= 36:
|
|
1712
|
+
if Z in (19,37,55,87): return "alkali_metal"
|
|
1713
|
+
if Z in (20,38,56,88): return "alkaline_earth"
|
|
1714
|
+
if 21 <= Z <= 30: return "transition"
|
|
1715
|
+
if 31 <= Z <= 36:
|
|
1716
|
+
if Z == 31: return "post_transition"
|
|
1717
|
+
if Z in (32,): return "metalloid"
|
|
1718
|
+
if Z in (33,34): return "metalloid" if Z == 33 else "nonmetal"
|
|
1719
|
+
return "halogen" if Z == 35 else "noble_gas"
|
|
1720
|
+
if 37 <= Z <= 54:
|
|
1721
|
+
if Z in (37,55,87): return "alkali_metal"
|
|
1722
|
+
if Z in (38,56,88): return "alkaline_earth"
|
|
1723
|
+
if Z == 39 or (40 <= Z <= 48):
|
|
1724
|
+
return "transition"
|
|
1725
|
+
if 49 <= Z <= 54:
|
|
1726
|
+
if Z == 49: return "post_transition"
|
|
1727
|
+
if Z == 50: return "post_transition"
|
|
1728
|
+
if Z == 51: return "metalloid"
|
|
1729
|
+
if Z == 52: return "metalloid"
|
|
1730
|
+
return "halogen" if Z == 53 else "noble_gas"
|
|
1731
|
+
if 55 <= Z <= 86:
|
|
1732
|
+
if Z in (55,87): return "alkali_metal"
|
|
1733
|
+
if Z in (56,88): return "alkaline_earth"
|
|
1734
|
+
if 57 <= Z <= 71: return "lanthanide"
|
|
1735
|
+
if 72 <= Z <= 80: return "transition"
|
|
1736
|
+
if 81 <= Z <= 86:
|
|
1737
|
+
if Z == 81: return "post_transition"
|
|
1738
|
+
if Z in (82,83): return "post_transition"
|
|
1739
|
+
if Z in (84,): return "post_transition"
|
|
1740
|
+
return "halogen" if Z == 85 else "noble_gas"
|
|
1741
|
+
if 89 <= Z <= 103: return "actinide"
|
|
1742
|
+
if 104 <= Z <= 111: return "transition"
|
|
1743
|
+
if 112 <= Z <= 118:
|
|
1744
|
+
if Z in (113,114,115,116): return "post_transition"
|
|
1745
|
+
if Z == 117: return "halogen"
|
|
1746
|
+
if Z == 118: return "noble_gas"
|
|
1747
|
+
return "unknown"
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
class PeriodicTableDialog(QDialog):
|
|
1751
|
+
elementSelected = pyqtSignal(str)
|
|
1752
|
+
|
|
1753
|
+
def __init__(self, parent=None, current_element: str = "C"):
|
|
1754
|
+
super().__init__(parent)
|
|
1755
|
+
self.setWindowTitle("Select Element — Periodic Table")
|
|
1756
|
+
self.setModal(True)
|
|
1757
|
+
self.setMinimumSize(1020, 640)
|
|
1758
|
+
|
|
1759
|
+
layout = QVBoxLayout(self)
|
|
1760
|
+
layout.setSpacing(4)
|
|
1761
|
+
|
|
1762
|
+
grid = QGridLayout()
|
|
1763
|
+
grid.setSpacing(3)
|
|
1764
|
+
|
|
1765
|
+
btn_size = 52
|
|
1766
|
+
btn_font = QFont("", 10, QFont.Weight.Bold)
|
|
1767
|
+
|
|
1768
|
+
by_Z = {}
|
|
1769
|
+
for sym, Z, r, c in _PERIODIC_TABLE_LAYOUT:
|
|
1770
|
+
by_Z[Z] = (sym, r, c)
|
|
1771
|
+
|
|
1772
|
+
self._all_buttons: Dict[str, QPushButton] = {}
|
|
1773
|
+
|
|
1774
|
+
for sym, Z, r, c in _PERIODIC_TABLE_LAYOUT:
|
|
1775
|
+
btn = QPushButton(sym)
|
|
1776
|
+
btn.setFixedSize(btn_size, btn_size)
|
|
1777
|
+
btn.setFont(btn_font)
|
|
1778
|
+
tooltip = f"{sym} ({Z}) — {ELEM_FULL_NAME.get(sym, 'Unknown')}"
|
|
1779
|
+
btn.setToolTip(tooltip)
|
|
1780
|
+
|
|
1781
|
+
cat = _element_category(sym, Z)
|
|
1782
|
+
bg = _ELEM_CATEGORY_COLORS.get(cat, "#757575")
|
|
1783
|
+
is_dark = sum(int(bg[i:i+2],16) for i in (1,3,5)) < 400
|
|
1784
|
+
text_color = "#ffffff" if is_dark else "#000000"
|
|
1785
|
+
btn.setStyleSheet(
|
|
1786
|
+
f"QPushButton {{ background:{bg}; color:{text_color}; "
|
|
1787
|
+
f"border:1px solid rgba(0,0,0,0.2); border-radius:4px; }}"
|
|
1788
|
+
f"QPushButton:hover {{ border:2px solid white; }}"
|
|
1789
|
+
)
|
|
1790
|
+
|
|
1791
|
+
is_current = (sym == current_element)
|
|
1792
|
+
if is_current:
|
|
1793
|
+
btn.setStyleSheet(
|
|
1794
|
+
f"QPushButton {{ background:{bg}; color:{text_color}; "
|
|
1795
|
+
f"border:2px solid #FFD700; border-radius:4px; }}"
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
def _make_handler(s):
|
|
1799
|
+
return lambda checked, sym=s: self._select_element(sym)
|
|
1800
|
+
|
|
1801
|
+
btn.clicked.connect(_make_handler(sym))
|
|
1802
|
+
grid.addWidget(btn, r, c)
|
|
1803
|
+
self._all_buttons[sym] = btn
|
|
1804
|
+
|
|
1805
|
+
# Headers for characteristic rows
|
|
1806
|
+
label_font = QFont("", 8)
|
|
1807
|
+
lbl_la = QLabel("Lanthanides")
|
|
1808
|
+
lbl_la.setFont(label_font)
|
|
1809
|
+
lbl_la.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
1810
|
+
grid.addWidget(lbl_la, 7, 0, 1, 2)
|
|
1811
|
+
|
|
1812
|
+
lbl_ac = QLabel("Actinides")
|
|
1813
|
+
lbl_ac.setFont(label_font)
|
|
1814
|
+
lbl_ac.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
1815
|
+
grid.addWidget(lbl_ac, 8, 0, 1, 2)
|
|
1816
|
+
|
|
1817
|
+
layout.addLayout(grid)
|
|
1818
|
+
|
|
1819
|
+
# Close button
|
|
1820
|
+
btn_layout = QHBoxLayout()
|
|
1821
|
+
btn_layout.addStretch()
|
|
1822
|
+
close_btn = QPushButton("Cancel")
|
|
1823
|
+
close_btn.clicked.connect(self.reject)
|
|
1824
|
+
btn_layout.addWidget(close_btn)
|
|
1825
|
+
layout.addLayout(btn_layout)
|
|
1826
|
+
|
|
1827
|
+
def _select_element(self, sym: str):
|
|
1828
|
+
self.elementSelected.emit(sym)
|
|
1829
|
+
self.accept()
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
# ── Shortcut Configuration Dialog ───────────────────────────────────────────
|
|
1836
|
+
|
|
1837
|
+
class ShortcutDialog(QDialog):
|
|
1838
|
+
CONFIG_FILE = os.path.join(os.path.dirname(__file__), "molvector_config.json")
|
|
1839
|
+
|
|
1840
|
+
def __init__(self, shortcut_actions: dict, alt_defaults: dict = None, parent=None):
|
|
1841
|
+
super().__init__(parent)
|
|
1842
|
+
self.setWindowTitle("Configure Shortcuts")
|
|
1843
|
+
self.setMinimumSize(640, 420)
|
|
1844
|
+
self._actions = dict(shortcut_actions)
|
|
1845
|
+
self._alt_defaults = alt_defaults or {}
|
|
1846
|
+
self._initial_primary = {}
|
|
1847
|
+
self._initial_alt = {}
|
|
1848
|
+
|
|
1849
|
+
layout = QVBoxLayout(self)
|
|
1850
|
+
|
|
1851
|
+
# Conflict warning label
|
|
1852
|
+
self._conflict_lbl = QLabel()
|
|
1853
|
+
self._conflict_lbl.setVisible(False)
|
|
1854
|
+
layout.addWidget(self._conflict_lbl)
|
|
1855
|
+
|
|
1856
|
+
# Table
|
|
1857
|
+
self._table = QTableWidget(len(self._actions), 3)
|
|
1858
|
+
self._table.setHorizontalHeaderLabels(["Action", "Primary", "Alt"])
|
|
1859
|
+
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
1860
|
+
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
1861
|
+
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
|
1862
|
+
self._table.verticalHeader().setVisible(False)
|
|
1863
|
+
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
1864
|
+
|
|
1865
|
+
self._editors = []
|
|
1866
|
+
for row, (aid, action) in enumerate(self._actions.items()):
|
|
1867
|
+
name_item = QTableWidgetItem(action.text().replace("&", ""))
|
|
1868
|
+
name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
1869
|
+
self._table.setItem(row, 0, name_item)
|
|
1870
|
+
|
|
1871
|
+
ks1 = QKeySequenceEdit(action.shortcut())
|
|
1872
|
+
ks1.setClearButtonEnabled(True)
|
|
1873
|
+
self._table.setCellWidget(row, 1, ks1)
|
|
1874
|
+
ks1.keySequenceChanged.connect(self._check_conflicts)
|
|
1875
|
+
|
|
1876
|
+
alt_seq = self._alt_defaults.get(aid, "")
|
|
1877
|
+
ks2 = QKeySequenceEdit(QKeySequence(alt_seq))
|
|
1878
|
+
ks2.setClearButtonEnabled(True)
|
|
1879
|
+
self._table.setCellWidget(row, 2, ks2)
|
|
1880
|
+
ks2.keySequenceChanged.connect(self._check_conflicts)
|
|
1881
|
+
|
|
1882
|
+
self._editors.append((ks1, ks2))
|
|
1883
|
+
self._initial_primary[aid] = ks1.keySequence().toString()
|
|
1884
|
+
self._initial_alt[aid] = ks2.keySequence().toString()
|
|
1885
|
+
|
|
1886
|
+
self._check_conflicts()
|
|
1887
|
+
layout.addWidget(self._table)
|
|
1888
|
+
|
|
1889
|
+
# Buttons
|
|
1890
|
+
btn_row = QHBoxLayout()
|
|
1891
|
+
btn_restore = QPushButton("Restore Defaults")
|
|
1892
|
+
btn_restore.clicked.connect(self._restore_defaults)
|
|
1893
|
+
btn_row.addWidget(btn_restore)
|
|
1894
|
+
|
|
1895
|
+
btn_make_default = QPushButton("Make Default")
|
|
1896
|
+
btn_make_default.clicked.connect(self._make_default)
|
|
1897
|
+
btn_row.addWidget(btn_make_default)
|
|
1898
|
+
|
|
1899
|
+
btn_row.addStretch()
|
|
1900
|
+
|
|
1901
|
+
btn_ok = QPushButton("OK")
|
|
1902
|
+
btn_ok.clicked.connect(self.accept)
|
|
1903
|
+
btn_row.addWidget(btn_ok)
|
|
1904
|
+
|
|
1905
|
+
btn_cancel = QPushButton("Cancel")
|
|
1906
|
+
btn_cancel.clicked.connect(self._on_cancel)
|
|
1907
|
+
btn_row.addWidget(btn_cancel)
|
|
1908
|
+
|
|
1909
|
+
layout.addLayout(btn_row)
|
|
1910
|
+
|
|
1911
|
+
def _check_conflicts(self):
|
|
1912
|
+
usage = {}
|
|
1913
|
+
for row, (aid, action) in enumerate(self._actions.items()):
|
|
1914
|
+
for col, editor in enumerate([self._editors[row][0], self._editors[row][1]]):
|
|
1915
|
+
ks = editor.keySequence()
|
|
1916
|
+
if not ks.isEmpty():
|
|
1917
|
+
seq_str = ks.toString().lower()
|
|
1918
|
+
if seq_str not in usage:
|
|
1919
|
+
usage[seq_str] = []
|
|
1920
|
+
usage[seq_str].append((aid, action.text().replace("&", ""), col))
|
|
1921
|
+
|
|
1922
|
+
conflicts = {k: v for k, v in usage.items() if len(v) > 1}
|
|
1923
|
+
|
|
1924
|
+
if conflicts:
|
|
1925
|
+
lines = []
|
|
1926
|
+
for seq, items in conflicts.items():
|
|
1927
|
+
names = [f"{name} ({'Alt' if c == 1 else 'Primary'})" for _, name, c in items]
|
|
1928
|
+
lines.append(f"⚠ {seq} used by: {', '.join(names)}")
|
|
1929
|
+
self._conflict_lbl.setText(
|
|
1930
|
+
'<span style="color:#ff6b6b; font-weight:bold;">' +
|
|
1931
|
+
"<br>".join(lines) +
|
|
1932
|
+
'</span>'
|
|
1933
|
+
)
|
|
1934
|
+
self._conflict_lbl.setVisible(True)
|
|
1935
|
+
else:
|
|
1936
|
+
self._conflict_lbl.setVisible(False)
|
|
1937
|
+
|
|
1938
|
+
def _restore_defaults(self):
|
|
1939
|
+
for row, (aid, action) in enumerate(self._actions.items()):
|
|
1940
|
+
primary = MainWindow.DEFAULT_SHORTCUTS.get(aid, "")
|
|
1941
|
+
alt = MainWindow.ALT_SHORTCUT_DEFAULTS.get(aid, "")
|
|
1942
|
+
self._editors[row][0].setKeySequence(QKeySequence(primary))
|
|
1943
|
+
self._editors[row][1].setKeySequence(QKeySequence(alt))
|
|
1944
|
+
self._check_conflicts()
|
|
1945
|
+
|
|
1946
|
+
def _make_default(self):
|
|
1947
|
+
primary, alt = self._collect_shortcuts()
|
|
1948
|
+
config = {}
|
|
1949
|
+
try:
|
|
1950
|
+
if os.path.exists(self.CONFIG_FILE):
|
|
1951
|
+
with open(self.CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
1952
|
+
config = json.load(f)
|
|
1953
|
+
except Exception:
|
|
1954
|
+
pass
|
|
1955
|
+
config["shortcuts"] = primary
|
|
1956
|
+
config["alt_shortcuts"] = alt
|
|
1957
|
+
try:
|
|
1958
|
+
with open(self.CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
1959
|
+
json.dump(config, f, indent=2)
|
|
1960
|
+
QMessageBox.information(self, "Saved", f"Shortcuts saved to:\n{self.CONFIG_FILE}")
|
|
1961
|
+
except Exception as e:
|
|
1962
|
+
QMessageBox.critical(self, "Error", f"Could not save shortcuts:\n{e}")
|
|
1963
|
+
|
|
1964
|
+
def _has_changes(self) -> bool:
|
|
1965
|
+
for row, (aid, action) in enumerate(self._actions.items()):
|
|
1966
|
+
ks1 = self._editors[row][0].keySequence().toString()
|
|
1967
|
+
ks2 = self._editors[row][1].keySequence().toString()
|
|
1968
|
+
if ks1 != self._initial_primary.get(aid, "") or ks2 != self._initial_alt.get(aid, ""):
|
|
1969
|
+
return True
|
|
1970
|
+
return False
|
|
1971
|
+
|
|
1972
|
+
def _on_cancel(self):
|
|
1973
|
+
if self._has_changes():
|
|
1974
|
+
ret = QMessageBox.warning(
|
|
1975
|
+
self, "Unsaved Changes",
|
|
1976
|
+
"You have unsaved shortcut changes. Discard them?",
|
|
1977
|
+
QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
|
|
1978
|
+
QMessageBox.StandardButton.Cancel,
|
|
1979
|
+
)
|
|
1980
|
+
if ret == QMessageBox.StandardButton.Discard:
|
|
1981
|
+
self.reject()
|
|
1982
|
+
else:
|
|
1983
|
+
self.reject()
|
|
1984
|
+
|
|
1985
|
+
def closeEvent(self, event):
|
|
1986
|
+
if self._has_changes():
|
|
1987
|
+
ret = QMessageBox.warning(
|
|
1988
|
+
self, "Unsaved Changes",
|
|
1989
|
+
"You have unsaved shortcut changes. Discard them?",
|
|
1990
|
+
QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
|
|
1991
|
+
QMessageBox.StandardButton.Cancel,
|
|
1992
|
+
)
|
|
1993
|
+
if ret == QMessageBox.StandardButton.Discard:
|
|
1994
|
+
event.accept()
|
|
1995
|
+
else:
|
|
1996
|
+
event.ignore()
|
|
1997
|
+
else:
|
|
1998
|
+
event.accept()
|
|
1999
|
+
|
|
2000
|
+
def get_shortcuts(self) -> tuple:
|
|
2001
|
+
return self._collect_shortcuts()
|
|
2002
|
+
|
|
2003
|
+
def _collect_shortcuts(self) -> tuple:
|
|
2004
|
+
primary = {}
|
|
2005
|
+
alt = {}
|
|
2006
|
+
for row, (aid, action) in enumerate(self._actions.items()):
|
|
2007
|
+
ks1 = self._editors[row][0].keySequence()
|
|
2008
|
+
ks2 = self._editors[row][1].keySequence()
|
|
2009
|
+
primary[aid] = ks1.toString() if not ks1.isEmpty() else ""
|
|
2010
|
+
alt[aid] = ks2.toString() if not ks2.isEmpty() else ""
|
|
2011
|
+
return primary, alt
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2015
|
+
# Quick SVG Export Dialog ────────────────────────────────────────────────────
|
|
2016
|
+
|
|
2017
|
+
class QuickExportDialog(QDialog):
|
|
2018
|
+
"""A dialog with a draggable SVG target for drag-and-drop into external editors."""
|
|
2019
|
+
|
|
2020
|
+
def __init__(self, canvas, parent=None):
|
|
2021
|
+
super().__init__(parent)
|
|
2022
|
+
self._canvas = canvas
|
|
2023
|
+
self._tmp_path = None
|
|
2024
|
+
self.setWindowTitle("Quick SVG Export")
|
|
2025
|
+
self.setFixedSize(320, 220)
|
|
2026
|
+
|
|
2027
|
+
layout = QVBoxLayout(self)
|
|
2028
|
+
layout.setSpacing(12)
|
|
2029
|
+
|
|
2030
|
+
lbl = QLabel("Drag the icon into Inkscape or another SVG editor:")
|
|
2031
|
+
lbl.setWordWrap(True)
|
|
2032
|
+
layout.addWidget(lbl)
|
|
2033
|
+
|
|
2034
|
+
self._drag_widget = _DragWidget(self._get_svg_path, self)
|
|
2035
|
+
self._drag_widget.setFixedSize(120, 80)
|
|
2036
|
+
layout.addWidget(self._drag_widget, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
2037
|
+
|
|
2038
|
+
note = QLabel("Note: in Inkscape, you may have to drag the molecule to see gradients.")
|
|
2039
|
+
note.setObjectName("dim")
|
|
2040
|
+
note.setWordWrap(True)
|
|
2041
|
+
note.setStyleSheet("font-size:10px;")
|
|
2042
|
+
layout.addWidget(note)
|
|
2043
|
+
|
|
2044
|
+
btn_close = QPushButton("Close")
|
|
2045
|
+
btn_close.clicked.connect(self.close)
|
|
2046
|
+
layout.addWidget(btn_close, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
2047
|
+
|
|
2048
|
+
def _get_svg_path(self) -> str:
|
|
2049
|
+
if self._tmp_path is None:
|
|
2050
|
+
svg_data = self._canvas.get_svg_bytes(export_mode=True)
|
|
2051
|
+
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
|
|
2052
|
+
f.write(svg_data)
|
|
2053
|
+
self._tmp_path = f.name
|
|
2054
|
+
return self._tmp_path
|
|
2055
|
+
|
|
2056
|
+
def closeEvent(self, event):
|
|
2057
|
+
if self._tmp_path and os.path.exists(self._tmp_path):
|
|
2058
|
+
try:
|
|
2059
|
+
os.unlink(self._tmp_path)
|
|
2060
|
+
except OSError:
|
|
2061
|
+
pass
|
|
2062
|
+
self._tmp_path = None
|
|
2063
|
+
super().closeEvent(event)
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
class _DragWidget(QPushButton):
|
|
2067
|
+
"""A draggable pushbutton that initiates a file drag on mouse move."""
|
|
2068
|
+
|
|
2069
|
+
def __init__(self, get_svg_path_cb, parent=None):
|
|
2070
|
+
super().__init__("Drag SVG", parent)
|
|
2071
|
+
self._get_svg_path = get_svg_path_cb
|
|
2072
|
+
self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
|
|
2073
|
+
self.setToolTip("Drag this into an SVG editor")
|
|
2074
|
+
self.setStyleSheet("""
|
|
2075
|
+
QPushButton {
|
|
2076
|
+
border: 2px dashed #888;
|
|
2077
|
+
border-radius: 8px;
|
|
2078
|
+
font-size: 14px;
|
|
2079
|
+
background: #f0f0f0;
|
|
2080
|
+
padding: 8px;
|
|
2081
|
+
}
|
|
2082
|
+
QPushButton:hover {
|
|
2083
|
+
border-color: #4a9eff;
|
|
2084
|
+
background: #e0edff;
|
|
2085
|
+
}
|
|
2086
|
+
""")
|
|
2087
|
+
|
|
2088
|
+
def mousePressEvent(self, event):
|
|
2089
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
2090
|
+
self._drag_start = event.position().toPoint()
|
|
2091
|
+
super().mousePressEvent(event)
|
|
2092
|
+
|
|
2093
|
+
def mouseMoveEvent(self, event):
|
|
2094
|
+
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
|
2095
|
+
return
|
|
2096
|
+
if (event.position().toPoint() - self._drag_start).manhattanLength() < QApplication.startDragDistance():
|
|
2097
|
+
return
|
|
2098
|
+
|
|
2099
|
+
svg_path = self._get_svg_path()
|
|
2100
|
+
|
|
2101
|
+
mime = QMimeData()
|
|
2102
|
+
mime.setUrls([QUrl.fromLocalFile(svg_path)])
|
|
2103
|
+
|
|
2104
|
+
with open(svg_path, "rb") as f:
|
|
2105
|
+
mime.setData("image/svg+xml", f.read())
|
|
2106
|
+
|
|
2107
|
+
drag = QDrag(self)
|
|
2108
|
+
drag.setMimeData(mime)
|
|
2109
|
+
|
|
2110
|
+
pixmap = self.grab().scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
2111
|
+
drag.setPixmap(pixmap)
|
|
2112
|
+
drag.setHotSpot(self.rect().center())
|
|
2113
|
+
|
|
2114
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2118
|
+
# INTERACTIVE CANVAS
|
|
2119
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2120
|
+
|
|
2121
|
+
def _ideal_directions(n: int, angle_rad: float):
|
|
2122
|
+
"""Return n unit vectors in ideal VSEPR-like geometry (tetrahedral, trigonal, bent)."""
|
|
2123
|
+
if n == 4:
|
|
2124
|
+
a = 1.0 / math.sqrt(3.0)
|
|
2125
|
+
return [np.array([a,a,a]), np.array([a,-a,-a]),
|
|
2126
|
+
np.array([-a,a,-a]), np.array([-a,-a,a])]
|
|
2127
|
+
elif n == 3:
|
|
2128
|
+
s3 = math.sqrt(3.0)/2.0
|
|
2129
|
+
return [np.array([1.0,0.0,0.0]), np.array([-0.5,s3,0.0]), np.array([-0.5,-s3,0.0])]
|
|
2130
|
+
elif n == 2:
|
|
2131
|
+
half = angle_rad / 2.0
|
|
2132
|
+
return [np.array([math.sin(half),0.0,math.cos(half)]),
|
|
2133
|
+
np.array([-math.sin(half),0.0,math.cos(half)])]
|
|
2134
|
+
dirs = []
|
|
2135
|
+
for i in range(n):
|
|
2136
|
+
theta = 2.0 * math.pi * i / n
|
|
2137
|
+
phi = math.acos(2.0 * i / n - 1.0)
|
|
2138
|
+
dirs.append(np.array([math.sin(phi)*math.cos(theta),
|
|
2139
|
+
math.sin(phi)*math.sin(theta), math.cos(phi)]))
|
|
2140
|
+
return dirs
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
class MoleculeCanvas(QSvgWidget):
|
|
2144
|
+
rotationChanged = pyqtSignal()
|
|
2145
|
+
fileDropped = pyqtSignal(str)
|
|
2146
|
+
moleculeChanged = pyqtSignal() # Emitted when atoms/bonds change
|
|
2147
|
+
requestHistorySave = pyqtSignal() # Emitted BEFORE a change
|
|
2148
|
+
|
|
2149
|
+
def __init__(self, parent=None):
|
|
2150
|
+
super().__init__(parent)
|
|
2151
|
+
self.molecule: Molecule | None = None
|
|
2152
|
+
self._rot = np.eye(3)
|
|
2153
|
+
self._zoom = 1.0
|
|
2154
|
+
self._pan = np.array([0.0, 0.0])
|
|
2155
|
+
self._axes_ref = np.eye(3)
|
|
2156
|
+
self._default_view = np.eye(3)
|
|
2157
|
+
self._drag_start: QPoint | None = None
|
|
2158
|
+
self._drag_mode = "none"
|
|
2159
|
+
|
|
2160
|
+
self.setMinimumSize(500, 450)
|
|
2161
|
+
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
2162
|
+
self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
|
|
2163
|
+
self.setAcceptDrops(True)
|
|
2164
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
2165
|
+
self.setMouseTracking(True)
|
|
2166
|
+
self._show_vectors = False
|
|
2167
|
+
self.build_mode = False
|
|
2168
|
+
self.selection_mode = False
|
|
2169
|
+
self.align_mode = False
|
|
2170
|
+
self.build_element = "C"
|
|
2171
|
+
self._bonding_from: int | None = None
|
|
2172
|
+
self._mouse_pos: QPoint | None = None
|
|
2173
|
+
|
|
2174
|
+
# Selection state
|
|
2175
|
+
self.selected_atoms: set = set()
|
|
2176
|
+
self._sel_drag_start: QPoint | None = None
|
|
2177
|
+
self._sel_rect: QRectF | None = None
|
|
2178
|
+
|
|
2179
|
+
# Atom dragging (Alt+click in build/select mode)
|
|
2180
|
+
self._drag_atom_idx: int | None = None
|
|
2181
|
+
self._align_axis: np.ndarray | None = None # bond axis for align rotate-around-bond drag
|
|
2182
|
+
|
|
2183
|
+
# Render parameters — all public, set by MainWindow
|
|
2184
|
+
self.base_scale = 110.0
|
|
2185
|
+
self.atom_scale = 0.7
|
|
2186
|
+
self.bond_width_px = 10.0
|
|
2187
|
+
self.background = "#ffffff"
|
|
2188
|
+
self.bond_style = "splitted"
|
|
2189
|
+
self.bond_color = "#444444"
|
|
2190
|
+
self.atom_border_mode = "scaled"
|
|
2191
|
+
self.atom_border_scale = 1.04
|
|
2192
|
+
self.atom_border_width = 2.0
|
|
2193
|
+
self.color_overrides: dict = {}
|
|
2194
|
+
self.lighting_intensity: float = 1.0
|
|
2195
|
+
self.light_position: str = "Top left"
|
|
2196
|
+
self.roughness: float = 1.0
|
|
2197
|
+
self.show_axes: bool = False
|
|
2198
|
+
self.show_principal_axes: bool = False
|
|
2199
|
+
self.axes_position: str = "bottom-left"
|
|
2200
|
+
self.principal_axes_position: str = "bottom-right"
|
|
2201
|
+
self.animation_phase: float = 0.0
|
|
2202
|
+
self.animation_amplitude: float = 0.0
|
|
2203
|
+
|
|
2204
|
+
self._render_timer = QTimer()
|
|
2205
|
+
self._render_timer.setSingleShot(True)
|
|
2206
|
+
self._render_timer.timeout.connect(self._do_render)
|
|
2207
|
+
|
|
2208
|
+
self.arrows: Optional[List[Tuple[int, float, float, float, str]]] = None
|
|
2209
|
+
self.active_vectors: Optional[np.ndarray] = None
|
|
2210
|
+
self.animation_phase: float = 0.0
|
|
2211
|
+
self.animation_amplitude: float = 0.0
|
|
2212
|
+
|
|
2213
|
+
# Build options
|
|
2214
|
+
self.build_mode: bool = False
|
|
2215
|
+
self.build_element: str = "C"
|
|
2216
|
+
self.auto_adjust_h: bool = False
|
|
2217
|
+
self.bond_order_disabled: bool = False
|
|
2218
|
+
self._saved_bond_orders: list[int] | None = None
|
|
2219
|
+
|
|
2220
|
+
# ── drag and drop ─────────────────────────────────────────────────────────
|
|
2221
|
+
|
|
2222
|
+
def dragEnterEvent(self, event):
|
|
2223
|
+
if event.mimeData().hasUrls():
|
|
2224
|
+
event.setDropAction(Qt.DropAction.CopyAction)
|
|
2225
|
+
event.accept()
|
|
2226
|
+
else:
|
|
2227
|
+
event.ignore()
|
|
2228
|
+
|
|
2229
|
+
def dragMoveEvent(self, event):
|
|
2230
|
+
if event.mimeData().hasUrls():
|
|
2231
|
+
event.setDropAction(Qt.DropAction.CopyAction)
|
|
2232
|
+
event.accept()
|
|
2233
|
+
else:
|
|
2234
|
+
event.ignore()
|
|
2235
|
+
|
|
2236
|
+
def dropEvent(self, event):
|
|
2237
|
+
urls = event.mimeData().urls()
|
|
2238
|
+
if urls and urls[0].isLocalFile():
|
|
2239
|
+
path = urls[0].toLocalFile()
|
|
2240
|
+
self.fileDropped.emit(path)
|
|
2241
|
+
event.setDropAction(Qt.DropAction.CopyAction)
|
|
2242
|
+
event.accept()
|
|
2243
|
+
else:
|
|
2244
|
+
event.ignore()
|
|
2245
|
+
|
|
2246
|
+
# ── public ───────────────────────────────────────────────────────────────
|
|
2247
|
+
|
|
2248
|
+
def load_molecule(self, mol: Molecule):
|
|
2249
|
+
self.molecule = mol
|
|
2250
|
+
self._rot = np.eye(3)
|
|
2251
|
+
self._zoom = 1.0
|
|
2252
|
+
self._pan = np.array([0.0, 0.0])
|
|
2253
|
+
self._axes_ref = np.eye(3)
|
|
2254
|
+
self._default_view = np.eye(3)
|
|
2255
|
+
self.selected_atoms.clear()
|
|
2256
|
+
# Auto-scale: fit the molecule to 80% of the smaller canvas dimension
|
|
2257
|
+
positions = np.array([[a.x, a.y, a.z] for a in mol.atoms])
|
|
2258
|
+
if len(positions) > 0:
|
|
2259
|
+
centroid = positions.mean(axis=0)
|
|
2260
|
+
centered = positions - centroid
|
|
2261
|
+
max_extent = np.max(np.linalg.norm(centered, axis=1)) + 1.5 # +1.5 Å for atom radius margin
|
|
2262
|
+
if max_extent > 0:
|
|
2263
|
+
w = max(self.width(), 500)
|
|
2264
|
+
h = max(self.height(), 450)
|
|
2265
|
+
self.base_scale = 0.40 * min(w, h) / max_extent
|
|
2266
|
+
self.request_render()
|
|
2267
|
+
|
|
2268
|
+
def reset_view(self):
|
|
2269
|
+
self._rot = self._default_view.copy()
|
|
2270
|
+
self._zoom = 1.0
|
|
2271
|
+
self._pan = np.array([0.0, 0.0])
|
|
2272
|
+
self.request_render()
|
|
2273
|
+
|
|
2274
|
+
def reset_xyz_axes(self):
|
|
2275
|
+
self._axes_ref = self._rot.copy()
|
|
2276
|
+
self._default_view = self._rot.copy()
|
|
2277
|
+
self.request_render()
|
|
2278
|
+
|
|
2279
|
+
def set_preset(self, rx, ry, rz):
|
|
2280
|
+
from molvector.render import rotation_matrix
|
|
2281
|
+
self._rot = rotation_matrix(math.radians(rx), math.radians(ry), math.radians(rz))
|
|
2282
|
+
self._zoom = 1.0
|
|
2283
|
+
self._pan = np.array([0.0, 0.0])
|
|
2284
|
+
self.request_render()
|
|
2285
|
+
|
|
2286
|
+
def set_principal_axis_preset(self, axis_index):
|
|
2287
|
+
import mol_strudel as strudel
|
|
2288
|
+
from molvector.render import ATOMIC_MASSES
|
|
2289
|
+
mol = self.molecule
|
|
2290
|
+
if not mol or len(mol.atoms) < 2:
|
|
2291
|
+
return
|
|
2292
|
+
coords = np.array([[a.x, a.y, a.z] for a in mol.atoms])
|
|
2293
|
+
masses = np.array([ATOMIC_MASSES.get(a.element, 0.0) for a in mol.atoms])
|
|
2294
|
+
_, eigvecs = strudel.diagonalize_I_tensor(coords, masses)
|
|
2295
|
+
Vt = eigvecs.T
|
|
2296
|
+
if axis_index == 0:
|
|
2297
|
+
cx, sx = math.cos(-math.pi/2), math.sin(-math.pi/2)
|
|
2298
|
+
Ry = np.array([[cx,0,sx],[0,1,0],[-sx,0,cx]])
|
|
2299
|
+
self._rot = Ry @ Vt
|
|
2300
|
+
elif axis_index == 1:
|
|
2301
|
+
cx, sx = math.cos(math.pi/2), math.sin(math.pi/2)
|
|
2302
|
+
Rx = np.array([[1,0,0],[0,cx,-sx],[0,sx,cx]])
|
|
2303
|
+
self._rot = Rx @ Vt
|
|
2304
|
+
else:
|
|
2305
|
+
self._rot = Vt
|
|
2306
|
+
self._zoom = 1.0
|
|
2307
|
+
self._pan = np.array([0.0, 0.0])
|
|
2308
|
+
self.request_render()
|
|
2309
|
+
|
|
2310
|
+
def edit_background_color(self):
|
|
2311
|
+
col = QColorDialog.getColor(QColor(self.background), self, "Background Colour")
|
|
2312
|
+
if col.isValid():
|
|
2313
|
+
self.background = col.name()
|
|
2314
|
+
self.request_render()
|
|
2315
|
+
|
|
2316
|
+
def request_render(self, delay_ms: int = 0):
|
|
2317
|
+
if not self._render_timer.isActive():
|
|
2318
|
+
self._render_timer.start(delay_ms)
|
|
2319
|
+
|
|
2320
|
+
def get_svg_bytes(self, export_mode: bool = False) -> bytes:
|
|
2321
|
+
return self._render_to_bytes(export_mode=export_mode)
|
|
2322
|
+
|
|
2323
|
+
# ── internal ─────────────────────────────────────────────────────────────
|
|
2324
|
+
|
|
2325
|
+
def _render_to_bytes(self, w=None, h=None, export_mode: bool = False) -> bytes:
|
|
2326
|
+
if self.molecule is None:
|
|
2327
|
+
return b""
|
|
2328
|
+
w = w or max(self.width(), 500)
|
|
2329
|
+
h = h or max(self.height(), 450)
|
|
2330
|
+
|
|
2331
|
+
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
|
|
2332
|
+
tmp = f.name
|
|
2333
|
+
try:
|
|
2334
|
+
render_molecule(
|
|
2335
|
+
self.molecule,
|
|
2336
|
+
rot_matrix_override=self._rot,
|
|
2337
|
+
pan_x=self._pan[0], pan_y=self._pan[1],
|
|
2338
|
+
canvas_w=w, canvas_h=h,
|
|
2339
|
+
scale=self.base_scale * self._zoom,
|
|
2340
|
+
atom_scale=self.atom_scale,
|
|
2341
|
+
bond_width_px=self.bond_width_px,
|
|
2342
|
+
bond_style=self.bond_style,
|
|
2343
|
+
bond_color=self.bond_color,
|
|
2344
|
+
background=self.background,
|
|
2345
|
+
color_overrides=self.color_overrides or None,
|
|
2346
|
+
atom_border_mode=self.atom_border_mode,
|
|
2347
|
+
atom_border_scale=self.atom_border_scale,
|
|
2348
|
+
atom_border_width=self.atom_border_width,
|
|
2349
|
+
lighting_intensity=self.lighting_intensity,
|
|
2350
|
+
light_position=self.light_position,
|
|
2351
|
+
roughness=self.roughness,
|
|
2352
|
+
show_axes=self.show_axes,
|
|
2353
|
+
show_principal_axes=self.show_principal_axes,
|
|
2354
|
+
axes_position=self.axes_position,
|
|
2355
|
+
principal_axes_position=self.principal_axes_position,
|
|
2356
|
+
axes_rot_override=self._rot @ np.linalg.inv(self._axes_ref),
|
|
2357
|
+
active_vectors=self.active_vectors,
|
|
2358
|
+
animation_phase=self.animation_phase,
|
|
2359
|
+
animation_amplitude=self.animation_amplitude,
|
|
2360
|
+
output_path=tmp,
|
|
2361
|
+
export_mode=export_mode,
|
|
2362
|
+
selected_indices=self.selected_atoms if not export_mode else None,
|
|
2363
|
+
vectors=self.arrows if self._show_vectors else None,
|
|
2364
|
+
)
|
|
2365
|
+
with open(tmp,"rb") as f:
|
|
2366
|
+
return f.read()
|
|
2367
|
+
finally:
|
|
2368
|
+
try: os.unlink(tmp)
|
|
2369
|
+
except OSError: pass
|
|
2370
|
+
|
|
2371
|
+
def _do_render(self):
|
|
2372
|
+
data = self._render_to_bytes()
|
|
2373
|
+
if data:
|
|
2374
|
+
self.load(QByteArray(data))
|
|
2375
|
+
# Overlay for bonding line
|
|
2376
|
+
if self.build_mode and self._bonding_from is not None and self._mouse_pos is not None:
|
|
2377
|
+
self.update() # Force paintEvent for overlay
|
|
2378
|
+
|
|
2379
|
+
def paintEvent(self, event):
|
|
2380
|
+
super().paintEvent(event)
|
|
2381
|
+
draw_sel = self.selection_mode and self._sel_rect is not None and self._sel_rect.isValid()
|
|
2382
|
+
draw_bond = self.build_mode and self._bonding_from is not None and self._mouse_pos is not None and self.molecule
|
|
2383
|
+
if not draw_sel and not draw_bond:
|
|
2384
|
+
return
|
|
2385
|
+
|
|
2386
|
+
p = QPainter(self)
|
|
2387
|
+
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
2388
|
+
|
|
2389
|
+
if draw_sel:
|
|
2390
|
+
p.fillRect(self._sel_rect, QColor(0, 120, 255, 30))
|
|
2391
|
+
p.setPen(QColor(0, 140, 255, 200))
|
|
2392
|
+
p.drawRect(self._sel_rect.toRect())
|
|
2393
|
+
|
|
2394
|
+
if draw_bond:
|
|
2395
|
+
p.setPen(QColor("#007bff"))
|
|
2396
|
+
atoms, _ = project_molecule(
|
|
2397
|
+
self.molecule, self._rot, self._pan[0], self._pan[1],
|
|
2398
|
+
self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
|
|
2399
|
+
)
|
|
2400
|
+
ax, ay, az, ar = atoms[self._bonding_from]
|
|
2401
|
+
p.drawLine(int(ax), int(ay), self._mouse_pos.x(), self._mouse_pos.y())
|
|
2402
|
+
|
|
2403
|
+
p.end()
|
|
2404
|
+
|
|
2405
|
+
# ── mouse ─────────────────────────────────────────────────────────────────
|
|
2406
|
+
|
|
2407
|
+
def _get_hit_atom(self, pos: QPoint) -> int | None:
|
|
2408
|
+
if not self.molecule: return None
|
|
2409
|
+
atoms, _ = project_molecule(
|
|
2410
|
+
self.molecule, self._rot, self._pan[0], self._pan[1],
|
|
2411
|
+
self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
|
|
2412
|
+
)
|
|
2413
|
+
for i, (ax, ay, az, ar) in enumerate(atoms):
|
|
2414
|
+
dist = math.hypot(pos.x() - ax, pos.y() - ay)
|
|
2415
|
+
if dist < max(ar, 10):
|
|
2416
|
+
return i
|
|
2417
|
+
return None
|
|
2418
|
+
|
|
2419
|
+
def _get_hit_bond(self, pos: QPoint) -> int | None:
|
|
2420
|
+
if not self.molecule: return None
|
|
2421
|
+
_, bonds = project_molecule(
|
|
2422
|
+
self.molecule, self._rot, self._pan[0], self._pan[1],
|
|
2423
|
+
self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
|
|
2424
|
+
)
|
|
2425
|
+
threshold = 8.0
|
|
2426
|
+
for ax, ay, bx, by, z_avg, idx in bonds:
|
|
2427
|
+
# Distance from point to segment
|
|
2428
|
+
L2 = (bx-ax)**2 + (by-ay)**2
|
|
2429
|
+
if L2 < 1e-6: continue
|
|
2430
|
+
t = max(0, min(1, ((pos.x()-ax)*(bx-ax) + (pos.y()-ay)*(by-ay)) / L2))
|
|
2431
|
+
proj_x = ax + t * (bx-ax)
|
|
2432
|
+
proj_y = ay + t * (by-ay)
|
|
2433
|
+
dist = math.hypot(pos.x() - proj_x, pos.y() - proj_y)
|
|
2434
|
+
if dist < threshold:
|
|
2435
|
+
return idx
|
|
2436
|
+
return None
|
|
2437
|
+
|
|
2438
|
+
def _unproject(self, pos: QPoint) -> np.ndarray:
|
|
2439
|
+
# returns 3D position in molecule local frame (relative to centroid)
|
|
2440
|
+
cx = self.width()/2 + self._pan[0]
|
|
2441
|
+
cy = self.height()/2 + self._pan[1]
|
|
2442
|
+
scale = self.base_scale * self._zoom
|
|
2443
|
+
|
|
2444
|
+
# Assume z_local = 0
|
|
2445
|
+
rp_x = (pos.x() - cx) / scale
|
|
2446
|
+
rp_y = (cy - pos.y()) / scale
|
|
2447
|
+
rp = np.array([rp_x, rp_y, 0.0])
|
|
2448
|
+
# local = rot^-1 @ rp
|
|
2449
|
+
local = np.linalg.inv(self._rot) @ rp
|
|
2450
|
+
return local
|
|
2451
|
+
|
|
2452
|
+
def mousePressEvent(self, event):
|
|
2453
|
+
self._update_cursor(event.position().toPoint(), event.modifiers())
|
|
2454
|
+
# Alt/Option+click drags an atom in build or selection mode
|
|
2455
|
+
mod = event.modifiers()
|
|
2456
|
+
if (self.build_mode or self.selection_mode) and event.button() == Qt.MouseButton.LeftButton and (mod & (Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier)):
|
|
2457
|
+
idx = self._get_hit_atom(event.position().toPoint())
|
|
2458
|
+
if idx is not None and self.molecule:
|
|
2459
|
+
self.requestHistorySave.emit()
|
|
2460
|
+
self._drag_atom_idx = idx
|
|
2461
|
+
self._drag_start = event.position().toPoint()
|
|
2462
|
+
self._drag_mode = "atom"
|
|
2463
|
+
self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
|
|
2464
|
+
return
|
|
2465
|
+
|
|
2466
|
+
if self.selection_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
2467
|
+
mod = event.modifiers()
|
|
2468
|
+
idx = self._get_hit_atom(event.position().toPoint())
|
|
2469
|
+
if idx is not None:
|
|
2470
|
+
if mod & Qt.KeyboardModifier.ShiftModifier:
|
|
2471
|
+
if idx in self.selected_atoms:
|
|
2472
|
+
self.selected_atoms.discard(idx)
|
|
2473
|
+
else:
|
|
2474
|
+
self.selected_atoms.add(idx)
|
|
2475
|
+
else:
|
|
2476
|
+
self.selected_atoms = {idx} if idx not in self.selected_atoms else set()
|
|
2477
|
+
self.request_render()
|
|
2478
|
+
return
|
|
2479
|
+
self.selected_atoms.clear()
|
|
2480
|
+
self._sel_drag_start = event.position().toPoint()
|
|
2481
|
+
self._sel_rect = QRectF()
|
|
2482
|
+
self.request_render()
|
|
2483
|
+
return
|
|
2484
|
+
|
|
2485
|
+
if self.build_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
2486
|
+
idx = self._get_hit_atom(event.position().toPoint())
|
|
2487
|
+
if idx is not None:
|
|
2488
|
+
self._bonding_from = idx
|
|
2489
|
+
self._mouse_pos = event.position().toPoint()
|
|
2490
|
+
return
|
|
2491
|
+
|
|
2492
|
+
b_idx = self._get_hit_bond(event.position().toPoint())
|
|
2493
|
+
if b_idx is not None:
|
|
2494
|
+
self.requestHistorySave.emit()
|
|
2495
|
+
if self.bond_order_disabled:
|
|
2496
|
+
self.molecule.bonds[b_idx].order = 1
|
|
2497
|
+
else:
|
|
2498
|
+
self.molecule.bonds[b_idx].order = (self.molecule.bonds[b_idx].order % 3) + 1
|
|
2499
|
+
|
|
2500
|
+
if self.auto_adjust_h:
|
|
2501
|
+
at1, at2 = self.molecule.atoms[self.molecule.bonds[b_idx].i], self.molecule.atoms[self.molecule.bonds[b_idx].j]
|
|
2502
|
+
self._apply_auto_h(at1)
|
|
2503
|
+
self._apply_auto_h(at2)
|
|
2504
|
+
self._relax_h()
|
|
2505
|
+
|
|
2506
|
+
self.moleculeChanged.emit()
|
|
2507
|
+
self.request_render()
|
|
2508
|
+
return
|
|
2509
|
+
|
|
2510
|
+
# Add new atom if nothing hit
|
|
2511
|
+
if not self.molecule:
|
|
2512
|
+
self.molecule = Molecule("New Molecule", atoms=[])
|
|
2513
|
+
|
|
2514
|
+
self.requestHistorySave.emit()
|
|
2515
|
+
loc = self._unproject(event.position().toPoint())
|
|
2516
|
+
# Convert local to absolute (if we have a centroid)
|
|
2517
|
+
if self.molecule.atoms:
|
|
2518
|
+
pos_arr = np.array([[a.x, a.y, a.z] for a in self.molecule.atoms])
|
|
2519
|
+
centroid = pos_arr.mean(axis=0)
|
|
2520
|
+
abs_pos = loc + centroid
|
|
2521
|
+
else:
|
|
2522
|
+
abs_pos = loc
|
|
2523
|
+
|
|
2524
|
+
new_atom = Atom(self.build_element, abs_pos[0], abs_pos[1], abs_pos[2])
|
|
2525
|
+
self.molecule.atoms.append(new_atom)
|
|
2526
|
+
new_idx = len(self.molecule.atoms) - 1
|
|
2527
|
+
|
|
2528
|
+
if self.auto_adjust_h:
|
|
2529
|
+
self._apply_auto_h(self.molecule.atoms[new_idx])
|
|
2530
|
+
self._relax_h()
|
|
2531
|
+
|
|
2532
|
+
self.moleculeChanged.emit()
|
|
2533
|
+
self.request_render()
|
|
2534
|
+
return
|
|
2535
|
+
|
|
2536
|
+
if self.build_mode and event.button() == Qt.MouseButton.RightButton:
|
|
2537
|
+
idx = self._get_hit_atom(event.position().toPoint())
|
|
2538
|
+
if idx is not None:
|
|
2539
|
+
self.requestHistorySave.emit()
|
|
2540
|
+
# 1. Identify connected H atoms to remove
|
|
2541
|
+
h_to_remove = []
|
|
2542
|
+
neighbors_to_adjust = []
|
|
2543
|
+
|
|
2544
|
+
for b in self.molecule.bonds:
|
|
2545
|
+
if b.i == idx:
|
|
2546
|
+
nb_idx = b.j
|
|
2547
|
+
elif b.j == idx:
|
|
2548
|
+
nb_idx = b.i
|
|
2549
|
+
else:
|
|
2550
|
+
continue
|
|
2551
|
+
|
|
2552
|
+
nb_atom = self.molecule.atoms[nb_idx]
|
|
2553
|
+
if nb_atom.element == "H":
|
|
2554
|
+
# Only remove if it has no other bonds
|
|
2555
|
+
other_bonds = 0
|
|
2556
|
+
for b2 in self.molecule.bonds:
|
|
2557
|
+
if b2.i == nb_idx or b2.j == nb_idx: other_bonds += 1
|
|
2558
|
+
if other_bonds == 1:
|
|
2559
|
+
h_to_remove.append(nb_idx)
|
|
2560
|
+
else:
|
|
2561
|
+
neighbors_to_adjust.append(nb_atom)
|
|
2562
|
+
|
|
2563
|
+
# 2. Delete the atoms (highest index first to avoid shifts)
|
|
2564
|
+
all_to_del = sorted(list(set(h_to_remove + [idx])), reverse=True)
|
|
2565
|
+
for d_idx in all_to_del:
|
|
2566
|
+
self.molecule.atoms.pop(d_idx)
|
|
2567
|
+
new_bonds = []
|
|
2568
|
+
for b in self.molecule.bonds:
|
|
2569
|
+
if b.i == d_idx or b.j == d_idx: continue
|
|
2570
|
+
ni = b.i - 1 if b.i > d_idx else b.i
|
|
2571
|
+
nj = b.j - 1 if b.j > d_idx else b.j
|
|
2572
|
+
new_bonds.append(Bond(ni, nj, b.order))
|
|
2573
|
+
self.molecule.bonds = new_bonds
|
|
2574
|
+
|
|
2575
|
+
# 3. Adjust neighbors if auto_adjust_h is on
|
|
2576
|
+
if self.auto_adjust_h:
|
|
2577
|
+
for nb_atom in neighbors_to_adjust:
|
|
2578
|
+
self._apply_auto_h(nb_atom)
|
|
2579
|
+
self._relax_h()
|
|
2580
|
+
|
|
2581
|
+
self.moleculeChanged.emit()
|
|
2582
|
+
self.request_render()
|
|
2583
|
+
return
|
|
2584
|
+
|
|
2585
|
+
b_idx = self._get_hit_bond(event.position().toPoint())
|
|
2586
|
+
if b_idx is not None:
|
|
2587
|
+
self.requestHistorySave.emit()
|
|
2588
|
+
# Remove bond
|
|
2589
|
+
self.molecule.bonds.pop(b_idx)
|
|
2590
|
+
self.moleculeChanged.emit()
|
|
2591
|
+
self.request_render()
|
|
2592
|
+
return
|
|
2593
|
+
|
|
2594
|
+
if self.align_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
2595
|
+
b_idx = self._get_hit_bond(event.position().toPoint())
|
|
2596
|
+
if b_idx is not None and self.molecule:
|
|
2597
|
+
bond = self.molecule.bonds[b_idx]
|
|
2598
|
+
a1 = self.molecule.atoms[bond.i]
|
|
2599
|
+
a2 = self.molecule.atoms[bond.j]
|
|
2600
|
+
v_local = np.array([a2.x - a1.x, a2.y - a1.y, a2.z - a1.z])
|
|
2601
|
+
norm = np.linalg.norm(v_local)
|
|
2602
|
+
if norm > 1e-10:
|
|
2603
|
+
v_local_hat = v_local / norm
|
|
2604
|
+
v_view = self._rot @ v_local_hat
|
|
2605
|
+
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
|
2606
|
+
target = np.array([1.0, 0.0, 0.0])
|
|
2607
|
+
else:
|
|
2608
|
+
target = np.array([0.0, 1.0, 0.0])
|
|
2609
|
+
dot = np.dot(v_view, target)
|
|
2610
|
+
# Pick the closer direction to avoid large flips
|
|
2611
|
+
if dot < 0:
|
|
2612
|
+
target = -target
|
|
2613
|
+
dot = -dot
|
|
2614
|
+
if dot < 0.9999:
|
|
2615
|
+
sin_theta = np.linalg.norm(np.cross(v_view, target))
|
|
2616
|
+
if sin_theta > 1e-10:
|
|
2617
|
+
cos_theta = dot
|
|
2618
|
+
axis = np.cross(v_view, target) / sin_theta
|
|
2619
|
+
K = np.array([[0, -axis[2], axis[1]],
|
|
2620
|
+
[axis[2], 0, -axis[0]],
|
|
2621
|
+
[-axis[1], axis[0], 0]])
|
|
2622
|
+
R_corr = np.eye(3) + sin_theta * K + (1.0 - cos_theta) * (K @ K)
|
|
2623
|
+
self._rot = R_corr @ self._rot
|
|
2624
|
+
self.rotationChanged.emit()
|
|
2625
|
+
self.request_render()
|
|
2626
|
+
return
|
|
2627
|
+
|
|
2628
|
+
if self.align_mode and event.button() == Qt.MouseButton.RightButton:
|
|
2629
|
+
b_idx = self._get_hit_bond(event.position().toPoint())
|
|
2630
|
+
if b_idx is not None and self.molecule:
|
|
2631
|
+
bond = self.molecule.bonds[b_idx]
|
|
2632
|
+
a1 = self.molecule.atoms[bond.i]
|
|
2633
|
+
a2 = self.molecule.atoms[bond.j]
|
|
2634
|
+
v_local = np.array([a2.x - a1.x, a2.y - a1.y, a2.z - a1.z])
|
|
2635
|
+
n = np.linalg.norm(v_local)
|
|
2636
|
+
if n > 1e-10:
|
|
2637
|
+
self._align_axis = v_local / n
|
|
2638
|
+
self._drag_start = event.position().toPoint()
|
|
2639
|
+
self._drag_mode = "rotate_around_bond"
|
|
2640
|
+
self.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
|
|
2641
|
+
return
|
|
2642
|
+
|
|
2643
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
2644
|
+
self._drag_start = event.position().toPoint()
|
|
2645
|
+
self._drag_mode = "rotate"
|
|
2646
|
+
self.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
|
|
2647
|
+
elif event.button() == Qt.MouseButton.RightButton:
|
|
2648
|
+
self._drag_start = event.position().toPoint()
|
|
2649
|
+
self._drag_mode = "pan"
|
|
2650
|
+
self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
|
|
2651
|
+
|
|
2652
|
+
def _update_cursor(self, pos: QPoint, mods):
|
|
2653
|
+
has_atom = self._get_hit_atom(pos) is not None
|
|
2654
|
+
alt_held = bool(mods & (Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier))
|
|
2655
|
+
|
|
2656
|
+
if self.build_mode:
|
|
2657
|
+
if alt_held and has_atom:
|
|
2658
|
+
self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
|
|
2659
|
+
else:
|
|
2660
|
+
self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
|
|
2661
|
+
elif self.selection_mode:
|
|
2662
|
+
if alt_held and has_atom:
|
|
2663
|
+
self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
|
|
2664
|
+
elif has_atom:
|
|
2665
|
+
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
|
2666
|
+
else:
|
|
2667
|
+
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
2668
|
+
elif self.align_mode:
|
|
2669
|
+
if self._get_hit_bond(pos) is not None:
|
|
2670
|
+
self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
|
|
2671
|
+
else:
|
|
2672
|
+
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
2673
|
+
else:
|
|
2674
|
+
self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
|
|
2675
|
+
|
|
2676
|
+
def mouseMoveEvent(self, event):
|
|
2677
|
+
self._mouse_pos = event.position().toPoint()
|
|
2678
|
+
self._update_cursor(event.position().toPoint(), event.modifiers())
|
|
2679
|
+
if self.build_mode and self._bonding_from is not None:
|
|
2680
|
+
self.request_render(delay_ms=16)
|
|
2681
|
+
return
|
|
2682
|
+
|
|
2683
|
+
if self.selection_mode and self._sel_drag_start is not None:
|
|
2684
|
+
cur = event.position().toPoint()
|
|
2685
|
+
self._sel_rect = QRectF(QPointF(self._sel_drag_start), QPointF(cur)).normalized()
|
|
2686
|
+
self.update()
|
|
2687
|
+
return
|
|
2688
|
+
|
|
2689
|
+
if self._drag_mode == "atom" and self._drag_atom_idx is not None and self.molecule:
|
|
2690
|
+
cur = event.position().toPoint()
|
|
2691
|
+
dx = cur.x() - self._drag_start.x()
|
|
2692
|
+
dy = cur.y() - self._drag_start.y()
|
|
2693
|
+
self._drag_start = cur
|
|
2694
|
+
scale = self.base_scale * self._zoom
|
|
2695
|
+
inv_rot = np.linalg.inv(self._rot)
|
|
2696
|
+
delta_3d = inv_rot @ np.array([dx / scale, -dy / scale, 0.0])
|
|
2697
|
+
# Move clicked atom
|
|
2698
|
+
atom = self.molecule.atoms[self._drag_atom_idx]
|
|
2699
|
+
atom.x += delta_3d[0]
|
|
2700
|
+
atom.y += delta_3d[1]
|
|
2701
|
+
atom.z += delta_3d[2]
|
|
2702
|
+
# Move all other selected atoms together
|
|
2703
|
+
if self._drag_atom_idx in self.selected_atoms:
|
|
2704
|
+
for i in self.selected_atoms:
|
|
2705
|
+
if i == self._drag_atom_idx:
|
|
2706
|
+
continue
|
|
2707
|
+
a = self.molecule.atoms[i]
|
|
2708
|
+
a.x += delta_3d[0]
|
|
2709
|
+
a.y += delta_3d[1]
|
|
2710
|
+
a.z += delta_3d[2]
|
|
2711
|
+
self.request_render(delay_ms=16)
|
|
2712
|
+
return
|
|
2713
|
+
|
|
2714
|
+
if self._drag_start is None or self.molecule is None:
|
|
2715
|
+
return
|
|
2716
|
+
cur = event.position().toPoint()
|
|
2717
|
+
dx = cur.x() - self._drag_start.x()
|
|
2718
|
+
dy = cur.y() - self._drag_start.y()
|
|
2719
|
+
self._drag_start = cur
|
|
2720
|
+
|
|
2721
|
+
if self._drag_mode == "rotate":
|
|
2722
|
+
sens = 0.008
|
|
2723
|
+
ax, ay = dy*sens, dx*sens
|
|
2724
|
+
cx,sx = math.cos(ax), math.sin(ax)
|
|
2725
|
+
cy,sy = math.cos(ay), math.sin(ay)
|
|
2726
|
+
Rx = np.array([[1,0,0],[0,cx,-sx],[0,sx,cx]])
|
|
2727
|
+
Ry = np.array([[cy,0,sy],[0,1,0],[-sy,0,cy]])
|
|
2728
|
+
self._rot = Rx @ Ry @ self._rot
|
|
2729
|
+
elif self._drag_mode == "pan":
|
|
2730
|
+
self._pan[0] += dx
|
|
2731
|
+
self._pan[1] += dy
|
|
2732
|
+
elif self._drag_mode == "rotate_around_bond" and self._align_axis is not None:
|
|
2733
|
+
sens = 0.008
|
|
2734
|
+
angle = dx * sens
|
|
2735
|
+
if abs(angle) > 1e-10:
|
|
2736
|
+
axis = self._rot @ self._align_axis
|
|
2737
|
+
ca = math.cos(angle); sa = math.sin(angle)
|
|
2738
|
+
K = np.array([[0, -axis[2], axis[1]],
|
|
2739
|
+
[axis[2], 0, -axis[0]],
|
|
2740
|
+
[-axis[1], axis[0], 0]])
|
|
2741
|
+
R = np.eye(3) + sa * K + (1.0 - ca) * (K @ K)
|
|
2742
|
+
self._rot = R @ self._rot
|
|
2743
|
+
|
|
2744
|
+
self.rotationChanged.emit()
|
|
2745
|
+
self.request_render(delay_ms=16)
|
|
2746
|
+
|
|
2747
|
+
def mouseReleaseEvent(self, event):
|
|
2748
|
+
self._update_cursor(event.position().toPoint(), event.modifiers())
|
|
2749
|
+
if self.selection_mode and self._sel_drag_start is not None:
|
|
2750
|
+
final_pt = event.position().toPoint()
|
|
2751
|
+
rect = QRectF(QPointF(self._sel_drag_start), QPointF(final_pt)).normalized()
|
|
2752
|
+
if rect.width() > 5 and rect.height() > 5 and self.molecule:
|
|
2753
|
+
atoms, _ = project_molecule(
|
|
2754
|
+
self.molecule, self._rot, self._pan[0], self._pan[1],
|
|
2755
|
+
self.width(), self.height(), self.base_scale * self._zoom, self.atom_scale
|
|
2756
|
+
)
|
|
2757
|
+
for i, (ax, ay, az, ar) in enumerate(atoms):
|
|
2758
|
+
if rect.contains(ax, ay):
|
|
2759
|
+
self.selected_atoms.add(i)
|
|
2760
|
+
self.request_render()
|
|
2761
|
+
self._sel_drag_start = None
|
|
2762
|
+
self._sel_rect = None
|
|
2763
|
+
self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
|
|
2764
|
+
self.update()
|
|
2765
|
+
return
|
|
2766
|
+
|
|
2767
|
+
if self.build_mode and self._bonding_from is not None:
|
|
2768
|
+
end_idx = self._get_hit_atom(event.position().toPoint())
|
|
2769
|
+
if end_idx is not None and end_idx == self._bonding_from:
|
|
2770
|
+
self.requestHistorySave.emit()
|
|
2771
|
+
self.molecule.atoms[self._bonding_from].element = self.build_element
|
|
2772
|
+
if self.auto_adjust_h:
|
|
2773
|
+
self._apply_auto_h(self.molecule.atoms[self._bonding_from])
|
|
2774
|
+
self._relax_h()
|
|
2775
|
+
self.moleculeChanged.emit()
|
|
2776
|
+
elif end_idx is not None and end_idx != self._bonding_from:
|
|
2777
|
+
self.requestHistorySave.emit()
|
|
2778
|
+
# Bond existing
|
|
2779
|
+
self.molecule.bonds.append(Bond(self._bonding_from, end_idx))
|
|
2780
|
+
|
|
2781
|
+
if self.auto_adjust_h:
|
|
2782
|
+
at1, at2 = self.molecule.atoms[self._bonding_from], self.molecule.atoms[end_idx]
|
|
2783
|
+
self._apply_auto_h(at1)
|
|
2784
|
+
self._apply_auto_h(at2)
|
|
2785
|
+
self._relax_h()
|
|
2786
|
+
|
|
2787
|
+
self.moleculeChanged.emit()
|
|
2788
|
+
elif end_idx is None:
|
|
2789
|
+
self.requestHistorySave.emit()
|
|
2790
|
+
# Add new atom and bond
|
|
2791
|
+
loc = self._unproject(event.position().toPoint())
|
|
2792
|
+
pos_arr = np.array([[a.x, a.y, a.z] for a in self.molecule.atoms])
|
|
2793
|
+
centroid = pos_arr.mean(axis=0)
|
|
2794
|
+
abs_pos = loc + centroid
|
|
2795
|
+
new_idx = len(self.molecule.atoms)
|
|
2796
|
+
self.molecule.atoms.append(Atom(self.build_element, abs_pos[0], abs_pos[1], abs_pos[2]))
|
|
2797
|
+
self.molecule.bonds.append(Bond(self._bonding_from, new_idx))
|
|
2798
|
+
|
|
2799
|
+
if self.auto_adjust_h:
|
|
2800
|
+
at1, at2 = self.molecule.atoms[self._bonding_from], self.molecule.atoms[new_idx]
|
|
2801
|
+
self._apply_auto_h(at1)
|
|
2802
|
+
self._apply_auto_h(at2)
|
|
2803
|
+
self._relax_h()
|
|
2804
|
+
|
|
2805
|
+
self.moleculeChanged.emit()
|
|
2806
|
+
|
|
2807
|
+
self._bonding_from = None
|
|
2808
|
+
self._mouse_pos = None
|
|
2809
|
+
self.request_render()
|
|
2810
|
+
return
|
|
2811
|
+
|
|
2812
|
+
was_atom_drag = self._drag_mode == "atom"
|
|
2813
|
+
self._drag_atom_idx = None
|
|
2814
|
+
self._drag_start = None
|
|
2815
|
+
self._drag_mode = "none"
|
|
2816
|
+
if was_atom_drag:
|
|
2817
|
+
if self.build_mode or self.selection_mode:
|
|
2818
|
+
self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
|
|
2819
|
+
else:
|
|
2820
|
+
self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
|
|
2821
|
+
elif self.align_mode:
|
|
2822
|
+
self._update_cursor(event.position().toPoint(), Qt.KeyboardModifier.NoModifier)
|
|
2823
|
+
else:
|
|
2824
|
+
self.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
|
|
2825
|
+
self.request_render(delay_ms=0)
|
|
2826
|
+
|
|
2827
|
+
def keyPressEvent(self, event):
|
|
2828
|
+
if event.matches(QKeySequence.StandardKey.SelectAll):
|
|
2829
|
+
if self.molecule:
|
|
2830
|
+
self.selected_atoms = set(range(len(self.molecule.atoms)))
|
|
2831
|
+
self.request_render()
|
|
2832
|
+
return
|
|
2833
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
2834
|
+
if self.selected_atoms:
|
|
2835
|
+
self.selected_atoms.clear()
|
|
2836
|
+
self.request_render()
|
|
2837
|
+
return
|
|
2838
|
+
mod = event.modifiers()
|
|
2839
|
+
if event.key() == Qt.Key.Key_A and (mod & Qt.KeyboardModifier.ShiftModifier) and (mod & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier)):
|
|
2840
|
+
if self.selected_atoms:
|
|
2841
|
+
self.selected_atoms.clear()
|
|
2842
|
+
self.request_render()
|
|
2843
|
+
return
|
|
2844
|
+
super().keyPressEvent(event)
|
|
2845
|
+
|
|
2846
|
+
def keyReleaseEvent(self, event):
|
|
2847
|
+
if self._mouse_pos and self._get_hit_atom(self._mouse_pos) is not None:
|
|
2848
|
+
self._update_cursor(self._mouse_pos, event.modifiers())
|
|
2849
|
+
super().keyReleaseEvent(event)
|
|
2850
|
+
|
|
2851
|
+
def _apply_auto_h(self, atom: Atom):
|
|
2852
|
+
valencies = {
|
|
2853
|
+
"H": 1, "C": 4, "N": 3, "O": 2, "F": 1,
|
|
2854
|
+
"P": 3, "S": 2, "Cl": 1, "Br": 1, "I": 1,
|
|
2855
|
+
"Si": 4, "B": 3
|
|
2856
|
+
}
|
|
2857
|
+
# Ideal X-H bond lengths per atom
|
|
2858
|
+
bond_lengths = {"H":0.74,"C":1.09,"N":1.01,"O":0.96,"F":0.92,
|
|
2859
|
+
"P":1.42,"S":1.34,"Cl":1.27,"Br":1.41,"I":1.61,
|
|
2860
|
+
"Si":1.48,"B":1.19}
|
|
2861
|
+
try:
|
|
2862
|
+
atom_idx = -1
|
|
2863
|
+
for i, a in enumerate(self.molecule.atoms):
|
|
2864
|
+
if a is atom:
|
|
2865
|
+
atom_idx = i
|
|
2866
|
+
break
|
|
2867
|
+
if atom_idx == -1: return
|
|
2868
|
+
except Exception: return
|
|
2869
|
+
|
|
2870
|
+
v = valencies.get(atom.element, 0)
|
|
2871
|
+
if v <= 0: return
|
|
2872
|
+
|
|
2873
|
+
# Collect existing neighbor indices and bond orders
|
|
2874
|
+
neighbors = []
|
|
2875
|
+
neighbor_orders = {}
|
|
2876
|
+
for b in self.molecule.bonds:
|
|
2877
|
+
if b.i == atom_idx:
|
|
2878
|
+
neighbors.append(b.j)
|
|
2879
|
+
neighbor_orders[b.j] = b.order
|
|
2880
|
+
elif b.j == atom_idx:
|
|
2881
|
+
neighbors.append(b.i)
|
|
2882
|
+
neighbor_orders[b.i] = b.order
|
|
2883
|
+
|
|
2884
|
+
# Separate current H (only bonded to this atom) from heavy neighbors
|
|
2885
|
+
h_indices = []
|
|
2886
|
+
heavy_indices = []
|
|
2887
|
+
heavy_order_sum = 0
|
|
2888
|
+
for nb_idx in set(neighbors):
|
|
2889
|
+
if self.molecule.atoms[nb_idx].element == "H":
|
|
2890
|
+
other_bonds = sum(1 for b in self.molecule.bonds if b.i == nb_idx or b.j == nb_idx)
|
|
2891
|
+
if other_bonds == 1:
|
|
2892
|
+
h_indices.append(nb_idx)
|
|
2893
|
+
else:
|
|
2894
|
+
heavy_indices.append(nb_idx)
|
|
2895
|
+
heavy_order_sum += neighbor_orders.get(nb_idx, 1)
|
|
2896
|
+
else:
|
|
2897
|
+
heavy_indices.append(nb_idx)
|
|
2898
|
+
heavy_order_sum += neighbor_orders.get(nb_idx, 1)
|
|
2899
|
+
|
|
2900
|
+
needed = max(0, v - heavy_order_sum)
|
|
2901
|
+
|
|
2902
|
+
# Remove excess H atoms
|
|
2903
|
+
if len(h_indices) > needed:
|
|
2904
|
+
to_del = sorted(h_indices[needed:], reverse=True)
|
|
2905
|
+
for idx in to_del:
|
|
2906
|
+
self.molecule.atoms.pop(idx)
|
|
2907
|
+
new_bonds = []
|
|
2908
|
+
for b in self.molecule.bonds:
|
|
2909
|
+
if b.i == idx or b.j == idx: continue
|
|
2910
|
+
ni = b.i - 1 if b.i > idx else b.i
|
|
2911
|
+
nj = b.j - 1 if b.j > idx else b.j
|
|
2912
|
+
new_bonds.append(Bond(ni, nj, b.order))
|
|
2913
|
+
self.molecule.bonds = new_bonds
|
|
2914
|
+
if atom_idx > idx: atom_idx -= 1
|
|
2915
|
+
return
|
|
2916
|
+
|
|
2917
|
+
if len(h_indices) >= needed:
|
|
2918
|
+
return
|
|
2919
|
+
|
|
2920
|
+
# Need to add (needed - len(h_indices)) H atoms
|
|
2921
|
+
n_add = needed - len(h_indices)
|
|
2922
|
+
|
|
2923
|
+
# Build existing bond direction vectors (from atom to heavy neighbors)
|
|
2924
|
+
atom_pos = np.array([atom.x, atom.y, atom.z])
|
|
2925
|
+
existing_vecs = []
|
|
2926
|
+
for nb_idx in heavy_indices:
|
|
2927
|
+
nb = self.molecule.atoms[nb_idx]
|
|
2928
|
+
d = np.array([nb.x - atom.x, nb.y - atom.y, nb.z - atom.z])
|
|
2929
|
+
nd = np.linalg.norm(d)
|
|
2930
|
+
if nd > 1e-4:
|
|
2931
|
+
existing_vecs.append(d / nd)
|
|
2932
|
+
else:
|
|
2933
|
+
existing_vecs.append(np.array([1.0, 0.0, 0.0]))
|
|
2934
|
+
|
|
2935
|
+
bl = bond_lengths.get(atom.element, 1.09)
|
|
2936
|
+
steric = len(heavy_indices) + needed # total bonded atoms (including H to add)
|
|
2937
|
+
if atom.element == "C":
|
|
2938
|
+
angle_by_s = {4: 109.47, 3: 120.0, 2: 180.0}
|
|
2939
|
+
elif atom.element == "O":
|
|
2940
|
+
angle_by_s = {4: 109.47, 3: 120.0, 2: 104.5, 1: 180.0}
|
|
2941
|
+
elif atom.element == "N":
|
|
2942
|
+
angle_by_s = {4: 109.47, 3: 120.0, 2: 104.5, 1: 180.0}
|
|
2943
|
+
else:
|
|
2944
|
+
angle_by_s = {4: 109.47, 3: 120.0, 2: 104.5, 1: 180.0}
|
|
2945
|
+
angle_deg = angle_by_s.get(steric, 109.47)
|
|
2946
|
+
angle_rad = math.radians(angle_deg)
|
|
2947
|
+
|
|
2948
|
+
# Generate ideal geometry directions for the full steric number
|
|
2949
|
+
ideal_dirs = _ideal_directions(steric, angle_rad)
|
|
2950
|
+
|
|
2951
|
+
# Match existing bond directions to closest ideal directions
|
|
2952
|
+
used = set()
|
|
2953
|
+
h_dirs = []
|
|
2954
|
+
for vec in existing_vecs:
|
|
2955
|
+
best = -1
|
|
2956
|
+
best_dot = -2
|
|
2957
|
+
for j, d in enumerate(ideal_dirs):
|
|
2958
|
+
if j in used: continue
|
|
2959
|
+
dot = np.dot(vec, d)
|
|
2960
|
+
if dot > best_dot:
|
|
2961
|
+
best_dot = dot
|
|
2962
|
+
best = j
|
|
2963
|
+
used.add(best)
|
|
2964
|
+
|
|
2965
|
+
for j in range(len(ideal_dirs)):
|
|
2966
|
+
if j not in used:
|
|
2967
|
+
h_dirs.append(ideal_dirs[j])
|
|
2968
|
+
|
|
2969
|
+
# Add H atoms in the available ideal directions
|
|
2970
|
+
for i in range(min(n_add, len(h_dirs))):
|
|
2971
|
+
h_idx = len(self.molecule.atoms)
|
|
2972
|
+
off = h_dirs[i] * bl
|
|
2973
|
+
self.molecule.atoms.append(Atom("H", atom.x + off[0], atom.y + off[1], atom.z + off[2]))
|
|
2974
|
+
self.molecule.bonds.append(Bond(atom_idx, h_idx))
|
|
2975
|
+
# If more H needed than available ideal slots (edge case), place at uniform offsets
|
|
2976
|
+
for i in range(n_add - min(n_add, len(h_dirs))):
|
|
2977
|
+
h_idx = len(self.molecule.atoms)
|
|
2978
|
+
off = np.array([1.0, 0.0, 0.0])
|
|
2979
|
+
self.molecule.atoms.append(Atom("H", atom.x + off[0], atom.y + off[1], atom.z + off[2]))
|
|
2980
|
+
self.molecule.bonds.append(Bond(atom_idx, h_idx))
|
|
2981
|
+
|
|
2982
|
+
def _relax_h(self):
|
|
2983
|
+
"""Geometrically re-place all H atoms (no FF solver needed)."""
|
|
2984
|
+
if not self.molecule:
|
|
2985
|
+
return
|
|
2986
|
+
parents_to_relax = []
|
|
2987
|
+
for atom_idx, atom in enumerate(self.molecule.atoms):
|
|
2988
|
+
if atom.element == "H":
|
|
2989
|
+
continue
|
|
2990
|
+
has_h = False
|
|
2991
|
+
for b in self.molecule.bonds:
|
|
2992
|
+
other = b.j if b.i == atom_idx else (b.i if b.j == atom_idx else -1)
|
|
2993
|
+
if 0 <= other < len(self.molecule.atoms) and self.molecule.atoms[other].element == "H":
|
|
2994
|
+
has_h = True
|
|
2995
|
+
break
|
|
2996
|
+
if has_h:
|
|
2997
|
+
parents_to_relax.append(atom)
|
|
2998
|
+
for atom in parents_to_relax:
|
|
2999
|
+
if atom in self.molecule.atoms:
|
|
3000
|
+
self._apply_auto_h(atom)
|
|
3001
|
+
|
|
3002
|
+
def _get_non_h_indices(self) -> List[int]:
|
|
3003
|
+
if not self.molecule: return []
|
|
3004
|
+
return [i for i, a in enumerate(self.molecule.atoms) if a.element != "H"]
|
|
3005
|
+
|
|
3006
|
+
def wheelEvent(self, event):
|
|
3007
|
+
if self.molecule is None:
|
|
3008
|
+
return
|
|
3009
|
+
factor = 1.12 if event.angleDelta().y() > 0 else (1/1.12)
|
|
3010
|
+
self._zoom = max(0.15, min(6.0, self._zoom * factor))
|
|
3011
|
+
self.rotationChanged.emit()
|
|
3012
|
+
self.request_render(delay_ms=16)
|
|
3013
|
+
|
|
3014
|
+
def resizeEvent(self, event):
|
|
3015
|
+
super().resizeEvent(event)
|
|
3016
|
+
self.request_render(delay_ms=80)
|
|
3017
|
+
|
|
3018
|
+
|
|
3019
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
3020
|
+
# MAIN WINDOW
|
|
3021
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
3022
|
+
class MainWindow(QMainWindow):
|
|
3023
|
+
|
|
3024
|
+
DEFAULT_SHORTCUTS = {
|
|
3025
|
+
"open": _mod("Ctrl+O"),
|
|
3026
|
+
"save_as": _mod("Ctrl+S"),
|
|
3027
|
+
"export_svg": _mod("Ctrl+Shift+S"),
|
|
3028
|
+
"export_view": _mod("Ctrl+E"),
|
|
3029
|
+
"quick_svg_export": _mod("Ctrl+Shift+X"),
|
|
3030
|
+
"quit": _mod("Ctrl+Q"),
|
|
3031
|
+
"settings": _mod("Ctrl+;"),
|
|
3032
|
+
"style": _mod("Ctrl+="),
|
|
3033
|
+
"shortcuts": "",
|
|
3034
|
+
"info": _mod("Ctrl+I"),
|
|
3035
|
+
"charge": "",
|
|
3036
|
+
"inchi": "",
|
|
3037
|
+
"selection_mode": "S",
|
|
3038
|
+
"build_mode": "B",
|
|
3039
|
+
"align_mode": "A",
|
|
3040
|
+
"reset_view": "R",
|
|
3041
|
+
"reset_xyz_axes": "Shift+R",
|
|
3042
|
+
"preset_top": "",
|
|
3043
|
+
"preset_bottom": "",
|
|
3044
|
+
"preset_front": "",
|
|
3045
|
+
"preset_back": "",
|
|
3046
|
+
"preset_left": "",
|
|
3047
|
+
"preset_right": "",
|
|
3048
|
+
"preset_perspective": "",
|
|
3049
|
+
"preset_bc_plane": "1",
|
|
3050
|
+
"preset_ac_plane": "2",
|
|
3051
|
+
"preset_ab_plane": "3",
|
|
3052
|
+
"overlay_settings": "",
|
|
3053
|
+
"g16_input": "",
|
|
3054
|
+
"rotational_constants": "",
|
|
3055
|
+
"dipole_moment": "",
|
|
3056
|
+
"calc_results": _mod("Ctrl+M"),
|
|
3057
|
+
"clean_molecule": _mod("Ctrl+L"),
|
|
3058
|
+
"undo": _mod("Ctrl+Z"),
|
|
3059
|
+
"redo": _mod("Ctrl+Shift+Z"),
|
|
3060
|
+
"optimize_settings": "",
|
|
3061
|
+
"bond_order": "",
|
|
3062
|
+
"open_test": _mod("Ctrl+Shift+T"),
|
|
3063
|
+
"open_test_folder": "",
|
|
3064
|
+
"about": "",
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
ALT_SHORTCUT_DEFAULTS = {
|
|
3068
|
+
"redo": _mod("Ctrl+Y"),
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
LAST_MOLECULE_FILE = os.path.join(
|
|
3072
|
+
os.path.dirname(os.path.abspath(__file__)), "molvector_last_molecule.txt"
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
def __init__(self):
|
|
3076
|
+
super().__init__()
|
|
3077
|
+
self.setWindowTitle("Molvector — Molecule Viewer")
|
|
3078
|
+
self.resize(1080, 720)
|
|
3079
|
+
self._current_theme = "light"
|
|
3080
|
+
self._apply_theme("light")
|
|
3081
|
+
self._current_source = "None"
|
|
3082
|
+
self._current_path = ""
|
|
3083
|
+
self._color_overrides: dict = {} # elem -> hex
|
|
3084
|
+
|
|
3085
|
+
# Animation timer
|
|
3086
|
+
self._anim_timer = QTimer(self)
|
|
3087
|
+
self._anim_timer.setInterval(40) # ~25 FPS
|
|
3088
|
+
self._anim_timer.timeout.connect(self._on_anim_step)
|
|
3089
|
+
self._anim_phase = 0.0
|
|
3090
|
+
|
|
3091
|
+
# FF Parameters
|
|
3092
|
+
self._ff_max_steps = 500
|
|
3093
|
+
self._ff_tol = 0.01
|
|
3094
|
+
|
|
3095
|
+
# Undo/Redo history
|
|
3096
|
+
self._history = [] # list of deepcopied Molecule
|
|
3097
|
+
self._redo_stack = []
|
|
3098
|
+
self._max_history = 50
|
|
3099
|
+
|
|
3100
|
+
# Toolbar icon size for mode buttons
|
|
3101
|
+
self._mode_icon_size = QSize(22, 22)
|
|
3102
|
+
|
|
3103
|
+
self._shortcut_actions: dict = {}
|
|
3104
|
+
|
|
3105
|
+
self._build_central()
|
|
3106
|
+
self._build_menubar()
|
|
3107
|
+
self._build_toolbar()
|
|
3108
|
+
self._setup_builder_toolbar()
|
|
3109
|
+
self._set_build_options_visible(False)
|
|
3110
|
+
self._build_statusbar()
|
|
3111
|
+
self._load_appearance_config()
|
|
3112
|
+
self._apply_shortcut_config()
|
|
3113
|
+
self._maybe_restore_molecule()
|
|
3114
|
+
self._show_placeholder()
|
|
3115
|
+
self.setAcceptDrops(True)
|
|
3116
|
+
|
|
3117
|
+
# ── menu bar ─────────────────────────────────────────────────────────────
|
|
3118
|
+
|
|
3119
|
+
def _build_menubar(self):
|
|
3120
|
+
mb = self.menuBar()
|
|
3121
|
+
|
|
3122
|
+
# ── File ──
|
|
3123
|
+
file_menu = mb.addMenu("&File")
|
|
3124
|
+
|
|
3125
|
+
act_open = QAction("&Open…", self)
|
|
3126
|
+
act_open.setShortcut(_mod("Ctrl+O"))
|
|
3127
|
+
act_open.triggered.connect(self._open_file)
|
|
3128
|
+
file_menu.addAction(act_open)
|
|
3129
|
+
self._shortcut_actions["open"] = act_open
|
|
3130
|
+
|
|
3131
|
+
act_save_as = QAction("Save &As…", self)
|
|
3132
|
+
act_save_as.setShortcut(_mod("Ctrl+S"))
|
|
3133
|
+
act_save_as.triggered.connect(self._save_as)
|
|
3134
|
+
file_menu.addAction(act_save_as)
|
|
3135
|
+
self._shortcut_actions["save_as"] = act_save_as
|
|
3136
|
+
|
|
3137
|
+
file_menu.addSeparator()
|
|
3138
|
+
|
|
3139
|
+
act_save_svg = QAction("Export as &SVG…", self)
|
|
3140
|
+
act_save_svg.setShortcut(_mod("Ctrl+Shift+S"))
|
|
3141
|
+
act_save_svg.triggered.connect(self._save_svg)
|
|
3142
|
+
file_menu.addAction(act_save_svg)
|
|
3143
|
+
self._shortcut_actions["export_svg"] = act_save_svg
|
|
3144
|
+
|
|
3145
|
+
act_export = QAction("&Export View…", self)
|
|
3146
|
+
act_export.setShortcut(_mod("Ctrl+E"))
|
|
3147
|
+
act_export.triggered.connect(self._export_view)
|
|
3148
|
+
file_menu.addAction(act_export)
|
|
3149
|
+
self._shortcut_actions["export_view"] = act_export
|
|
3150
|
+
|
|
3151
|
+
act_quick_svg = QAction("Quick SVG E&xport…", self)
|
|
3152
|
+
act_quick_svg.setShortcut(_mod("Ctrl+Shift+X"))
|
|
3153
|
+
act_quick_svg.triggered.connect(self._quick_svg_export)
|
|
3154
|
+
file_menu.addAction(act_quick_svg)
|
|
3155
|
+
self._shortcut_actions["quick_svg_export"] = act_quick_svg
|
|
3156
|
+
|
|
3157
|
+
file_menu.addSeparator()
|
|
3158
|
+
|
|
3159
|
+
act_quit = QAction("&Quit", self)
|
|
3160
|
+
act_quit.setShortcut(_mod("Ctrl+Q"))
|
|
3161
|
+
act_quit.triggered.connect(self.close)
|
|
3162
|
+
file_menu.addAction(act_quit)
|
|
3163
|
+
self._shortcut_actions["quit"] = act_quit
|
|
3164
|
+
|
|
3165
|
+
# ── Edit ──
|
|
3166
|
+
edit_menu = mb.addMenu("&Edit")
|
|
3167
|
+
|
|
3168
|
+
act_settings = QAction("&Settings…", self)
|
|
3169
|
+
act_settings.setShortcut(_mod("Ctrl+;"))
|
|
3170
|
+
act_settings.triggered.connect(self._edit_settings)
|
|
3171
|
+
edit_menu.addAction(act_settings)
|
|
3172
|
+
self._shortcut_actions["settings"] = act_settings
|
|
3173
|
+
|
|
3174
|
+
act_style = QAction("&Style…", self)
|
|
3175
|
+
act_style.setShortcut(_mod("Ctrl+="))
|
|
3176
|
+
act_style.triggered.connect(self._edit_style)
|
|
3177
|
+
edit_menu.addAction(act_style)
|
|
3178
|
+
self._shortcut_actions["style"] = act_style
|
|
3179
|
+
|
|
3180
|
+
act_shortcuts = QAction("&Shortcuts…", self)
|
|
3181
|
+
act_shortcuts.triggered.connect(self._edit_shortcuts)
|
|
3182
|
+
edit_menu.addAction(act_shortcuts)
|
|
3183
|
+
self._shortcut_actions["shortcuts"] = act_shortcuts
|
|
3184
|
+
|
|
3185
|
+
edit_menu.addSeparator()
|
|
3186
|
+
|
|
3187
|
+
act_info = QAction("&Info…", self)
|
|
3188
|
+
act_info.setShortcut(_mod("Ctrl+I"))
|
|
3189
|
+
act_info.triggered.connect(self._show_molecule_info)
|
|
3190
|
+
edit_menu.addAction(act_info)
|
|
3191
|
+
self._shortcut_actions["info"] = act_info
|
|
3192
|
+
|
|
3193
|
+
act_charge = QAction("Edit &Charge…", self)
|
|
3194
|
+
act_charge.triggered.connect(self._edit_charge)
|
|
3195
|
+
edit_menu.addAction(act_charge)
|
|
3196
|
+
self._shortcut_actions["charge"] = act_charge
|
|
3197
|
+
|
|
3198
|
+
act_inchi = QAction("&InChI…", self)
|
|
3199
|
+
act_inchi.triggered.connect(self._show_inchi)
|
|
3200
|
+
edit_menu.addAction(act_inchi)
|
|
3201
|
+
self._shortcut_actions["inchi"] = act_inchi
|
|
3202
|
+
|
|
3203
|
+
edit_menu.addSeparator()
|
|
3204
|
+
|
|
3205
|
+
act_select_toggle = QAction("Selection Mode", self)
|
|
3206
|
+
act_select_toggle.setCheckable(True)
|
|
3207
|
+
act_select_toggle.setShortcut("S")
|
|
3208
|
+
act_select_toggle.triggered.connect(self._toggle_selection_mode)
|
|
3209
|
+
edit_menu.addAction(act_select_toggle)
|
|
3210
|
+
self._act_select_toggle = act_select_toggle
|
|
3211
|
+
self._shortcut_actions["selection_mode"] = act_select_toggle
|
|
3212
|
+
|
|
3213
|
+
act_toggle = QAction("Build Mode", self)
|
|
3214
|
+
act_toggle.setCheckable(True)
|
|
3215
|
+
act_toggle.setShortcut("B")
|
|
3216
|
+
act_toggle.triggered.connect(self._toggle_build_mode)
|
|
3217
|
+
edit_menu.addAction(act_toggle)
|
|
3218
|
+
self._act_build_toggle = act_toggle
|
|
3219
|
+
self._shortcut_actions["build_mode"] = act_toggle
|
|
3220
|
+
|
|
3221
|
+
act_align_toggle = QAction("Align Mode", self)
|
|
3222
|
+
act_align_toggle.setCheckable(True)
|
|
3223
|
+
act_align_toggle.setShortcut("A")
|
|
3224
|
+
act_align_toggle.triggered.connect(self._toggle_align_mode)
|
|
3225
|
+
edit_menu.addAction(act_align_toggle)
|
|
3226
|
+
self._act_align_toggle = act_align_toggle
|
|
3227
|
+
self._shortcut_actions["align_mode"] = act_align_toggle
|
|
3228
|
+
|
|
3229
|
+
# ── View ──
|
|
3230
|
+
view_menu = mb.addMenu("&View")
|
|
3231
|
+
act_reset_view = QAction("&Reset View", self)
|
|
3232
|
+
act_reset_view.setShortcut("R")
|
|
3233
|
+
act_reset_view.triggered.connect(self._reset_view)
|
|
3234
|
+
view_menu.addAction(act_reset_view)
|
|
3235
|
+
self._shortcut_actions["reset_view"] = act_reset_view
|
|
3236
|
+
|
|
3237
|
+
act_reset_xyz = QAction("Set XYZ &Axes", self)
|
|
3238
|
+
act_reset_xyz.setShortcut("Shift+R")
|
|
3239
|
+
act_reset_xyz.triggered.connect(lambda: self._canvas.reset_xyz_axes())
|
|
3240
|
+
view_menu.addAction(act_reset_xyz)
|
|
3241
|
+
self._shortcut_actions["reset_xyz_axes"] = act_reset_xyz
|
|
3242
|
+
|
|
3243
|
+
view_menu.addSeparator()
|
|
3244
|
+
|
|
3245
|
+
presets_menu = view_menu.addMenu("&Preset Orientation")
|
|
3246
|
+
preset_names = {
|
|
3247
|
+
"preset_top": ("Top", ( 5, 0, 0)),
|
|
3248
|
+
"preset_bottom": ("Bottom", (175, 0, 0)),
|
|
3249
|
+
"preset_front": ("Front", ( 0, 0, 0)),
|
|
3250
|
+
"preset_back": ("Back", ( 0,180, 0)),
|
|
3251
|
+
"preset_left": ("Left", ( 0,-90, 0)),
|
|
3252
|
+
"preset_right": ("Right", ( 0, 90, 0)),
|
|
3253
|
+
"preset_perspective": ("Perspective", (55, 20, 15)),
|
|
3254
|
+
}
|
|
3255
|
+
for key, (label, rot) in preset_names.items():
|
|
3256
|
+
a = QAction(label, self)
|
|
3257
|
+
a.triggered.connect(lambda _, r=rot: self._canvas.set_preset(*r))
|
|
3258
|
+
presets_menu.addAction(a)
|
|
3259
|
+
self._shortcut_actions[key] = a
|
|
3260
|
+
|
|
3261
|
+
presets_menu.addSeparator()
|
|
3262
|
+
for idx, label in [(0, "BC plane"), (1, "AC plane"), (2, "AB plane")]:
|
|
3263
|
+
a = QAction(label, self)
|
|
3264
|
+
a.setShortcut(str(idx + 1))
|
|
3265
|
+
a.triggered.connect(lambda _, i=idx: self._canvas.set_principal_axis_preset(i))
|
|
3266
|
+
presets_menu.addAction(a)
|
|
3267
|
+
key = ["preset_bc_plane", "preset_ac_plane", "preset_ab_plane"][idx]
|
|
3268
|
+
self._shortcut_actions[key] = a
|
|
3269
|
+
|
|
3270
|
+
view_menu.addSeparator()
|
|
3271
|
+
act_overlay = QAction("Overlay Settings", self)
|
|
3272
|
+
act_overlay.triggered.connect(self._edit_overlay)
|
|
3273
|
+
view_menu.addAction(act_overlay)
|
|
3274
|
+
self._shortcut_actions["overlay_settings"] = act_overlay
|
|
3275
|
+
|
|
3276
|
+
# ── Calculations ──
|
|
3277
|
+
self._menu_calc = mb.addMenu("&Calculations")
|
|
3278
|
+
act_g16 = QAction("Generate G16 Input…", self)
|
|
3279
|
+
act_g16.triggered.connect(self._generate_g16_input)
|
|
3280
|
+
self._menu_calc.addAction(act_g16)
|
|
3281
|
+
self._shortcut_actions["g16_input"] = act_g16
|
|
3282
|
+
self._menu_calc.setEnabled(True)
|
|
3283
|
+
|
|
3284
|
+
self._menu_calc.addSeparator()
|
|
3285
|
+
act_rotconstant = QAction("Calculate Rotational Constants", self)
|
|
3286
|
+
act_rotconstant.triggered.connect(self._calculate_rotational_constants)
|
|
3287
|
+
self._menu_calc.addAction(act_rotconstant)
|
|
3288
|
+
self._shortcut_actions["rotational_constants"] = act_rotconstant
|
|
3289
|
+
|
|
3290
|
+
act_dipole = QAction("Calculate Dipole Moment", self)
|
|
3291
|
+
act_dipole.triggered.connect(self._calculate_dipole_moment)
|
|
3292
|
+
self._menu_calc.addAction(act_dipole)
|
|
3293
|
+
self._shortcut_actions["dipole_moment"] = act_dipole
|
|
3294
|
+
|
|
3295
|
+
# ── Build ──
|
|
3296
|
+
self._menu_build = mb.addMenu("&Build")
|
|
3297
|
+
|
|
3298
|
+
act_clean_m = QAction("Clean Molecule", self)
|
|
3299
|
+
act_clean_m.setShortcut(_mod("Ctrl+L"))
|
|
3300
|
+
act_clean_m.triggered.connect(self._clean_molecule)
|
|
3301
|
+
self._menu_build.addAction(act_clean_m)
|
|
3302
|
+
self._shortcut_actions["clean_molecule"] = act_clean_m
|
|
3303
|
+
|
|
3304
|
+
act_undo = QAction("Undo", self)
|
|
3305
|
+
act_undo.setShortcut(_mod("Ctrl+Z"))
|
|
3306
|
+
act_undo.triggered.connect(self._undo)
|
|
3307
|
+
self._menu_build.addAction(act_undo)
|
|
3308
|
+
self._shortcut_actions["undo"] = act_undo
|
|
3309
|
+
|
|
3310
|
+
act_redo = QAction("Redo", self)
|
|
3311
|
+
act_redo.setShortcuts([QKeySequence(_mod("Ctrl+Shift+Z")), QKeySequence(_mod("Ctrl+Y"))])
|
|
3312
|
+
act_redo.triggered.connect(self._redo)
|
|
3313
|
+
self._menu_build.addAction(act_redo)
|
|
3314
|
+
self._shortcut_actions["redo"] = act_redo
|
|
3315
|
+
|
|
3316
|
+
act_ff = QAction("Optimize Settings…", self)
|
|
3317
|
+
act_ff.triggered.connect(self._edit_ff_settings)
|
|
3318
|
+
self._menu_build.addAction(act_ff)
|
|
3319
|
+
self._shortcut_actions["optimize_settings"] = act_ff
|
|
3320
|
+
|
|
3321
|
+
self._menu_build.addSeparator()
|
|
3322
|
+
|
|
3323
|
+
self._act_bond_order = QAction("Disable Bond Order", self)
|
|
3324
|
+
self._act_bond_order.setCheckable(True)
|
|
3325
|
+
self._act_bond_order.triggered.connect(self._toggle_bond_order)
|
|
3326
|
+
self._menu_build.addAction(self._act_bond_order)
|
|
3327
|
+
self._shortcut_actions["bond_order"] = self._act_bond_order
|
|
3328
|
+
|
|
3329
|
+
act_paint = QAction("Paint Selected Atoms…", self)
|
|
3330
|
+
act_paint.triggered.connect(self._paint_selected_atoms)
|
|
3331
|
+
self._menu_build.addAction(act_paint)
|
|
3332
|
+
self._shortcut_actions["paint_atoms"] = act_paint
|
|
3333
|
+
|
|
3334
|
+
act_clear_paint = QAction("Clear Paint", self)
|
|
3335
|
+
act_clear_paint.triggered.connect(self._clear_paint)
|
|
3336
|
+
self._menu_build.addAction(act_clear_paint)
|
|
3337
|
+
self._shortcut_actions["clear_paint"] = act_clear_paint
|
|
3338
|
+
|
|
3339
|
+
# ── Help ──
|
|
3340
|
+
help_menu = mb.addMenu("&Help")
|
|
3341
|
+
act_open_test = QAction("Open Test Molecule", self)
|
|
3342
|
+
act_open_test.setShortcut(_mod("Ctrl+Shift+T"))
|
|
3343
|
+
act_open_test.triggered.connect(self._open_test_molecule)
|
|
3344
|
+
help_menu.addAction(act_open_test)
|
|
3345
|
+
self._shortcut_actions["open_test"] = act_open_test
|
|
3346
|
+
|
|
3347
|
+
act_open_test_folder = QAction("Open Test Files Folder", self)
|
|
3348
|
+
act_open_test_folder.triggered.connect(
|
|
3349
|
+
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(
|
|
3350
|
+
os.path.join(os.path.dirname(os.path.dirname(__file__)), "test_files"))))
|
|
3351
|
+
help_menu.addAction(act_open_test_folder)
|
|
3352
|
+
self._shortcut_actions["open_test_folder"] = act_open_test_folder
|
|
3353
|
+
|
|
3354
|
+
help_menu.addSeparator()
|
|
3355
|
+
|
|
3356
|
+
act_about = QAction("&About Molvector", self)
|
|
3357
|
+
act_about.triggered.connect(self._show_about)
|
|
3358
|
+
help_menu.addAction(act_about)
|
|
3359
|
+
self._shortcut_actions["about"] = act_about
|
|
3360
|
+
|
|
3361
|
+
def _setup_builder_toolbar(self):
|
|
3362
|
+
assets_dir = os.path.join(os.path.dirname(__file__), "assets", "icons")
|
|
3363
|
+
self._build_toolbar_obj = QToolBar("Builder")
|
|
3364
|
+
self._build_toolbar_obj.setObjectName("builderToolbar")
|
|
3365
|
+
self._build_toolbar_obj.setIconSize(QSize(22, 22))
|
|
3366
|
+
self._build_toolbar_obj.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
|
3367
|
+
self.addToolBar(self._build_toolbar_obj)
|
|
3368
|
+
|
|
3369
|
+
icon_color = '#ccd6f6' if self._current_theme == 'dark' else '#000000'
|
|
3370
|
+
|
|
3371
|
+
# Selection tool
|
|
3372
|
+
select_path = os.path.join(assets_dir, "icon_select.svg")
|
|
3373
|
+
self._act_select_btn = QAction(load_colored_icon(select_path, icon_color), "Selection", self)
|
|
3374
|
+
self._act_select_btn.setCheckable(True)
|
|
3375
|
+
self._act_select_btn.setToolTip("Selection tool — click or drag to select atoms")
|
|
3376
|
+
self._act_select_btn.triggered.connect(self._toggle_selection_mode)
|
|
3377
|
+
self._build_toolbar_obj.addAction(self._act_select_btn)
|
|
3378
|
+
|
|
3379
|
+
# Build tool
|
|
3380
|
+
draw_path = os.path.join(assets_dir, "icon_draw.svg")
|
|
3381
|
+
self._act_build_btn = QAction(load_colored_icon(draw_path, icon_color), "Build Mode", self)
|
|
3382
|
+
self._act_build_btn.setCheckable(True)
|
|
3383
|
+
self._act_build_btn.setToolTip("Build mode — add / bond atoms")
|
|
3384
|
+
self._act_build_btn.triggered.connect(self._toggle_build_mode)
|
|
3385
|
+
self._build_toolbar_obj.addAction(self._act_build_btn)
|
|
3386
|
+
|
|
3387
|
+
# Align tool
|
|
3388
|
+
align_path = os.path.join(assets_dir, "icon_align.svg")
|
|
3389
|
+
self._act_align_btn = QAction(load_colored_icon(align_path, icon_color), "Align", self)
|
|
3390
|
+
self._act_align_btn.setCheckable(True)
|
|
3391
|
+
self._act_align_btn.setToolTip("Align tool — click a bond to align it vertically")
|
|
3392
|
+
self._act_align_btn.triggered.connect(self._toggle_align_mode)
|
|
3393
|
+
self._build_toolbar_obj.addAction(self._act_align_btn)
|
|
3394
|
+
|
|
3395
|
+
self._sep_build1 = self._build_toolbar_obj.addSeparator()
|
|
3396
|
+
self._act_lbl_elem = self._build_toolbar_obj.addWidget(QLabel(" Element: "))
|
|
3397
|
+
self._elem_combo = QComboBox()
|
|
3398
|
+
self._elem_combo.setMinimumContentsLength(3)
|
|
3399
|
+
self._elem_combo.setMaximumWidth(60)
|
|
3400
|
+
self._elem_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
|
|
3401
|
+
self._common_elements = ["H", "C", "N", "O", "F", "P", "S", "Cl", "Br", "I"]
|
|
3402
|
+
self._elem_combo.addItems(self._common_elements + ["..."])
|
|
3403
|
+
self._elem_combo.setCurrentText("C")
|
|
3404
|
+
self._elem_combo.currentTextChanged.connect(self._on_build_elem_change)
|
|
3405
|
+
self._act_elem_combo = self._build_toolbar_obj.addWidget(self._elem_combo)
|
|
3406
|
+
|
|
3407
|
+
self._sep_build2 = self._build_toolbar_obj.addSeparator()
|
|
3408
|
+
self._auto_h_check = QCheckBox("Auto adjust H")
|
|
3409
|
+
self._auto_h_check.setChecked(False)
|
|
3410
|
+
self._auto_h_check.toggled.connect(self._on_auto_h_toggle)
|
|
3411
|
+
self._act_auto_h = self._build_toolbar_obj.addWidget(self._auto_h_check)
|
|
3412
|
+
|
|
3413
|
+
self._sep_build3 = self._build_toolbar_obj.addSeparator()
|
|
3414
|
+
self._act_clean = QAction("Clean", self)
|
|
3415
|
+
self._act_clean.setToolTip("Rapidly optimize geometry (Force Field)")
|
|
3416
|
+
self._act_clean.triggered.connect(self._clean_molecule)
|
|
3417
|
+
self._build_toolbar_obj.addAction(self._act_clean)
|
|
3418
|
+
|
|
3419
|
+
def _set_build_options_visible(self, visible: bool):
|
|
3420
|
+
self._sep_build1.setVisible(visible)
|
|
3421
|
+
self._act_lbl_elem.setVisible(visible)
|
|
3422
|
+
self._act_elem_combo.setVisible(visible)
|
|
3423
|
+
self._sep_build2.setVisible(visible)
|
|
3424
|
+
self._act_auto_h.setVisible(visible)
|
|
3425
|
+
self._sep_build3.setVisible(visible)
|
|
3426
|
+
self._act_clean.setVisible(visible)
|
|
3427
|
+
self._build_toolbar_obj.updateGeometry()
|
|
3428
|
+
|
|
3429
|
+
# ── toolbar (only the most frequent actions) ─────────────────────────────
|
|
3430
|
+
|
|
3431
|
+
def _build_toolbar(self):
|
|
3432
|
+
tb = self.addToolBar("Main")
|
|
3433
|
+
tb.setMovable(False)
|
|
3434
|
+
tb.setIconSize(QSize(16,16))
|
|
3435
|
+
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
|
|
3436
|
+
|
|
3437
|
+
def tb_action(label, slot, shortcut=None, tooltip=None):
|
|
3438
|
+
a = QAction(label, self)
|
|
3439
|
+
if shortcut: a.setShortcut(shortcut)
|
|
3440
|
+
if tooltip: a.setToolTip(tooltip)
|
|
3441
|
+
a.triggered.connect(slot)
|
|
3442
|
+
tb.addAction(a)
|
|
3443
|
+
return a
|
|
3444
|
+
|
|
3445
|
+
tb_action("Open", self._open_file, _mod("Ctrl+O"), "Open molecule file")
|
|
3446
|
+
tb_action("Save", self._save_as, _mod("Ctrl+S"), "Save molecule file")
|
|
3447
|
+
tb_action("Export view", self._export_view, _mod("Ctrl+E"), "Export view as PDF/PNG/SVG")
|
|
3448
|
+
tb.addSeparator()
|
|
3449
|
+
tb_action("Reset view", self._reset_view)
|
|
3450
|
+
tb_action("Clear All", self._clear_molecule)
|
|
3451
|
+
tb.addSeparator()
|
|
3452
|
+
|
|
3453
|
+
# Zoom readout
|
|
3454
|
+
self._zoom_lbl = QLabel("100%")
|
|
3455
|
+
self._zoom_lbl.setFixedWidth(46)
|
|
3456
|
+
self._zoom_lbl.setObjectName("zoom_label")
|
|
3457
|
+
# Style this in get_stylesheet instead of here
|
|
3458
|
+
tb.addWidget(self._zoom_lbl)
|
|
3459
|
+
|
|
3460
|
+
# Zoom slider
|
|
3461
|
+
self._zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
|
3462
|
+
self._zoom_slider.setRange(15, 600)
|
|
3463
|
+
self._zoom_slider.setValue(100)
|
|
3464
|
+
self._zoom_slider.setFixedWidth(120)
|
|
3465
|
+
self._zoom_slider.setToolTip("Zoom level (%)")
|
|
3466
|
+
self._zoom_slider.valueChanged.connect(self._on_zoom_change)
|
|
3467
|
+
self._zoom_slider.installEventFilter(self)
|
|
3468
|
+
tb.addWidget(self._zoom_slider)
|
|
3469
|
+
|
|
3470
|
+
# ── central layout ────────────────────────────────────────────────────────
|
|
3471
|
+
|
|
3472
|
+
def _build_central(self):
|
|
3473
|
+
central = QWidget()
|
|
3474
|
+
self.setCentralWidget(central)
|
|
3475
|
+
root = QHBoxLayout(central)
|
|
3476
|
+
root.setContentsMargins(0,0,0,0)
|
|
3477
|
+
root.setSpacing(0)
|
|
3478
|
+
|
|
3479
|
+
# Sidebar
|
|
3480
|
+
sidebar = QWidget()
|
|
3481
|
+
sidebar.setObjectName("sidebar")
|
|
3482
|
+
sidebar.setFixedWidth(172)
|
|
3483
|
+
sl = QVBoxLayout(sidebar)
|
|
3484
|
+
sl.setContentsMargins(10,14,10,14)
|
|
3485
|
+
sl.setSpacing(10)
|
|
3486
|
+
|
|
3487
|
+
# Molecule info
|
|
3488
|
+
info = QGroupBox("Molecule")
|
|
3489
|
+
il = QVBoxLayout(info)
|
|
3490
|
+
self._lbl_name = QLabel("—")
|
|
3491
|
+
self._lbl_name.setWordWrap(True)
|
|
3492
|
+
self._lbl_name.setObjectName("mol_name")
|
|
3493
|
+
self._lbl_charge = QLabel("Charge: —")
|
|
3494
|
+
self._lbl_atoms = QLabel("Atoms: —")
|
|
3495
|
+
self._lbl_bonds = QLabel("Bonds: —")
|
|
3496
|
+
self._lbl_mass = QLabel("Mass: —")
|
|
3497
|
+
self._lbl_src = QLabel("Source: —")
|
|
3498
|
+
self._lbl_src.setObjectName("dim")
|
|
3499
|
+
self._lbl_src.setStyleSheet("font-size:10px;")
|
|
3500
|
+
for w in (self._lbl_name, self._lbl_atoms, self._lbl_bonds, self._lbl_mass, self._lbl_charge, self._lbl_src):
|
|
3501
|
+
il.addWidget(w)
|
|
3502
|
+
sl.addWidget(info)
|
|
3503
|
+
|
|
3504
|
+
# Calculations info
|
|
3505
|
+
self._calc_group = QGroupBox("Calculations")
|
|
3506
|
+
cl = QVBoxLayout(self._calc_group)
|
|
3507
|
+
self._lbl_energy = QLabel("Energy: —")
|
|
3508
|
+
self._lbl_vib = QLabel("Vibrations: —")
|
|
3509
|
+
self._lbl_td = QLabel("States: —")
|
|
3510
|
+
for w in (self._lbl_energy, self._lbl_vib, self._lbl_td):
|
|
3511
|
+
cl.addWidget(w)
|
|
3512
|
+
self._btn_view_calc = QPushButton("View Results…")
|
|
3513
|
+
self._btn_view_calc.clicked.connect(self._show_calculations_dialog)
|
|
3514
|
+
cl.addWidget(self._btn_view_calc)
|
|
3515
|
+
sl.addWidget(self._calc_group)
|
|
3516
|
+
self._calc_group.hide()
|
|
3517
|
+
|
|
3518
|
+
# Legend
|
|
3519
|
+
self._legend = LegendPanel()
|
|
3520
|
+
self._legend.elementColorChanged.connect(self._on_legend_color_changed)
|
|
3521
|
+
self._legend.atomColorChanged.connect(self._on_atom_color_changed)
|
|
3522
|
+
sl.addWidget(self._legend)
|
|
3523
|
+
sl.addStretch()
|
|
3524
|
+
|
|
3525
|
+
# Hint
|
|
3526
|
+
self._hint = QLabel("Drag rotate\nRight-drag pan\nScroll zoom")
|
|
3527
|
+
self._hint.setObjectName("hint")
|
|
3528
|
+
self._hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
3529
|
+
sl.addWidget(self._hint)
|
|
3530
|
+
|
|
3531
|
+
root.addWidget(sidebar)
|
|
3532
|
+
|
|
3533
|
+
# Canvas
|
|
3534
|
+
self._canvas = MoleculeCanvas()
|
|
3535
|
+
self._canvas.rotationChanged.connect(self._on_rotation_changed)
|
|
3536
|
+
self._canvas.fileDropped.connect(self._load_and_display)
|
|
3537
|
+
self._canvas.moleculeChanged.connect(self._on_structure_changed)
|
|
3538
|
+
self._canvas.requestHistorySave.connect(self._save_history)
|
|
3539
|
+
root.addWidget(self._canvas, 1)
|
|
3540
|
+
|
|
3541
|
+
# ── status bar ────────────────────────────────────────────────────────────
|
|
3542
|
+
|
|
3543
|
+
def _build_statusbar(self):
|
|
3544
|
+
self._status = QStatusBar()
|
|
3545
|
+
self.setStatusBar(self._status)
|
|
3546
|
+
self._status.showMessage(
|
|
3547
|
+
"No file loaded — use File → Open to load an .xyz, .gjf, .com or .log file."
|
|
3548
|
+
)
|
|
3549
|
+
|
|
3550
|
+
# ── placeholder ───────────────────────────────────────────────────────────
|
|
3551
|
+
|
|
3552
|
+
def _show_placeholder(self):
|
|
3553
|
+
bg = THEMES[self._current_theme]["CANVAS"]
|
|
3554
|
+
fg = THEMES[self._current_theme]["FG_DIM"]
|
|
3555
|
+
svg = (
|
|
3556
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="600" height="450" viewBox="0 0 600 450">'
|
|
3557
|
+
f'<rect width="600" height="450" fill="{bg}"/>'
|
|
3558
|
+
f'<text x="300" y="210" text-anchor="middle" font-family="monospace" font-size="15" fill="{fg}">'
|
|
3559
|
+
'Open a molecule file to begin'
|
|
3560
|
+
'</text>'
|
|
3561
|
+
'</svg>'
|
|
3562
|
+
).encode("ascii")
|
|
3563
|
+
self._canvas.load(QByteArray(svg))
|
|
3564
|
+
|
|
3565
|
+
# ── drag and drop ─────────────────────────────────────────────────────────
|
|
3566
|
+
|
|
3567
|
+
def dragEnterEvent(self, event):
|
|
3568
|
+
if event.mimeData().hasUrls():
|
|
3569
|
+
event.acceptProposedAction()
|
|
3570
|
+
else:
|
|
3571
|
+
event.ignore()
|
|
3572
|
+
|
|
3573
|
+
def dragMoveEvent(self, event):
|
|
3574
|
+
if event.mimeData().hasUrls():
|
|
3575
|
+
event.acceptProposedAction()
|
|
3576
|
+
else:
|
|
3577
|
+
event.ignore()
|
|
3578
|
+
|
|
3579
|
+
def dropEvent(self, event):
|
|
3580
|
+
urls = event.mimeData().urls()
|
|
3581
|
+
if urls and urls[0].isLocalFile():
|
|
3582
|
+
path = urls[0].toLocalFile()
|
|
3583
|
+
self._load_and_display(path)
|
|
3584
|
+
event.acceptProposedAction()
|
|
3585
|
+
else:
|
|
3586
|
+
event.ignore()
|
|
3587
|
+
|
|
3588
|
+
# ── file open / save ──────────────────────────────────────────────────────
|
|
3589
|
+
|
|
3590
|
+
def _load_mol_from_path(self, path: str):
|
|
3591
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
3592
|
+
text = f.read()
|
|
3593
|
+
base = os.path.basename(path)
|
|
3594
|
+
name = os.path.splitext(base)[0]
|
|
3595
|
+
|
|
3596
|
+
mol = None
|
|
3597
|
+
src = ""
|
|
3598
|
+
errors = []
|
|
3599
|
+
|
|
3600
|
+
# Try all parsers regardless of file extension
|
|
3601
|
+
parsers = [
|
|
3602
|
+
("Gaussian log", lambda t: parse_gaussian_log(t)),
|
|
3603
|
+
("XYZ", lambda t: parse_xyz(t, name=name)),
|
|
3604
|
+
("PDB", lambda t: parse_pdb(t, name=name)),
|
|
3605
|
+
("MDL Molfile", lambda t: parse_mol(t, name=name)),
|
|
3606
|
+
("Gaussian input", lambda t: parse_gaussian(t))
|
|
3607
|
+
]
|
|
3608
|
+
|
|
3609
|
+
for source_name, parser in parsers:
|
|
3610
|
+
try:
|
|
3611
|
+
mol = parser(text)
|
|
3612
|
+
if mol and mol.atoms:
|
|
3613
|
+
src = source_name
|
|
3614
|
+
break
|
|
3615
|
+
except Exception as e:
|
|
3616
|
+
errors.append(f"{source_name}: {e}")
|
|
3617
|
+
|
|
3618
|
+
if mol is None or not mol.atoms:
|
|
3619
|
+
err_msg = "\n".join(errors)
|
|
3620
|
+
raise ValueError(f"Could not parse file format.\nAttempts:\n{err_msg}")
|
|
3621
|
+
|
|
3622
|
+
infer_bonds(mol)
|
|
3623
|
+
return mol, src
|
|
3624
|
+
|
|
3625
|
+
def _load_and_display(self, path: str):
|
|
3626
|
+
"""Parse a file and update all UI elements. Used by drag-and-drop and Open."""
|
|
3627
|
+
try:
|
|
3628
|
+
mol, src = self._load_mol_from_path(path)
|
|
3629
|
+
self._color_overrides = {}
|
|
3630
|
+
self._canvas.color_overrides = {}
|
|
3631
|
+
self._canvas.load_molecule(mol)
|
|
3632
|
+
self._legend.update_for(mol, {})
|
|
3633
|
+
self._current_source = src
|
|
3634
|
+
self._current_path = path
|
|
3635
|
+
self._update_info_panel(mol)
|
|
3636
|
+
self._update_calculations_menu(mol)
|
|
3637
|
+
except Exception as e:
|
|
3638
|
+
QMessageBox.critical(self, "Error loading file",
|
|
3639
|
+
f"{type(e).__name__}: {e}")
|
|
3640
|
+
|
|
3641
|
+
def _update_calculations_menu(self, mol: Molecule):
|
|
3642
|
+
# Keep G16 Input, separator, Rotational Constants, Dipole Moment
|
|
3643
|
+
while len(self._menu_calc.actions()) > 4:
|
|
3644
|
+
self._menu_calc.removeAction(self._menu_calc.actions()[-1])
|
|
3645
|
+
|
|
3646
|
+
if mol and (mol.vibrational_modes or mol.excited_states):
|
|
3647
|
+
self._menu_calc.addSeparator()
|
|
3648
|
+
act_results = QAction("Calculation Results…", self)
|
|
3649
|
+
overrides, alt_overrides = self._load_shortcut_overrides()
|
|
3650
|
+
ks = overrides.get("calc_results", _mod("Ctrl+M"))
|
|
3651
|
+
act_results.setShortcut(ks)
|
|
3652
|
+
alt_ks = alt_overrides.get("calc_results", "")
|
|
3653
|
+
if alt_ks and ks:
|
|
3654
|
+
act_results.setShortcuts([QKeySequence(ks), QKeySequence(alt_ks)])
|
|
3655
|
+
act_results.triggered.connect(self._show_calculations_dialog)
|
|
3656
|
+
self._menu_calc.addAction(act_results)
|
|
3657
|
+
self._shortcut_actions["calc_results"] = act_results
|
|
3658
|
+
|
|
3659
|
+
def _generate_g16_input(self):
|
|
3660
|
+
mol = self._canvas.molecule
|
|
3661
|
+
if not mol or not mol.atoms:
|
|
3662
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
3663
|
+
return
|
|
3664
|
+
dlg = G16InputDialog(mol, parent=self)
|
|
3665
|
+
dlg.exec()
|
|
3666
|
+
|
|
3667
|
+
def _show_calculations_dialog(self):
|
|
3668
|
+
mol = self._canvas.molecule
|
|
3669
|
+
if not mol: return
|
|
3670
|
+
dlg = CalculationsDialog(mol, parent=self)
|
|
3671
|
+
dlg.modeSelected.connect(self._show_vibration)
|
|
3672
|
+
dlg.stateSelected.connect(self._show_excited_state)
|
|
3673
|
+
dlg.viewSpectrum.connect(self._on_view_spectrum)
|
|
3674
|
+
dlg.animationToggled.connect(self._on_anim_toggle)
|
|
3675
|
+
dlg.vectorsToggled.connect(self._on_vectors_toggle)
|
|
3676
|
+
dlg.finished.connect(self._on_calculations_closed)
|
|
3677
|
+
dlg.show()
|
|
3678
|
+
|
|
3679
|
+
def _calculate_rotational_constants(self):
|
|
3680
|
+
mol = self._canvas.molecule
|
|
3681
|
+
if not mol:
|
|
3682
|
+
QMessageBox.information(self, 'No molecule', "Load or build a molecule first.")
|
|
3683
|
+
return
|
|
3684
|
+
|
|
3685
|
+
rot_consts = calculate_rotational_constants(mol)
|
|
3686
|
+
|
|
3687
|
+
QMessageBox.information(self, 'Rotational constants', f"Rotational constants:\nA: {rot_consts[0]:10.4f} MHz\nB: {rot_consts[1]:10.4f} MHz\nC: {rot_consts[2]:10.4f} MHz")
|
|
3688
|
+
return(rot_consts)
|
|
3689
|
+
|
|
3690
|
+
def _calculate_dipole_moment(self):
|
|
3691
|
+
mol = self._canvas.molecule
|
|
3692
|
+
if not mol:
|
|
3693
|
+
QMessageBox.information(self, 'No molecule', "Load or build a molecule first.")
|
|
3694
|
+
return
|
|
3695
|
+
|
|
3696
|
+
if not HAS_OPENBABEL:
|
|
3697
|
+
QMessageBox.warning(
|
|
3698
|
+
self, "OpenBabel Not Found",
|
|
3699
|
+
"Dipole moment estimation requires OpenBabel.\n\n"
|
|
3700
|
+
"Install it with: pip install openbabel-wheel\n\n"
|
|
3701
|
+
"If already installed, set the BABEL_DATADIR environment "
|
|
3702
|
+
"variable to the folder containing UFF.prm."
|
|
3703
|
+
)
|
|
3704
|
+
return
|
|
3705
|
+
|
|
3706
|
+
mu_vec, mu_mag, atom_charges = calculate_dipole_moment(mol)
|
|
3707
|
+
if mu_mag is None:
|
|
3708
|
+
QMessageBox.information(self, 'Dipole moment',
|
|
3709
|
+
"Could not compute dipole moment.\n"
|
|
3710
|
+
"Ensure the molecule has at least 2 atoms and valid coordinates.")
|
|
3711
|
+
return
|
|
3712
|
+
|
|
3713
|
+
formula = chemical_formula(mol)
|
|
3714
|
+
|
|
3715
|
+
lines = [
|
|
3716
|
+
f"Molecule: {formula}",
|
|
3717
|
+
f"Charge: {mol.charge}",
|
|
3718
|
+
"",
|
|
3719
|
+
f"|μ| = {mu_mag:.4f} Debye",
|
|
3720
|
+
"",
|
|
3721
|
+
f"μx = {mu_vec[0]:+.4f} D",
|
|
3722
|
+
f"μy = {mu_vec[1]:+.4f} D",
|
|
3723
|
+
f"μz = {mu_vec[2]:+.4f} D",
|
|
3724
|
+
"",
|
|
3725
|
+
"Partial charges (EEM):",
|
|
3726
|
+
]
|
|
3727
|
+
for elem, q in atom_charges:
|
|
3728
|
+
lines.append(f" {elem:2s} {q:+.4f} e")
|
|
3729
|
+
|
|
3730
|
+
QMessageBox.information(self, 'Dipole moment', "\n".join(lines))
|
|
3731
|
+
|
|
3732
|
+
|
|
3733
|
+
def _on_anim_toggle(self, enabled: bool):
|
|
3734
|
+
if enabled:
|
|
3735
|
+
self._anim_timer.start()
|
|
3736
|
+
else:
|
|
3737
|
+
self._anim_timer.stop()
|
|
3738
|
+
self._canvas.animation_amplitude = 0.0
|
|
3739
|
+
self._canvas.request_render()
|
|
3740
|
+
|
|
3741
|
+
def _on_vectors_toggle(self, enabled: bool):
|
|
3742
|
+
self._canvas._show_vectors = enabled
|
|
3743
|
+
self._canvas.request_render()
|
|
3744
|
+
|
|
3745
|
+
def _on_anim_step(self):
|
|
3746
|
+
self._anim_phase += 0.25
|
|
3747
|
+
if self._anim_phase > 2*math.pi:
|
|
3748
|
+
self._anim_phase -= 2*math.pi
|
|
3749
|
+
self._canvas.animation_phase = self._anim_phase
|
|
3750
|
+
self._canvas.animation_amplitude = 0.6
|
|
3751
|
+
self._canvas.request_render()
|
|
3752
|
+
|
|
3753
|
+
def _on_calculations_closed(self):
|
|
3754
|
+
self._anim_timer.stop()
|
|
3755
|
+
self._anim_phase = 0.0
|
|
3756
|
+
self._canvas.animation_phase = 0.0
|
|
3757
|
+
self._canvas.animation_amplitude = 0.0
|
|
3758
|
+
self._canvas.active_vectors = None
|
|
3759
|
+
self._canvas.arrows = None
|
|
3760
|
+
self._canvas.request_render()
|
|
3761
|
+
self._status.clearMessage()
|
|
3762
|
+
|
|
3763
|
+
def _on_view_spectrum(self, kind):
|
|
3764
|
+
if kind == "ir": self._view_ir_spectrum()
|
|
3765
|
+
else: self._view_uvvis_spectrum()
|
|
3766
|
+
|
|
3767
|
+
def _view_ir_spectrum(self):
|
|
3768
|
+
mol = self._canvas.molecule
|
|
3769
|
+
if not mol: return
|
|
3770
|
+
x = [m.frequency for m in mol.vibrational_modes]
|
|
3771
|
+
y = [m.intensity for m in mol.vibrational_modes]
|
|
3772
|
+
meta = f"Molecule: {mol.name}\nCalculation: Vibrational Frequencies (IR)"
|
|
3773
|
+
safe_name = get_safe_filename(mol.name)
|
|
3774
|
+
default_file = f"{safe_name}_ir_spectrum.txt"
|
|
3775
|
+
dlg = SpectrumDialog(x, y, "Frequency / cm-1", "Intensity (arb. units)", "IR Spectrum", meta, self)
|
|
3776
|
+
dlg.set_default_filename(default_file)
|
|
3777
|
+
dlg.exec()
|
|
3778
|
+
|
|
3779
|
+
def _view_uvvis_spectrum(self):
|
|
3780
|
+
mol = self._canvas.molecule
|
|
3781
|
+
if not mol: return
|
|
3782
|
+
x = [s.wavelength_nm for s in mol.excited_states]
|
|
3783
|
+
y = [s.oscillator_strength for s in mol.excited_states]
|
|
3784
|
+
meta = f"Molecule: {mol.name}\nCalculation: Excited States (TDDFT)"
|
|
3785
|
+
safe_name = get_safe_filename(mol.name)
|
|
3786
|
+
default_file = f"{safe_name}_uvvis_spectrum.txt"
|
|
3787
|
+
dlg = SpectrumDialog(x, y, "Wavelength / nm", "Oscillator Strength / f", "UV-Vis Spectrum", meta, self)
|
|
3788
|
+
dlg.set_default_filename(default_file)
|
|
3789
|
+
dlg.exec()
|
|
3790
|
+
|
|
3791
|
+
def _reset_state(self):
|
|
3792
|
+
self._canvas.arrows = None
|
|
3793
|
+
self._canvas.active_vectors = None
|
|
3794
|
+
self._canvas.request_render()
|
|
3795
|
+
if self._canvas.molecule:
|
|
3796
|
+
self._update_info_panel(self._canvas.molecule)
|
|
3797
|
+
|
|
3798
|
+
def _show_vibration(self, mode: VibrationalMode):
|
|
3799
|
+
# Scale displacements for visibility
|
|
3800
|
+
self._canvas.active_vectors = mode.displacements
|
|
3801
|
+
scale = 1.2
|
|
3802
|
+
arrows = []
|
|
3803
|
+
for i, d in enumerate(mode.displacements):
|
|
3804
|
+
arrows.append((i, d[0]*scale, d[1]*scale, d[2]*scale, "#00ff00"))
|
|
3805
|
+
|
|
3806
|
+
self._canvas.arrows = arrows
|
|
3807
|
+
self._canvas.request_render()
|
|
3808
|
+
self._status.showMessage(f"Visualizing mode {mode.index}: {mode.frequency:.1f} cm⁻¹")
|
|
3809
|
+
|
|
3810
|
+
def _show_excited_state(self, state: ExcitedState):
|
|
3811
|
+
self._canvas.arrows = None
|
|
3812
|
+
self._canvas.active_vectors = None
|
|
3813
|
+
self._canvas.request_render()
|
|
3814
|
+
|
|
3815
|
+
msg = f"Excited State {state.index}: {state.symmetry} | {state.energy_ev:.3f} eV | f={state.oscillator_strength:.4f}"
|
|
3816
|
+
self._status.showMessage(msg)
|
|
3817
|
+
|
|
3818
|
+
formula = chemical_formula(self._canvas.molecule)
|
|
3819
|
+
# Keep name consistent, don't add (S1) here if user wants consistency
|
|
3820
|
+
# Or just use plain text
|
|
3821
|
+
self._lbl_name.setText(formula)
|
|
3822
|
+
|
|
3823
|
+
def _on_legend_color_changed(self, elem: str, hex_color: str):
|
|
3824
|
+
self._color_overrides[elem] = hex_color
|
|
3825
|
+
self._canvas.color_overrides[elem] = hex_color
|
|
3826
|
+
self._canvas.request_render()
|
|
3827
|
+
if self._canvas.molecule:
|
|
3828
|
+
self._legend.update_for(self._canvas.molecule, self._color_overrides)
|
|
3829
|
+
|
|
3830
|
+
def _on_atom_color_changed(self, atom_idx: int, hex_color: str):
|
|
3831
|
+
mol = self._canvas.molecule
|
|
3832
|
+
if mol is None or atom_idx < 0 or atom_idx >= len(mol.atoms):
|
|
3833
|
+
return
|
|
3834
|
+
mol.atoms[atom_idx].color = hex_color
|
|
3835
|
+
self._canvas.request_render()
|
|
3836
|
+
self._legend.update_for(mol, self._color_overrides)
|
|
3837
|
+
self._status.showMessage(f"Atom {atom_idx + 1} colour set to {hex_color}.")
|
|
3838
|
+
|
|
3839
|
+
def _toggle_theme(self):
|
|
3840
|
+
new_theme = "light" if self._current_theme == "dark" else "dark"
|
|
3841
|
+
self._apply_theme(new_theme)
|
|
3842
|
+
self._canvas.background = THEMES[new_theme]["CANVAS"]
|
|
3843
|
+
self._canvas.request_render()
|
|
3844
|
+
|
|
3845
|
+
def _apply_theme(self, theme_name: str):
|
|
3846
|
+
self._current_theme = theme_name
|
|
3847
|
+
self.setStyleSheet(get_stylesheet(theme_name))
|
|
3848
|
+
|
|
3849
|
+
if hasattr(self, "_act_theme"):
|
|
3850
|
+
self._act_theme.setText("Switch to Dark Mode" if theme_name == "light" else "Switch to Light Mode")
|
|
3851
|
+
|
|
3852
|
+
# Update toolbar icon colors
|
|
3853
|
+
self._update_tool_icons()
|
|
3854
|
+
|
|
3855
|
+
self.setProperty("theme", theme_name)
|
|
3856
|
+
self.style().unpolish(self)
|
|
3857
|
+
self.style().polish(self)
|
|
3858
|
+
|
|
3859
|
+
def _update_tool_icons(self):
|
|
3860
|
+
if not hasattr(self, '_act_select_btn') or not hasattr(self, '_act_build_btn'):
|
|
3861
|
+
return
|
|
3862
|
+
assets_dir = os.path.join(os.path.dirname(__file__), "assets", "icons")
|
|
3863
|
+
color = '#ccd6f6' if self._current_theme == 'dark' else '#000000'
|
|
3864
|
+
self._act_select_btn.setIcon(load_colored_icon(os.path.join(assets_dir, "icon_select.svg"), color))
|
|
3865
|
+
self._act_build_btn.setIcon(load_colored_icon(os.path.join(assets_dir, "icon_draw.svg"), color))
|
|
3866
|
+
if hasattr(self, '_act_align_btn'):
|
|
3867
|
+
self._act_align_btn.setIcon(load_colored_icon(os.path.join(assets_dir, "icon_align.svg"), color))
|
|
3868
|
+
|
|
3869
|
+
def _update_info_panel(self, mol):
|
|
3870
|
+
"""Populate sidebar labels and status bar for a loaded molecule."""
|
|
3871
|
+
formula = chemical_formula(mol)
|
|
3872
|
+
mass = molecular_mass(mol)
|
|
3873
|
+
charge = mol.charge
|
|
3874
|
+
src = self._current_source
|
|
3875
|
+
path = self._current_path
|
|
3876
|
+
|
|
3877
|
+
# Build name: plain text C60+ etc.
|
|
3878
|
+
if charge == 0:
|
|
3879
|
+
display_name = formula
|
|
3880
|
+
elif charge > 0:
|
|
3881
|
+
display_name = f"{formula}{charge}+" if charge > 1 else f"{formula}+"
|
|
3882
|
+
else:
|
|
3883
|
+
display_name = f"{formula}{abs(charge)}-" if charge < -1 else f"{formula}-"
|
|
3884
|
+
|
|
3885
|
+
charge_str = "neutral" if charge == 0 else (f"+{charge}" if charge > 0 else str(charge))
|
|
3886
|
+
|
|
3887
|
+
self._lbl_name.setText(display_name)
|
|
3888
|
+
self._lbl_charge.setText(f"Charge: {charge_str}")
|
|
3889
|
+
self._lbl_atoms.setText(f"Atoms: {len(mol.atoms)}")
|
|
3890
|
+
self._lbl_bonds.setText(f"Bonds: {len(mol.bonds)}")
|
|
3891
|
+
self._lbl_mass.setText(f"Mass: {mass:.3f} uma")
|
|
3892
|
+
self._lbl_src.setText(f"Source: {src}")
|
|
3893
|
+
|
|
3894
|
+
# Update Calculations Group
|
|
3895
|
+
has_vib = bool(mol.vibrational_modes)
|
|
3896
|
+
has_td = bool(mol.excited_states)
|
|
3897
|
+
has_energy = mol.g16_scf_energy is not None or mol.g16_opt_energy is not None
|
|
3898
|
+
has_any = has_vib or has_td or has_energy
|
|
3899
|
+
if has_any:
|
|
3900
|
+
self._calc_group.show()
|
|
3901
|
+
if has_energy:
|
|
3902
|
+
eng = mol.g16_opt_energy if mol.g16_opt_energy is not None else mol.g16_scf_energy
|
|
3903
|
+
self._lbl_energy.setText(f"Energy: {eng:.4f} Ha")
|
|
3904
|
+
self._lbl_energy.setVisible(True)
|
|
3905
|
+
else:
|
|
3906
|
+
self._lbl_energy.setVisible(False)
|
|
3907
|
+
self._lbl_vib.setVisible(has_vib)
|
|
3908
|
+
self._lbl_td.setVisible(has_td)
|
|
3909
|
+
self._lbl_vib.setText(f"Vibrations: {len(mol.vibrational_modes)} modes")
|
|
3910
|
+
self._lbl_td.setText(f"TD-DFT: {len(mol.excited_states)} states")
|
|
3911
|
+
else:
|
|
3912
|
+
self._calc_group.hide()
|
|
3913
|
+
|
|
3914
|
+
# Update main window title
|
|
3915
|
+
self.setWindowTitle(f"Molvector — {display_name}")
|
|
3916
|
+
|
|
3917
|
+
status_path = f"{path} | " if path else ""
|
|
3918
|
+
self._status.showMessage(
|
|
3919
|
+
f"{status_path}{display_name} | {len(mol.atoms)} atoms, "
|
|
3920
|
+
f"{len(mol.bonds)} bonds | {mass:.3f} uma [{src}]"
|
|
3921
|
+
)
|
|
3922
|
+
self._legend.update_for(mol, self._color_overrides)
|
|
3923
|
+
|
|
3924
|
+
def _show_molecule_info(self):
|
|
3925
|
+
mol = self._canvas.molecule
|
|
3926
|
+
if mol is None:
|
|
3927
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
3928
|
+
return
|
|
3929
|
+
|
|
3930
|
+
from collections import Counter
|
|
3931
|
+
formula = chemical_formula(mol)
|
|
3932
|
+
mass = molecular_mass(mol)
|
|
3933
|
+
charge = mol.charge
|
|
3934
|
+
charge_str = "neutral" if charge == 0 else (f"+{charge}" if charge > 0 else str(charge))
|
|
3935
|
+
counts = Counter(a.element for a in mol.atoms)
|
|
3936
|
+
elem_parts = [f"{e}\u2009{counts[e]}" for e in sorted(counts.keys())]
|
|
3937
|
+
elem_str = " " + ", ".join(elem_parts) if mol.atoms else "—"
|
|
3938
|
+
src = self._current_source or "—"
|
|
3939
|
+
path = self._current_path or "—"
|
|
3940
|
+
|
|
3941
|
+
dlg = QDialog(self)
|
|
3942
|
+
dlg.setWindowTitle(f"Info \u2014 {formula}")
|
|
3943
|
+
dlg.setMinimumWidth(400)
|
|
3944
|
+
layout = QVBoxLayout(dlg)
|
|
3945
|
+
layout.setSpacing(6)
|
|
3946
|
+
|
|
3947
|
+
for label, value in [
|
|
3948
|
+
("Formula:", formula),
|
|
3949
|
+
("Charge:", charge_str),
|
|
3950
|
+
("Mass:", f"{mass:.3f} uma"),
|
|
3951
|
+
("Atoms:", str(len(mol.atoms))),
|
|
3952
|
+
("Bonds:", str(len(mol.bonds))),
|
|
3953
|
+
("Source:", src),
|
|
3954
|
+
("Path:", path),
|
|
3955
|
+
("Elements:", elem_str),
|
|
3956
|
+
]:
|
|
3957
|
+
row = QHBoxLayout()
|
|
3958
|
+
lbl = QLabel(label)
|
|
3959
|
+
lbl.setStyleSheet("font-weight: bold;")
|
|
3960
|
+
val = QLabel(value)
|
|
3961
|
+
val.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
3962
|
+
val.setWordWrap(True)
|
|
3963
|
+
row.addWidget(lbl)
|
|
3964
|
+
row.addWidget(val, 1)
|
|
3965
|
+
layout.addLayout(row)
|
|
3966
|
+
|
|
3967
|
+
layout.addSpacing(10)
|
|
3968
|
+
btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
3969
|
+
btn.rejected.connect(dlg.reject)
|
|
3970
|
+
layout.addWidget(btn)
|
|
3971
|
+
dlg.exec()
|
|
3972
|
+
|
|
3973
|
+
def _show_inchi(self):
|
|
3974
|
+
mol = self._canvas.molecule
|
|
3975
|
+
if mol is None:
|
|
3976
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
3977
|
+
return
|
|
3978
|
+
|
|
3979
|
+
inchi = generate_inchi(mol)
|
|
3980
|
+
if inchi is None:
|
|
3981
|
+
QMessageBox.warning(
|
|
3982
|
+
self, "InChI Error",
|
|
3983
|
+
"Failed to generate InChI for this molecule.\n"
|
|
3984
|
+
"Ensure RDKit is installed: pip install rdkit"
|
|
3985
|
+
)
|
|
3986
|
+
return
|
|
3987
|
+
|
|
3988
|
+
formula = chemical_formula(mol)
|
|
3989
|
+
warnings = check_valence_issues(mol)
|
|
3990
|
+
|
|
3991
|
+
dlg = QDialog(self)
|
|
3992
|
+
dlg.setWindowTitle(f"InChI \u2014 {formula}")
|
|
3993
|
+
dlg.setMinimumWidth(480)
|
|
3994
|
+
layout = QVBoxLayout(dlg)
|
|
3995
|
+
layout.setSpacing(6)
|
|
3996
|
+
|
|
3997
|
+
lbl = QLabel("InChI:")
|
|
3998
|
+
lbl.setStyleSheet("font-weight: bold;")
|
|
3999
|
+
layout.addWidget(lbl)
|
|
4000
|
+
|
|
4001
|
+
le = QLineEdit(inchi)
|
|
4002
|
+
le.setReadOnly(True)
|
|
4003
|
+
layout.addWidget(le)
|
|
4004
|
+
|
|
4005
|
+
copy_btn = QPushButton("Copy to Clipboard")
|
|
4006
|
+
copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(inchi))
|
|
4007
|
+
layout.addWidget(copy_btn)
|
|
4008
|
+
|
|
4009
|
+
nist_btn = QPushButton("Search in NIST WebBook")
|
|
4010
|
+
from urllib.parse import quote
|
|
4011
|
+
encoded = quote(inchi, safe='')
|
|
4012
|
+
nist_url = QUrl(f"https://webbook.nist.gov/cgi/cbook.cgi?InChI={encoded}&Units=SI")
|
|
4013
|
+
nist_btn.clicked.connect(lambda: QDesktopServices.openUrl(nist_url))
|
|
4014
|
+
layout.addWidget(nist_btn)
|
|
4015
|
+
|
|
4016
|
+
if warnings:
|
|
4017
|
+
layout.addSpacing(6)
|
|
4018
|
+
warn_lbl = QLabel("Valence warnings:")
|
|
4019
|
+
warn_lbl.setStyleSheet("font-weight: bold; color: #cc4444;")
|
|
4020
|
+
layout.addWidget(warn_lbl)
|
|
4021
|
+
|
|
4022
|
+
warn_text = QLabel("\n".join(warnings))
|
|
4023
|
+
warn_text.setStyleSheet("color: #cc4444;")
|
|
4024
|
+
warn_text.setWordWrap(True)
|
|
4025
|
+
layout.addWidget(warn_text)
|
|
4026
|
+
|
|
4027
|
+
layout.addSpacing(6)
|
|
4028
|
+
close_btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
4029
|
+
close_btn.rejected.connect(dlg.reject)
|
|
4030
|
+
layout.addWidget(close_btn)
|
|
4031
|
+
dlg.exec()
|
|
4032
|
+
|
|
4033
|
+
def _edit_charge(self):
|
|
4034
|
+
mol = self._canvas.molecule
|
|
4035
|
+
if mol is None:
|
|
4036
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4037
|
+
return
|
|
4038
|
+
|
|
4039
|
+
dlg = QDialog(self)
|
|
4040
|
+
dlg.setWindowTitle("Edit Charge")
|
|
4041
|
+
dlg.setMinimumWidth(240)
|
|
4042
|
+
layout = QVBoxLayout(dlg)
|
|
4043
|
+
|
|
4044
|
+
lbl = QLabel("Molecular charge:")
|
|
4045
|
+
layout.addWidget(lbl)
|
|
4046
|
+
|
|
4047
|
+
spin = QSpinBox()
|
|
4048
|
+
spin.setRange(-10, 10)
|
|
4049
|
+
spin.setValue(mol.charge)
|
|
4050
|
+
spin.setMinimumWidth(90)
|
|
4051
|
+
layout.addWidget(spin)
|
|
4052
|
+
|
|
4053
|
+
layout.addSpacing(6)
|
|
4054
|
+
btns = QDialogButtonBox(
|
|
4055
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
4056
|
+
)
|
|
4057
|
+
btns.accepted.connect(dlg.accept)
|
|
4058
|
+
btns.rejected.connect(dlg.reject)
|
|
4059
|
+
layout.addWidget(btns)
|
|
4060
|
+
|
|
4061
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
4062
|
+
mol.charge = spin.value()
|
|
4063
|
+
self._update_info_panel(mol)
|
|
4064
|
+
self._canvas.update()
|
|
4065
|
+
self._status.showMessage(f"Charge set to {mol.charge}")
|
|
4066
|
+
|
|
4067
|
+
def _open_file(self):
|
|
4068
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
4069
|
+
self, "Open Molecule File", "",
|
|
4070
|
+
"All supported (*.xyz *.gjf *.com *.log *.out *.pdb *.mol *.txt);;"
|
|
4071
|
+
"XYZ (*.xyz);;Gaussian input (*.gjf *.com);;"
|
|
4072
|
+
"Gaussian log (*.log *.out);;PDB (*.pdb);;MDL Molfile (*.mol);;Text (*.txt);;All files (*)"
|
|
4073
|
+
)
|
|
4074
|
+
if path:
|
|
4075
|
+
self._load_and_display(path)
|
|
4076
|
+
|
|
4077
|
+
def _save_svg(self):
|
|
4078
|
+
if self._canvas.molecule is None:
|
|
4079
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4080
|
+
return
|
|
4081
|
+
safe_name = get_safe_filename(self._canvas.molecule.name)
|
|
4082
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
4083
|
+
self, "Save SVG", f"{safe_name}_view.svg", "SVG files (*.svg)"
|
|
4084
|
+
)
|
|
4085
|
+
if not path:
|
|
4086
|
+
return
|
|
4087
|
+
try:
|
|
4088
|
+
data = self._canvas.get_svg_bytes(export_mode=True)
|
|
4089
|
+
with open(path, "wb") as f:
|
|
4090
|
+
f.write(data)
|
|
4091
|
+
self._status.showMessage(f"Saved: {path}")
|
|
4092
|
+
except Exception as e:
|
|
4093
|
+
QMessageBox.critical(self, "Error saving", str(e))
|
|
4094
|
+
|
|
4095
|
+
def _quick_svg_export(self):
|
|
4096
|
+
if self._canvas.molecule is None:
|
|
4097
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4098
|
+
return
|
|
4099
|
+
dlg = QuickExportDialog(self._canvas, self)
|
|
4100
|
+
dlg.show()
|
|
4101
|
+
|
|
4102
|
+
def _edit_shortcuts(self):
|
|
4103
|
+
_, saved_alt = self._load_shortcut_overrides()
|
|
4104
|
+
alt = dict(self.ALT_SHORTCUT_DEFAULTS)
|
|
4105
|
+
alt.update(saved_alt)
|
|
4106
|
+
alt = {k: v for k, v in alt.items() if k in self._shortcut_actions}
|
|
4107
|
+
dlg = ShortcutDialog(self._shortcut_actions, alt_defaults=alt, parent=self)
|
|
4108
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
4109
|
+
primary, alt = dlg.get_shortcuts()
|
|
4110
|
+
self._apply_shortcuts(primary)
|
|
4111
|
+
self._apply_alt_shortcuts(alt)
|
|
4112
|
+
self._save_shortcut_overrides(primary, alt)
|
|
4113
|
+
|
|
4114
|
+
def _apply_shortcut_config(self):
|
|
4115
|
+
primary, alt = self._load_shortcut_overrides()
|
|
4116
|
+
if primary:
|
|
4117
|
+
self._apply_shortcuts(primary)
|
|
4118
|
+
if alt:
|
|
4119
|
+
self._apply_alt_shortcuts(alt)
|
|
4120
|
+
|
|
4121
|
+
def _apply_shortcuts(self, overrides: dict):
|
|
4122
|
+
for aid, seq_str in overrides.items():
|
|
4123
|
+
if aid in self._shortcut_actions and seq_str:
|
|
4124
|
+
self._shortcut_actions[aid].setShortcut(seq_str)
|
|
4125
|
+
|
|
4126
|
+
def _apply_alt_shortcuts(self, alt: dict):
|
|
4127
|
+
for aid, seq_str in alt.items():
|
|
4128
|
+
if aid in self._shortcut_actions and seq_str:
|
|
4129
|
+
action = self._shortcut_actions[aid]
|
|
4130
|
+
primary = action.shortcut().toString()
|
|
4131
|
+
if primary:
|
|
4132
|
+
action.setShortcuts([QKeySequence(primary), QKeySequence(seq_str)])
|
|
4133
|
+
else:
|
|
4134
|
+
action.setShortcut(seq_str)
|
|
4135
|
+
|
|
4136
|
+
def _load_shortcut_overrides(self) -> tuple:
|
|
4137
|
+
cfg_path = os.path.join(os.path.dirname(__file__), "molvector_config.json")
|
|
4138
|
+
try:
|
|
4139
|
+
if os.path.exists(cfg_path):
|
|
4140
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
4141
|
+
cfg = json.load(f)
|
|
4142
|
+
return cfg.get("shortcuts", {}), cfg.get("alt_shortcuts", {})
|
|
4143
|
+
except Exception:
|
|
4144
|
+
pass
|
|
4145
|
+
return {}, {}
|
|
4146
|
+
|
|
4147
|
+
def _save_shortcut_overrides(self, primary: dict, alt: dict):
|
|
4148
|
+
cfg_path = os.path.join(os.path.dirname(__file__), "molvector_config.json")
|
|
4149
|
+
config = {}
|
|
4150
|
+
try:
|
|
4151
|
+
if os.path.exists(cfg_path):
|
|
4152
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
4153
|
+
config = json.load(f)
|
|
4154
|
+
except Exception:
|
|
4155
|
+
pass
|
|
4156
|
+
config["shortcuts"] = primary
|
|
4157
|
+
config["alt_shortcuts"] = alt
|
|
4158
|
+
try:
|
|
4159
|
+
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
4160
|
+
json.dump(config, f, indent=2)
|
|
4161
|
+
except Exception:
|
|
4162
|
+
pass
|
|
4163
|
+
|
|
4164
|
+
def _export_view(self):
|
|
4165
|
+
if self._canvas.molecule is None:
|
|
4166
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4167
|
+
return
|
|
4168
|
+
|
|
4169
|
+
safe_name = get_safe_filename(self._canvas.molecule.name)
|
|
4170
|
+
filters = "PDF files (*.pdf);;PNG files (*.png);;JPEG files (*.jpg *.jpeg);;SVG files (*.svg)"
|
|
4171
|
+
path, sel_filter = QFileDialog.getSaveFileName(
|
|
4172
|
+
self, "Export View", f"{safe_name}_view.pdf", filters
|
|
4173
|
+
)
|
|
4174
|
+
if not path:
|
|
4175
|
+
return
|
|
4176
|
+
|
|
4177
|
+
try:
|
|
4178
|
+
# 1. Get SVG data from canvas
|
|
4179
|
+
svg_data = self._canvas.get_svg_bytes(export_mode=True)
|
|
4180
|
+
renderer = QSvgRenderer(QByteArray(svg_data))
|
|
4181
|
+
|
|
4182
|
+
# 2. Determine size (use high res for raster)
|
|
4183
|
+
# We'll use a 2x or 3x scale for images to make them crisp
|
|
4184
|
+
view_size = renderer.defaultSize()
|
|
4185
|
+
if view_size.isEmpty():
|
|
4186
|
+
view_size = QSize(1200, 900)
|
|
4187
|
+
|
|
4188
|
+
ext = os.path.splitext(path)[1].lower()
|
|
4189
|
+
|
|
4190
|
+
if ext == ".pdf":
|
|
4191
|
+
pdf = QPdfWriter(path)
|
|
4192
|
+
pdf.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
|
4193
|
+
# Center the molecule on the page
|
|
4194
|
+
painter = QPainter(pdf)
|
|
4195
|
+
try:
|
|
4196
|
+
# Scale molecule to fit page comfortably
|
|
4197
|
+
target_rect = painter.viewport()
|
|
4198
|
+
# Maintain aspect ratio
|
|
4199
|
+
svg_ratio = view_size.width() / view_size.height()
|
|
4200
|
+
page_ratio = target_rect.width() / target_rect.height()
|
|
4201
|
+
|
|
4202
|
+
if svg_ratio > page_ratio:
|
|
4203
|
+
w = target_rect.width()
|
|
4204
|
+
h = int(w / svg_ratio)
|
|
4205
|
+
else:
|
|
4206
|
+
h = target_rect.height()
|
|
4207
|
+
w = int(h * svg_ratio)
|
|
4208
|
+
|
|
4209
|
+
# Center it
|
|
4210
|
+
x = (target_rect.width() - w) // 2
|
|
4211
|
+
y = (target_rect.height() - h) // 2
|
|
4212
|
+
renderer.render(painter, QRectF(float(x), float(y), float(w), float(h)))
|
|
4213
|
+
finally:
|
|
4214
|
+
painter.end()
|
|
4215
|
+
|
|
4216
|
+
elif ext in (".png", ".jpg", ".jpeg"):
|
|
4217
|
+
# Scale up for high quality
|
|
4218
|
+
scale_factor = 2.0
|
|
4219
|
+
img_size = view_size * scale_factor
|
|
4220
|
+
img = QImage(img_size, QImage.Format.Format_ARGB32)
|
|
4221
|
+
img.fill(Qt.GlobalColor.transparent if ext == ".png" else Qt.GlobalColor.white)
|
|
4222
|
+
|
|
4223
|
+
painter = QPainter(img)
|
|
4224
|
+
try:
|
|
4225
|
+
renderer.render(painter)
|
|
4226
|
+
finally:
|
|
4227
|
+
painter.end()
|
|
4228
|
+
|
|
4229
|
+
img.save(path)
|
|
4230
|
+
|
|
4231
|
+
elif ext == ".svg":
|
|
4232
|
+
with open(path, "wb") as f:
|
|
4233
|
+
f.write(svg_data)
|
|
4234
|
+
|
|
4235
|
+
self._status.showMessage(f"Exported: {path}")
|
|
4236
|
+
except Exception as e:
|
|
4237
|
+
QMessageBox.critical(self, "Export Error", str(e))
|
|
4238
|
+
|
|
4239
|
+
def _save_as(self):
|
|
4240
|
+
mol = self._canvas.molecule
|
|
4241
|
+
if not mol:
|
|
4242
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4243
|
+
return
|
|
4244
|
+
|
|
4245
|
+
safe_name = get_safe_filename(mol.name)
|
|
4246
|
+
filters = "XYZ (*.xyz);;PDB (*.pdb);;MDL Molfile (*.mol);;Gaussian input (*.gjf *.com);;All files (*)"
|
|
4247
|
+
path, sel_filter = QFileDialog.getSaveFileName(
|
|
4248
|
+
self, "Save Molecule As", f"{safe_name}.xyz", filters
|
|
4249
|
+
)
|
|
4250
|
+
if not path:
|
|
4251
|
+
return
|
|
4252
|
+
|
|
4253
|
+
try:
|
|
4254
|
+
ext = os.path.splitext(path)[1].lower()
|
|
4255
|
+
if ext == ".xyz":
|
|
4256
|
+
text = save_xyz(mol)
|
|
4257
|
+
elif ext == ".pdb":
|
|
4258
|
+
text = save_pdb(mol)
|
|
4259
|
+
elif ext == ".mol":
|
|
4260
|
+
text = save_mol(mol)
|
|
4261
|
+
elif ext in (".gjf", ".com"):
|
|
4262
|
+
text = save_gaussian_input(mol)
|
|
4263
|
+
else:
|
|
4264
|
+
# Default to XYZ if extension is unknown
|
|
4265
|
+
text = save_xyz(mol)
|
|
4266
|
+
|
|
4267
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
4268
|
+
f.write(text)
|
|
4269
|
+
self._status.showMessage(f"Saved: {path}")
|
|
4270
|
+
except Exception as e:
|
|
4271
|
+
QMessageBox.critical(self, "Save Error", str(e))
|
|
4272
|
+
|
|
4273
|
+
# ── Tool mode actions ────────────────────────────────────────────────────
|
|
4274
|
+
|
|
4275
|
+
def _update_status_for_mode(self):
|
|
4276
|
+
if self._canvas.build_mode:
|
|
4277
|
+
self._status.showMessage("Build: Left-click add atom · Drag to bond · Click bond change order · Scroll zoom")
|
|
4278
|
+
self._hint.setText("Left-click add atom\nDrag bond atoms\nClick bond change order")
|
|
4279
|
+
elif self._canvas.selection_mode:
|
|
4280
|
+
self._status.showMessage("Selection: Click select atom · Drag rectangle-select · Scroll zoom")
|
|
4281
|
+
self._hint.setText("Click select atom\nDrag rectangle-select")
|
|
4282
|
+
elif self._canvas.align_mode:
|
|
4283
|
+
self._status.showMessage("Align: Click bond align vertically · Shift+click align horizontally · Scroll zoom")
|
|
4284
|
+
self._hint.setText("Click bond align vertically\nShift+click align horizontally")
|
|
4285
|
+
else:
|
|
4286
|
+
self._status.showMessage("Left-drag rotate molecule · Right-drag pan · Scroll zoom")
|
|
4287
|
+
self._hint.setText("Drag rotate\nRight-drag pan\nScroll zoom")
|
|
4288
|
+
|
|
4289
|
+
def _toggle_selection_mode(self, enabled: bool):
|
|
4290
|
+
self._act_select_btn.setChecked(enabled)
|
|
4291
|
+
self._act_select_toggle.setChecked(enabled)
|
|
4292
|
+
self._canvas.selection_mode = enabled
|
|
4293
|
+
if enabled:
|
|
4294
|
+
self._act_build_toggle.setChecked(False)
|
|
4295
|
+
self._act_build_btn.setChecked(False)
|
|
4296
|
+
self._canvas.build_mode = False
|
|
4297
|
+
self._act_align_toggle.setChecked(False)
|
|
4298
|
+
self._act_align_btn.setChecked(False)
|
|
4299
|
+
self._canvas.align_mode = False
|
|
4300
|
+
self._set_build_options_visible(False)
|
|
4301
|
+
else:
|
|
4302
|
+
self._canvas.selected_atoms.clear()
|
|
4303
|
+
self._canvas.request_render()
|
|
4304
|
+
self._update_status_for_mode()
|
|
4305
|
+
self._canvas._update_cursor(self._canvas._mouse_pos or QPoint(0, 0), Qt.KeyboardModifier.NoModifier)
|
|
4306
|
+
|
|
4307
|
+
def _toggle_build_mode(self, enabled: bool):
|
|
4308
|
+
if enabled:
|
|
4309
|
+
self._act_select_btn.setChecked(False)
|
|
4310
|
+
self._act_select_toggle.setChecked(False)
|
|
4311
|
+
self._canvas.selection_mode = False
|
|
4312
|
+
self._act_align_toggle.setChecked(False)
|
|
4313
|
+
self._act_align_btn.setChecked(False)
|
|
4314
|
+
self._canvas.align_mode = False
|
|
4315
|
+
self._act_build_toggle.setChecked(enabled)
|
|
4316
|
+
self._act_build_btn.setChecked(enabled)
|
|
4317
|
+
self._canvas.build_mode = enabled
|
|
4318
|
+
self._set_build_options_visible(enabled)
|
|
4319
|
+
self._update_status_for_mode()
|
|
4320
|
+
self._canvas._update_cursor(self._canvas._mouse_pos or QPoint(0, 0), Qt.KeyboardModifier.NoModifier)
|
|
4321
|
+
|
|
4322
|
+
def _toggle_align_mode(self, enabled: bool):
|
|
4323
|
+
if enabled:
|
|
4324
|
+
self._act_select_btn.setChecked(False)
|
|
4325
|
+
self._act_select_toggle.setChecked(False)
|
|
4326
|
+
self._canvas.selection_mode = False
|
|
4327
|
+
self._act_build_btn.setChecked(False)
|
|
4328
|
+
self._act_build_toggle.setChecked(False)
|
|
4329
|
+
self._canvas.build_mode = False
|
|
4330
|
+
self._set_build_options_visible(False)
|
|
4331
|
+
self._act_align_toggle.setChecked(enabled)
|
|
4332
|
+
self._act_align_btn.setChecked(enabled)
|
|
4333
|
+
self._canvas.align_mode = enabled
|
|
4334
|
+
self._update_status_for_mode()
|
|
4335
|
+
self._canvas._update_cursor(self._canvas._mouse_pos or QPoint(0, 0), Qt.KeyboardModifier.NoModifier)
|
|
4336
|
+
|
|
4337
|
+
def _on_build_elem_change(self, elem: str):
|
|
4338
|
+
if elem == "...":
|
|
4339
|
+
prev = self._canvas.build_element
|
|
4340
|
+
dlg = PeriodicTableDialog(self, prev)
|
|
4341
|
+
dlg.elementSelected.connect(self._on_pick_from_periodic_table)
|
|
4342
|
+
if not dlg.exec():
|
|
4343
|
+
self._elem_combo.setCurrentText(prev)
|
|
4344
|
+
return
|
|
4345
|
+
self._canvas.build_element = elem
|
|
4346
|
+
|
|
4347
|
+
def _on_pick_from_periodic_table(self, sym: str):
|
|
4348
|
+
idx = self._elem_combo.findText(sym)
|
|
4349
|
+
if idx < 0:
|
|
4350
|
+
others_idx = self._elem_combo.findText("...")
|
|
4351
|
+
self._elem_combo.insertItem(others_idx, sym)
|
|
4352
|
+
self._elem_combo.setCurrentText(sym)
|
|
4353
|
+
|
|
4354
|
+
def _on_auto_h_toggle(self, checked: bool):
|
|
4355
|
+
self._canvas.auto_adjust_h = checked
|
|
4356
|
+
|
|
4357
|
+
def _toggle_bond_order(self, checked: bool):
|
|
4358
|
+
self._canvas.bond_order_disabled = checked
|
|
4359
|
+
if checked:
|
|
4360
|
+
if self._canvas.molecule:
|
|
4361
|
+
self._canvas._saved_bond_orders = [b.order for b in self._canvas.molecule.bonds]
|
|
4362
|
+
for b in self._canvas.molecule.bonds:
|
|
4363
|
+
b.order = 1
|
|
4364
|
+
self._act_bond_order.setText("Enable Bond Order")
|
|
4365
|
+
self._status.showMessage("Bond order disabled — all bonds set to single")
|
|
4366
|
+
else:
|
|
4367
|
+
if self._canvas.molecule and self._canvas._saved_bond_orders is not None:
|
|
4368
|
+
for i, b in enumerate(self._canvas.molecule.bonds):
|
|
4369
|
+
if i < len(self._canvas._saved_bond_orders):
|
|
4370
|
+
b.order = self._canvas._saved_bond_orders[i]
|
|
4371
|
+
self._canvas._saved_bond_orders = None
|
|
4372
|
+
self._act_bond_order.setText("Disable Bond Order")
|
|
4373
|
+
self._status.showMessage("Bond order enabled — click bonds to cycle order")
|
|
4374
|
+
self._canvas.request_render()
|
|
4375
|
+
|
|
4376
|
+
def _clear_molecule(self):
|
|
4377
|
+
if QMessageBox.question(self, "Clear", "Clear the entire molecule?") == QMessageBox.StandardButton.Yes:
|
|
4378
|
+
self._save_history()
|
|
4379
|
+
self._canvas.molecule = Molecule("New Molecule", atoms=[])
|
|
4380
|
+
self._canvas.request_render()
|
|
4381
|
+
self._update_info_panel(self._canvas.molecule)
|
|
4382
|
+
|
|
4383
|
+
def _clean_molecule(self):
|
|
4384
|
+
if not self._canvas.molecule or not self._canvas.molecule.atoms:
|
|
4385
|
+
return
|
|
4386
|
+
|
|
4387
|
+
if not HAS_OPENBABEL:
|
|
4388
|
+
QMessageBox.warning(
|
|
4389
|
+
self, "OpenBabel Not Found",
|
|
4390
|
+
"Geometry optimization requires OpenBabel.\n\n"
|
|
4391
|
+
"Install it with: pip install openbabel-wheel\n\n"
|
|
4392
|
+
"If already installed, set the BABEL_DATADIR environment "
|
|
4393
|
+
"variable to the folder containing UFF.prm."
|
|
4394
|
+
)
|
|
4395
|
+
return
|
|
4396
|
+
|
|
4397
|
+
self._save_history()
|
|
4398
|
+
steps_taken = optimize_geometry(
|
|
4399
|
+
self._canvas.molecule,
|
|
4400
|
+
max_steps=self._ff_max_steps,
|
|
4401
|
+
tol=self._ff_tol,
|
|
4402
|
+
)
|
|
4403
|
+
self._canvas.request_render()
|
|
4404
|
+
self._update_info_panel(self._canvas.molecule)
|
|
4405
|
+
self._status.showMessage(f"Geometry optimized ({steps_taken} iterations).", 3000)
|
|
4406
|
+
|
|
4407
|
+
def _on_structure_changed(self):
|
|
4408
|
+
# UI updates only; history should be saved BEFORE the change occurs
|
|
4409
|
+
# to capture the 'before' state correctly.
|
|
4410
|
+
self._update_info_panel(self._canvas.molecule)
|
|
4411
|
+
|
|
4412
|
+
def _save_history(self):
|
|
4413
|
+
import copy
|
|
4414
|
+
if self._canvas.molecule:
|
|
4415
|
+
# We save a snapshot of the CURRENT state before it gets modified.
|
|
4416
|
+
snap = copy.deepcopy(self._canvas.molecule)
|
|
4417
|
+
self._history.append(snap)
|
|
4418
|
+
if len(self._history) > self._max_history:
|
|
4419
|
+
self._history.pop(0)
|
|
4420
|
+
# Clear redo stack on NEW action
|
|
4421
|
+
self._redo_stack.clear()
|
|
4422
|
+
|
|
4423
|
+
def _undo(self):
|
|
4424
|
+
if not self._history:
|
|
4425
|
+
self._status.showMessage("Nothing to undo.")
|
|
4426
|
+
return
|
|
4427
|
+
|
|
4428
|
+
import copy
|
|
4429
|
+
# Save CURRENT state to redo stack before going back
|
|
4430
|
+
if self._canvas.molecule:
|
|
4431
|
+
self._redo_stack.append(copy.deepcopy(self._canvas.molecule))
|
|
4432
|
+
|
|
4433
|
+
# Restore the most recent snapshot
|
|
4434
|
+
prev = self._history.pop()
|
|
4435
|
+
self._canvas.molecule = prev
|
|
4436
|
+
self._canvas.request_render()
|
|
4437
|
+
self._update_info_panel(self._canvas.molecule)
|
|
4438
|
+
self._status.showMessage("Undo successful.")
|
|
4439
|
+
|
|
4440
|
+
def _redo(self):
|
|
4441
|
+
if not self._redo_stack:
|
|
4442
|
+
self._status.showMessage("Nothing to redo.")
|
|
4443
|
+
return
|
|
4444
|
+
|
|
4445
|
+
import copy
|
|
4446
|
+
# Save CURRENT state to history before going forward
|
|
4447
|
+
if self._canvas.molecule:
|
|
4448
|
+
self._history.append(copy.deepcopy(self._canvas.molecule))
|
|
4449
|
+
if len(self._history) > self._max_history:
|
|
4450
|
+
self._history.pop(0)
|
|
4451
|
+
|
|
4452
|
+
# Restore from redo stack
|
|
4453
|
+
next_state = self._redo_stack.pop()
|
|
4454
|
+
self._canvas.molecule = next_state
|
|
4455
|
+
self._canvas.request_render()
|
|
4456
|
+
self._update_info_panel(self._canvas.molecule)
|
|
4457
|
+
self._status.showMessage("Redo successful.")
|
|
4458
|
+
|
|
4459
|
+
def _edit_ff_settings(self):
|
|
4460
|
+
dlg = QDialog(self)
|
|
4461
|
+
dlg.setWindowTitle("Optimization Parameters")
|
|
4462
|
+
l = QVBoxLayout(dlg)
|
|
4463
|
+
|
|
4464
|
+
from PyQt6.QtWidgets import QFormLayout, QSpinBox, QDialogButtonBox
|
|
4465
|
+
form = QFormLayout()
|
|
4466
|
+
|
|
4467
|
+
s_steps = QSpinBox()
|
|
4468
|
+
s_steps.setRange(10, 100000)
|
|
4469
|
+
s_steps.setValue(self._ff_max_steps)
|
|
4470
|
+
form.addRow("Max Steps:", s_steps)
|
|
4471
|
+
|
|
4472
|
+
l.addLayout(form)
|
|
4473
|
+
|
|
4474
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
4475
|
+
btns.accepted.connect(dlg.accept)
|
|
4476
|
+
btns.rejected.connect(dlg.reject)
|
|
4477
|
+
l.addWidget(btns)
|
|
4478
|
+
|
|
4479
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
4480
|
+
self._ff_max_steps = s_steps.value()
|
|
4481
|
+
self._clean_molecule()
|
|
4482
|
+
|
|
4483
|
+
# ── View actions ─────────────────────────────────────────────────────────
|
|
4484
|
+
|
|
4485
|
+
def _set_bond_style(self, style: str):
|
|
4486
|
+
self._canvas.bond_style = style
|
|
4487
|
+
self._canvas.request_render()
|
|
4488
|
+
|
|
4489
|
+
def _set_ball_size(self, scale: float):
|
|
4490
|
+
self._canvas.atom_scale = scale
|
|
4491
|
+
self._canvas.request_render()
|
|
4492
|
+
|
|
4493
|
+
def _set_bond_width(self, w: float):
|
|
4494
|
+
self._canvas.bond_width_px = w
|
|
4495
|
+
self._canvas.request_render()
|
|
4496
|
+
|
|
4497
|
+
# ── Edit actions ──────────────────────────────────────────────────────────
|
|
4498
|
+
|
|
4499
|
+
def _load_appearance_config(self):
|
|
4500
|
+
cfg = SettingsDialog.load_config()
|
|
4501
|
+
if cfg is None:
|
|
4502
|
+
return
|
|
4503
|
+
g = cfg.get("general", {})
|
|
4504
|
+
o = cfg.get("overlay", {})
|
|
4505
|
+
a = cfg.get("atoms_bonds", {})
|
|
4506
|
+
if g:
|
|
4507
|
+
theme = g.get("theme", "light")
|
|
4508
|
+
if theme != self._current_theme:
|
|
4509
|
+
self._apply_theme(theme)
|
|
4510
|
+
self._canvas.background = o.get("bg_color", g.get("bg_color", self._canvas.background))
|
|
4511
|
+
self._restore_on_startup = g.get("restore_molecule", False)
|
|
4512
|
+
if a:
|
|
4513
|
+
self._canvas.atom_scale = a.get("atom_scale", self._canvas.atom_scale)
|
|
4514
|
+
self._canvas.bond_width_px = a.get("bond_width_px", self._canvas.bond_width_px)
|
|
4515
|
+
self._canvas.bond_style = a.get("bond_style", self._canvas.bond_style)
|
|
4516
|
+
self._canvas.bond_color = a.get("bond_color", self._canvas.bond_color)
|
|
4517
|
+
self._canvas.atom_border_mode = a.get("atom_border_mode", self._canvas.atom_border_mode)
|
|
4518
|
+
self._canvas.atom_border_scale = a.get("atom_border_scale", self._canvas.atom_border_scale)
|
|
4519
|
+
self._canvas.atom_border_width = a.get("atom_border_width", self._canvas.atom_border_width)
|
|
4520
|
+
self._canvas.lighting_intensity = a.get("lighting_intensity", self._canvas.lighting_intensity)
|
|
4521
|
+
self._canvas.light_position = a.get("light_position", self._canvas.light_position)
|
|
4522
|
+
self._canvas.roughness = a.get("roughness", self._canvas.roughness)
|
|
4523
|
+
self._color_overrides = a.get("color_overrides", {})
|
|
4524
|
+
self._canvas.color_overrides = self._color_overrides
|
|
4525
|
+
if o:
|
|
4526
|
+
self._canvas.show_axes = o.get("show_axes", self._canvas.show_axes)
|
|
4527
|
+
self._canvas.show_principal_axes = o.get("show_principal_axes", self._canvas.show_principal_axes)
|
|
4528
|
+
self._canvas.axes_position = o.get("axes_position", self._canvas.axes_position)
|
|
4529
|
+
self._canvas.principal_axes_position = o.get("principal_axes_position", self._canvas.principal_axes_position)
|
|
4530
|
+
self._canvas.request_render()
|
|
4531
|
+
|
|
4532
|
+
def _maybe_restore_molecule(self):
|
|
4533
|
+
if not getattr(self, '_restore_on_startup', False):
|
|
4534
|
+
return
|
|
4535
|
+
path = self.LAST_MOLECULE_FILE
|
|
4536
|
+
if not os.path.isfile(path):
|
|
4537
|
+
return
|
|
4538
|
+
try:
|
|
4539
|
+
with open(path) as f:
|
|
4540
|
+
content = f.read().strip()
|
|
4541
|
+
try:
|
|
4542
|
+
data = json.loads(content)
|
|
4543
|
+
mol_path = data.get("path", "")
|
|
4544
|
+
view = data
|
|
4545
|
+
except (json.JSONDecodeError, ValueError):
|
|
4546
|
+
mol_path = content
|
|
4547
|
+
view = None
|
|
4548
|
+
if mol_path and os.path.isfile(mol_path):
|
|
4549
|
+
self._load_and_display(mol_path)
|
|
4550
|
+
if view and self._canvas.molecule:
|
|
4551
|
+
c = self._canvas
|
|
4552
|
+
if "rot" in view:
|
|
4553
|
+
c._rot = np.array(view["rot"])
|
|
4554
|
+
if "zoom" in view:
|
|
4555
|
+
c._zoom = view["zoom"]
|
|
4556
|
+
if "pan" in view:
|
|
4557
|
+
c._pan = np.array(view["pan"])
|
|
4558
|
+
if "axes_ref" in view:
|
|
4559
|
+
c._axes_ref = np.array(view["axes_ref"])
|
|
4560
|
+
if "default_view" in view:
|
|
4561
|
+
c._default_view = np.array(view["default_view"])
|
|
4562
|
+
c.request_render()
|
|
4563
|
+
except Exception:
|
|
4564
|
+
pass
|
|
4565
|
+
|
|
4566
|
+
def _edit_settings(self, tab_index=0):
|
|
4567
|
+
cfg = SettingsDialog.load_config() or {}
|
|
4568
|
+
restore = cfg.get("general", {}).get("restore_molecule", False)
|
|
4569
|
+
orig = (self._current_theme, self._canvas.background,
|
|
4570
|
+
self._canvas.atom_scale, self._canvas.bond_width_px,
|
|
4571
|
+
self._canvas.bond_style, dict(self._color_overrides),
|
|
4572
|
+
self._canvas.atom_border_mode, self._canvas.atom_border_scale,
|
|
4573
|
+
self._canvas.atom_border_width, self._canvas.bond_color,
|
|
4574
|
+
self._canvas.lighting_intensity, self._canvas.light_position,
|
|
4575
|
+
self._canvas.roughness,
|
|
4576
|
+
self._canvas.show_axes, self._canvas.show_principal_axes,
|
|
4577
|
+
self._canvas.axes_position, self._canvas.principal_axes_position,
|
|
4578
|
+
restore)
|
|
4579
|
+
|
|
4580
|
+
def _live_update(theme, bg, ball, bw, style, colors, border_mode, border_scale,
|
|
4581
|
+
border_width, bcol, lighting, pos, rough,
|
|
4582
|
+
show_xyz, show_abc, xyz_pos, abc_pos):
|
|
4583
|
+
if theme != self._current_theme:
|
|
4584
|
+
self._apply_theme(theme)
|
|
4585
|
+
self._canvas.background = bg
|
|
4586
|
+
self._canvas.atom_scale = ball
|
|
4587
|
+
self._canvas.bond_width_px = bw
|
|
4588
|
+
self._canvas.bond_style = style
|
|
4589
|
+
self._canvas.bond_color = bcol
|
|
4590
|
+
self._canvas.atom_border_mode = border_mode
|
|
4591
|
+
self._canvas.atom_border_scale = border_scale
|
|
4592
|
+
self._canvas.atom_border_width = border_width
|
|
4593
|
+
self._canvas.lighting_intensity = lighting
|
|
4594
|
+
self._canvas.light_position = pos
|
|
4595
|
+
self._canvas.roughness = rough
|
|
4596
|
+
self._color_overrides = colors
|
|
4597
|
+
self._canvas.color_overrides = colors
|
|
4598
|
+
self._canvas.show_axes = show_xyz
|
|
4599
|
+
self._canvas.show_principal_axes = show_abc
|
|
4600
|
+
self._canvas.axes_position = xyz_pos
|
|
4601
|
+
self._canvas.principal_axes_position = abc_pos
|
|
4602
|
+
if self._canvas.molecule:
|
|
4603
|
+
self._legend.update_for(self._canvas.molecule, colors)
|
|
4604
|
+
self._canvas.request_render()
|
|
4605
|
+
|
|
4606
|
+
dlg = SettingsDialog(
|
|
4607
|
+
orig[0], orig[1], orig[2], orig[3], orig[4], orig[5],
|
|
4608
|
+
atom_border_mode=orig[6], atom_border_scale=orig[7], atom_border_width=orig[8],
|
|
4609
|
+
bond_color=orig[9], lighting_intensity=orig[10],
|
|
4610
|
+
light_position=orig[11], roughness=orig[12],
|
|
4611
|
+
show_axes=orig[13], show_principal_axes=orig[14],
|
|
4612
|
+
axes_position=orig[15], principal_axes_position=orig[16],
|
|
4613
|
+
restore_molecule=orig[17],
|
|
4614
|
+
live_callback=_live_update, parent=self,
|
|
4615
|
+
)
|
|
4616
|
+
dlg.tabs.setCurrentIndex(tab_index)
|
|
4617
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
4618
|
+
self._apply_theme(dlg.theme)
|
|
4619
|
+
self._canvas.background = dlg.bg_color
|
|
4620
|
+
self._canvas.atom_scale = dlg.ball_scale
|
|
4621
|
+
self._canvas.bond_width_px = dlg.bond_width
|
|
4622
|
+
self._canvas.bond_style = dlg.bond_style
|
|
4623
|
+
self._canvas.bond_color = dlg.bond_color
|
|
4624
|
+
self._canvas.atom_border_mode = dlg.border_mode
|
|
4625
|
+
self._canvas.atom_border_scale = dlg.border_scale
|
|
4626
|
+
self._canvas.atom_border_width = dlg.border_width
|
|
4627
|
+
self._canvas.lighting_intensity = dlg.lighting_intensity
|
|
4628
|
+
self._canvas.light_position = dlg.light_position
|
|
4629
|
+
self._canvas.roughness = dlg.roughness
|
|
4630
|
+
self._canvas.show_axes = dlg.show_axes
|
|
4631
|
+
self._canvas.show_principal_axes = dlg.show_principal_axes
|
|
4632
|
+
self._canvas.axes_position = dlg.axes_position
|
|
4633
|
+
self._canvas.principal_axes_position = dlg.principal_axes_position
|
|
4634
|
+
self._color_overrides = dlg._color_overrides
|
|
4635
|
+
self._canvas.color_overrides = dlg._color_overrides
|
|
4636
|
+
if self._canvas.molecule:
|
|
4637
|
+
self._legend.update_for(self._canvas.molecule, dlg._color_overrides)
|
|
4638
|
+
self._canvas.request_render()
|
|
4639
|
+
else:
|
|
4640
|
+
(self._current_theme, self._canvas.background,
|
|
4641
|
+
self._canvas.atom_scale, self._canvas.bond_width_px,
|
|
4642
|
+
self._canvas.bond_style, self._color_overrides,
|
|
4643
|
+
self._canvas.atom_border_mode, self._canvas.atom_border_scale,
|
|
4644
|
+
self._canvas.atom_border_width, self._canvas.bond_color,
|
|
4645
|
+
self._canvas.lighting_intensity,
|
|
4646
|
+
self._canvas.light_position,
|
|
4647
|
+
self._canvas.roughness,
|
|
4648
|
+
self._canvas.show_axes,
|
|
4649
|
+
self._canvas.show_principal_axes,
|
|
4650
|
+
self._canvas.axes_position,
|
|
4651
|
+
self._canvas.principal_axes_position,
|
|
4652
|
+
_) = orig
|
|
4653
|
+
self._canvas.color_overrides = self._color_overrides
|
|
4654
|
+
if self._canvas.molecule:
|
|
4655
|
+
self._legend.update_for(self._canvas.molecule, self._color_overrides)
|
|
4656
|
+
self._canvas.request_render()
|
|
4657
|
+
|
|
4658
|
+
def _edit_style(self):
|
|
4659
|
+
self._edit_settings(tab_index=2)
|
|
4660
|
+
|
|
4661
|
+
def _edit_overlay(self):
|
|
4662
|
+
self._edit_settings(tab_index=1)
|
|
4663
|
+
|
|
4664
|
+
def closeEvent(self, event):
|
|
4665
|
+
if self._current_path and self._canvas.molecule:
|
|
4666
|
+
try:
|
|
4667
|
+
c = self._canvas
|
|
4668
|
+
data = {
|
|
4669
|
+
"path": self._current_path,
|
|
4670
|
+
"rot": c._rot.tolist(),
|
|
4671
|
+
"zoom": c._zoom,
|
|
4672
|
+
"pan": c._pan.tolist(),
|
|
4673
|
+
"axes_ref": c._axes_ref.tolist(),
|
|
4674
|
+
"default_view": c._default_view.tolist(),
|
|
4675
|
+
}
|
|
4676
|
+
with open(self.LAST_MOLECULE_FILE, "w") as f:
|
|
4677
|
+
json.dump(data, f)
|
|
4678
|
+
except Exception:
|
|
4679
|
+
pass
|
|
4680
|
+
super().closeEvent(event)
|
|
4681
|
+
|
|
4682
|
+
def _edit_atom_colors(self):
|
|
4683
|
+
mol = self._canvas.molecule
|
|
4684
|
+
if mol is None:
|
|
4685
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4686
|
+
return
|
|
4687
|
+
elements = sorted({a.element for a in mol.atoms})
|
|
4688
|
+
orig_overrides = dict(self._color_overrides)
|
|
4689
|
+
|
|
4690
|
+
def _live_update(overrides):
|
|
4691
|
+
self._color_overrides = overrides
|
|
4692
|
+
self._canvas.color_overrides = overrides
|
|
4693
|
+
self._legend.update_for(mol, overrides)
|
|
4694
|
+
self._canvas.request_render()
|
|
4695
|
+
|
|
4696
|
+
dlg = AtomColorDialog(elements, orig_overrides, live_callback=_live_update, parent=self)
|
|
4697
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
4698
|
+
self._color_overrides = dlg.get_overrides()
|
|
4699
|
+
self._canvas.color_overrides = self._color_overrides
|
|
4700
|
+
self._legend.update_for(mol, self._color_overrides)
|
|
4701
|
+
self._canvas.request_render()
|
|
4702
|
+
else:
|
|
4703
|
+
self._color_overrides = orig_overrides
|
|
4704
|
+
self._canvas.color_overrides = orig_overrides
|
|
4705
|
+
self._legend.update_for(mol, orig_overrides)
|
|
4706
|
+
self._canvas.request_render()
|
|
4707
|
+
|
|
4708
|
+
def _reset_colors(self):
|
|
4709
|
+
self._color_overrides = {}
|
|
4710
|
+
self._canvas.color_overrides = {}
|
|
4711
|
+
if self._canvas.molecule:
|
|
4712
|
+
for atom in self._canvas.molecule.atoms:
|
|
4713
|
+
atom.color = None
|
|
4714
|
+
self._legend.update_for(self._canvas.molecule, {})
|
|
4715
|
+
self._canvas.request_render()
|
|
4716
|
+
self._status.showMessage("Atom colours reset to CPK defaults.")
|
|
4717
|
+
|
|
4718
|
+
def _paint_selected_atoms(self):
|
|
4719
|
+
mol = self._canvas.molecule
|
|
4720
|
+
if mol is None:
|
|
4721
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4722
|
+
return
|
|
4723
|
+
sel = self._canvas.selected_atoms
|
|
4724
|
+
if not sel:
|
|
4725
|
+
QMessageBox.information(self, "No selection", "Select atoms first, then use Paint.")
|
|
4726
|
+
return
|
|
4727
|
+
col = QColorDialog.getColor(QColor("#ff6666"), self, "Pick Colour for Selected Atoms")
|
|
4728
|
+
if not col.isValid():
|
|
4729
|
+
return
|
|
4730
|
+
hex_col = col.name()
|
|
4731
|
+
for idx in sel:
|
|
4732
|
+
mol.atoms[idx].color = hex_col
|
|
4733
|
+
self._canvas.selected_atoms.clear()
|
|
4734
|
+
self._canvas.color_overrides = {}
|
|
4735
|
+
self._color_overrides = {}
|
|
4736
|
+
self._legend.update_for(mol, {})
|
|
4737
|
+
self._canvas.request_render()
|
|
4738
|
+
self._status.showMessage(f"Painted {len(sel)} atom(s) with {hex_col}.")
|
|
4739
|
+
|
|
4740
|
+
def _clear_paint(self):
|
|
4741
|
+
mol = self._canvas.molecule
|
|
4742
|
+
if mol is None:
|
|
4743
|
+
QMessageBox.information(self, "No molecule", "Load or build a molecule first.")
|
|
4744
|
+
return
|
|
4745
|
+
n = sum(1 for a in mol.atoms if a.color is not None)
|
|
4746
|
+
if n == 0:
|
|
4747
|
+
self._status.showMessage("No painted atoms to clear.")
|
|
4748
|
+
return
|
|
4749
|
+
for atom in mol.atoms:
|
|
4750
|
+
atom.color = None
|
|
4751
|
+
self._legend.update_for(mol, self._color_overrides)
|
|
4752
|
+
self._canvas.request_render()
|
|
4753
|
+
self._status.showMessage(f"Cleared paint on {n} atom(s).")
|
|
4754
|
+
|
|
4755
|
+
# ── View actions ──────────────────────────────────────────────────────────
|
|
4756
|
+
|
|
4757
|
+
def _pick_background(self):
|
|
4758
|
+
col = QColorDialog.getColor(
|
|
4759
|
+
QColor(self._canvas.background), self, "Pick Background Colour"
|
|
4760
|
+
)
|
|
4761
|
+
if col.isValid():
|
|
4762
|
+
self._canvas.background = col.name()
|
|
4763
|
+
self._canvas.request_render()
|
|
4764
|
+
|
|
4765
|
+
# ── Help ──────────────────────────────────────────────────────────────────
|
|
4766
|
+
|
|
4767
|
+
def _open_test_molecule(self):
|
|
4768
|
+
test_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "test_files", "c5h6.xyz")
|
|
4769
|
+
if os.path.isfile(test_path):
|
|
4770
|
+
self._load_and_display(test_path)
|
|
4771
|
+
|
|
4772
|
+
def _show_about(self):
|
|
4773
|
+
QMessageBox.about(self, "About Molvector",
|
|
4774
|
+
"<b>Molvector</b> — 3D Molecule Viewer<br><br>"
|
|
4775
|
+
"Ball-and-stick rendering, G16 input generator.<br>"
|
|
4776
|
+
"Parsers: XYZ · Gaussian input (.gjf/.com) · Gaussian log (.log/.out) · Mol file<br><br>"
|
|
4777
|
+
"Dependencies: PyQt6 · NumPy · svgwrite · molstrudel · RDKit · OpenBabel"
|
|
4778
|
+
)
|
|
4779
|
+
|
|
4780
|
+
# ── callbacks ─────────────────────────────────────────────────────────────
|
|
4781
|
+
|
|
4782
|
+
def eventFilter(self, obj, event):
|
|
4783
|
+
if event.type() == QEvent.Type.MouseButtonDblClick and obj is self._zoom_slider:
|
|
4784
|
+
self._zoom_slider.setValue(100)
|
|
4785
|
+
return True
|
|
4786
|
+
return super().eventFilter(obj, event)
|
|
4787
|
+
|
|
4788
|
+
def _on_zoom_change(self, value):
|
|
4789
|
+
self._canvas._zoom = value / 100.0
|
|
4790
|
+
self._zoom_lbl.setText(f"{value}%")
|
|
4791
|
+
self._canvas.request_render(delay_ms=60)
|
|
4792
|
+
|
|
4793
|
+
def _reset_view(self):
|
|
4794
|
+
self._canvas.reset_view()
|
|
4795
|
+
self._zoom_slider.setValue(100)
|
|
4796
|
+
self._zoom_lbl.setText("100%")
|
|
4797
|
+
|
|
4798
|
+
def _on_rotation_changed(self):
|
|
4799
|
+
pct = int(self._canvas._zoom * 100)
|
|
4800
|
+
self._zoom_lbl.setText(f"{pct}%")
|
|
4801
|
+
self._zoom_slider.blockSignals(True)
|
|
4802
|
+
self._zoom_slider.setValue(pct)
|
|
4803
|
+
self._zoom_slider.blockSignals(False)
|
|
4804
|
+
|
|
4805
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
4806
|
+
# ENTRY POINT
|
|
4807
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
4808
|
+
|
|
4809
|
+
def main():
|
|
4810
|
+
app = QApplication(sys.argv)
|
|
4811
|
+
app.setApplicationName("Molvector")
|
|
4812
|
+
app.setFont(QFont("sans-serif", 10))
|
|
4813
|
+
|
|
4814
|
+
if platform.system() == "Windows":
|
|
4815
|
+
try:
|
|
4816
|
+
import ctypes
|
|
4817
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Molvector")
|
|
4818
|
+
except Exception:
|
|
4819
|
+
pass
|
|
4820
|
+
|
|
4821
|
+
|
|
4822
|
+
win = MainWindow()
|
|
4823
|
+
set_icons(app, win)
|
|
4824
|
+
win.show()
|
|
4825
|
+
|
|
4826
|
+
# Optional: open file from command line
|
|
4827
|
+
if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
|
|
4828
|
+
win._load_and_display(sys.argv[1])
|
|
4829
|
+
|
|
4830
|
+
sys.exit(app.exec())
|
|
4831
|
+
|
|
4832
|
+
|
|
4833
|
+
if __name__ == "__main__":
|
|
4834
|
+
main()
|