MoleditPy 2.2.0a0__py3-none-any.whl → 2.2.0a1__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.
Files changed (26) hide show
  1. moleditpy/modules/constants.py +1 -1
  2. moleditpy/modules/main_window_main_init.py +73 -103
  3. moleditpy/modules/plugin_manager.py +10 -0
  4. moleditpy/plugins/Analysis/ms_spectrum_neo.py +919 -0
  5. moleditpy/plugins/File/animated_xyz_giffer.py +583 -0
  6. moleditpy/plugins/File/cube_viewer.py +689 -0
  7. moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +1148 -0
  8. moleditpy/plugins/File/mapped_cube_viewer.py +552 -0
  9. moleditpy/plugins/File/orca_out_freq_analyzer.py +1226 -0
  10. moleditpy/plugins/File/paste_xyz.py +336 -0
  11. moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +930 -0
  12. moleditpy/plugins/Input Generator/orca_input_generator_neo.py +1028 -0
  13. moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +286 -0
  14. moleditpy/plugins/Optimization/all-trans_optimizer.py +65 -0
  15. moleditpy/plugins/Optimization/complex_molecule_untangler.py +268 -0
  16. moleditpy/plugins/Optimization/conf_search.py +224 -0
  17. moleditpy/plugins/Utility/atom_colorizer.py +547 -0
  18. moleditpy/plugins/Utility/console.py +163 -0
  19. moleditpy/plugins/Utility/pubchem_ressolver.py +244 -0
  20. moleditpy/plugins/Utility/vdw_radii_overlay.py +303 -0
  21. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/METADATA +1 -1
  22. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/RECORD +26 -9
  23. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/WHEEL +0 -0
  24. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/entry_points.txt +0 -0
  25. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/licenses/LICENSE +0 -0
  26. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1226 @@
1
+ import os
2
+ import numpy as np
3
+ import traceback
4
+ from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
5
+ QListWidget, QSlider, QCheckBox, QFileDialog, QMessageBox,
6
+ QDockWidget, QWidget, QFormLayout, QDialogButtonBox, QSpinBox, QApplication, QTreeWidget, QTreeWidgetItem, QHeaderView, QDoubleSpinBox)
7
+ from PyQt6.QtGui import QImage, QPainter, QPen, QColor, QFont, QPaintEvent
8
+ try:
9
+ from PIL import Image
10
+ HAS_PIL = True
11
+ except ImportError:
12
+ HAS_PIL = False
13
+ from PyQt6.QtCore import Qt, QTimer, pyqtSignal
14
+
15
+ # Try to import RDKit
16
+ try:
17
+ from rdkit import Chem
18
+ from rdkit.Geometry import Point3D
19
+ except ImportError:
20
+ Chem = None
21
+
22
+ PLUGIN_NAME = "ORCA Freq Analyzer"
23
+ __version__="2025.12.25"
24
+ __author__="HiroYokoyama"
25
+
26
+ class OrcaParser:
27
+ def __init__(self):
28
+ self.filename = ""
29
+ self.atoms = []
30
+ self.coords = []
31
+ self.frequencies = []
32
+ self.intensities = [] # IR Intensities
33
+ self.vib_modes = [] # list of list of (dx, dy, dz)
34
+ self.charge = 0
35
+ self.multiplicity = 1
36
+
37
+ def parse(self, filename):
38
+ self.filename = filename
39
+ self.atoms = []
40
+ self.coords = []
41
+ self.frequencies = []
42
+ self.vib_modes = []
43
+ self.charge = 0
44
+ self.multiplicity = 1
45
+
46
+ with open(filename, 'r', encoding='utf-8', errors='replace') as f:
47
+ lines = f.readlines()
48
+
49
+ # 1. basic properties: charge, mult
50
+ # Look for "Total Charge Charge .... 0"
51
+ # OR In input block: "* xyz 0 1"
52
+ # scan lines
53
+
54
+ # 2. Geometry: Look for "CARTESIAN COORDINATES (ANGSTROEM)" or similar.
55
+ # Use the LAST occurrence to get optimized geometry.
56
+
57
+ coord_start_lines = []
58
+ freq_start_line = -1
59
+ modes_start_line = -1
60
+
61
+ for i, line in enumerate(lines):
62
+ line_s = line.strip()
63
+ if "CARTESIAN COORDINATES (ANGSTROEM)" in line:
64
+ coord_start_lines.append(i)
65
+ elif "CARTESIAN COORDINATES (A.U.)" in line:
66
+ pass # ignore AU for now unless needed
67
+ elif "VIBRATIONAL FREQUENCIES" in line:
68
+ freq_start_line = i
69
+ elif "NORMAL MODES" in line:
70
+ modes_start_line = i
71
+ elif "Total Charge" in line and "Charge" in line:
72
+ # e.g. "Total Charge Charge .... 0"
73
+ parts = line.split()
74
+ try:
75
+ self.charge = int(parts[-1])
76
+ except: pass
77
+ elif "Multiplicity" in line and "Mult" in line:
78
+ parts = line.split()
79
+ try:
80
+ self.multiplicity = int(parts[-1])
81
+ except: pass
82
+
83
+ # Parse Geometry (Last one)
84
+ if coord_start_lines:
85
+ start = coord_start_lines[-1]
86
+ try:
87
+ # format:
88
+ # CARTESIAN COORDINATES (ANGSTROEM)
89
+ # ---------------------------------
90
+ # O 0.000000 0.000000 0.000000
91
+ # H 0.000000 0.759337 0.596043
92
+
93
+ # Check where data starts. Usually start+2
94
+ curr = start + 2
95
+ while curr < len(lines):
96
+ l = lines[curr].strip()
97
+ if not l:
98
+ curr += 1
99
+ # If multiple empty lines, maybe end
100
+ if curr < len(lines) and not lines[curr].strip():
101
+ break
102
+ continue
103
+
104
+ parts = l.split()
105
+ if len(parts) >= 4:
106
+ sym = parts[0]
107
+ # Check if sym is element
108
+ if not sym[0].isalpha():
109
+ break # End of block
110
+
111
+ try:
112
+ x = float(parts[1])
113
+ y = float(parts[2])
114
+ z = float(parts[3])
115
+
116
+ # Valid atom
117
+ # Convert Sym to atomic num? RDKit needs num or Valid symbol
118
+ # We can keep symbol or convert
119
+ # Let's keep symbol for now, convert to num for uniformity if needed
120
+ # RDKit Atom(symbol) works
121
+ self.atoms.append(sym)
122
+ self.coords.append((x, y, z))
123
+ except ValueError:
124
+ pass
125
+ else:
126
+ break
127
+ curr += 1
128
+ except Exception as e:
129
+ print(f"Error parsing coords: {e}")
130
+
131
+ # Parse Frequencies
132
+ # -----------------------
133
+ # VIBRATIONAL FREQUENCIES
134
+ # -----------------------
135
+ # ...
136
+ # 0: 0.00 cm**-1
137
+ # 6: 1709.03 cm**-1
138
+
139
+ if freq_start_line > 0:
140
+ curr = freq_start_line + 4 # skip header
141
+ while curr < len(lines):
142
+ l = lines[curr].strip()
143
+ if not l:
144
+ curr += 1
145
+ continue
146
+ if "NORMAL MODES" in l: # safe guard
147
+ break
148
+ # Format: " 6: 1709.03 cm**-1"
149
+ if ":" in l and "cm**-1" in l:
150
+ parts = l.split()
151
+ # parts example: ['6:', '1709.03', 'cm**-1']
152
+ # sometimes: '0:', '0.00', 'cm**-1'
153
+ try:
154
+ val_str = parts[1]
155
+ val = float(val_str)
156
+ if val != 0.0: # Only non-zero? FCHK has all. User wants vibrations.
157
+ # But let's verify if user wants 0 modes (translation/rotation). Usually not.
158
+ # But keeping index alignment with Normal Modes is crucial.
159
+ # ORCA Normal Modes output block usually includes all 3*N modes.
160
+ pass
161
+ self.frequencies.append(val)
162
+ except: pass
163
+ elif "NORMAL MODES" in l or "-----" in l:
164
+ if len(self.frequencies) > 0: # If we parsed some, stop
165
+ break
166
+ curr += 1
167
+
168
+ # Parse IR Spectrum for Intensities
169
+ # Store as a dictionary: mode_id -> intensity
170
+ intensity_map = {} # {mode_idx: intensity}
171
+
172
+ ir_start = -1
173
+ for i, line in enumerate(lines):
174
+ if "IR SPECTRUM" in line:
175
+ ir_start = i # Keep updating to get the LAST occurrence
176
+ # Don't break - continue to find last one
177
+
178
+ if ir_start > 0:
179
+ curr = ir_start + 6 # Skip headers and dashed line
180
+ # Expected format:
181
+ # Mode freq eps Int T**2 TX TY TZ
182
+ # cm**-1 L/(mol*cm) km/mol a.u.
183
+ # ----------------------------------------------------------------------------
184
+ # 6: 1709.03 0.015725 79.47 0.002871 (-0.001018 -0.053574 -0.000000)
185
+
186
+ while curr < len(lines):
187
+ l = lines[curr].strip()
188
+ if not l:
189
+ curr += 1
190
+ continue
191
+ if "-----" in l or "The first frequency" in l or "*" in l:
192
+ break
193
+
194
+ # line: " 6: 1709.03 0.015725 79.47 0.002871 ..."
195
+ if ":" in l:
196
+ parts = l.split()
197
+ # parts[0] -> "6:"
198
+ # parts[1] -> Freq
199
+ # parts[2] -> eps
200
+ # parts[3] -> Int (km/mol)
201
+ # parts[4] -> T**2
202
+ try:
203
+ mode_id_str = parts[0].rstrip(':')
204
+ mode_id = int(mode_id_str)
205
+ if len(parts) >= 4:
206
+ inten = float(parts[3])
207
+ intensity_map[mode_id] = inten
208
+ except:
209
+ pass
210
+ curr += 1
211
+
212
+ # Parse Normal Modes
213
+ # ------------
214
+ # NORMAL MODES
215
+ # ------------
216
+ # ...
217
+ # 0 1 2 3 4 5
218
+ # 0 0.000000 0.000000 ...
219
+ # ...
220
+ # 6 7 8
221
+ # 0 0.001345 -0.000914 0.069964
222
+
223
+ if modes_start_line > 0 and len(self.atoms) > 0:
224
+ # We need to reconstruct full vectors for each mode.
225
+ # ORCA output is blocked by columns (modes).
226
+ # Rows are atoms * 3 (X, Y, Z coordinates).
227
+
228
+ n_atoms = len(self.atoms)
229
+ n_coords = n_atoms * 3
230
+ # Initialize empty modes
231
+ # We don't know exactly how many modes total yet, but freq list gives a hint.
232
+ # Let's verify number of frequencies to initialize
233
+
234
+ # Since parsing columnar data is tricky line-by-line without knowing total columns,
235
+ # we will create a dictionary mode_index -> [vector]
236
+
237
+ mode_data = {} # {mode_idx: [val0, val1, ...]}
238
+
239
+ curr = modes_start_line + 7 # skip headers (approx)
240
+ # Find first line of numbers
241
+ while curr < len(lines) and not lines[curr].strip():
242
+ curr += 1
243
+
244
+ # Now we are at column headers: " 0 1 2 ..."
245
+ while curr < len(lines):
246
+ header = lines[curr].strip()
247
+ if not header:
248
+ curr += 1
249
+ continue
250
+ if "IR SPECTRUM" in header or "--------" in header:
251
+ break
252
+
253
+ # Parse column indices
254
+ try:
255
+ cols = [int(c) for c in header.split()]
256
+ except ValueError:
257
+ # Maybe not a header line, skip
258
+ curr += 1
259
+ continue
260
+
261
+ # Check next line: " 0 0.000000 0.000000 ..."
262
+ # This corresponds to coordinate index 0 (Atom 0, X)
263
+
264
+ start_data = curr + 1
265
+ # Read 3*N lines for coordinates
266
+ for row_idx in range(n_coords):
267
+ if start_data + row_idx >= len(lines): break
268
+ row_line = lines[start_data + row_idx].strip()
269
+ row_parts = row_line.split()
270
+
271
+ # First part is coordinate index (0, 1, 2...)
272
+ # Remaining parts are values for the columns
273
+ values = row_parts[1:]
274
+
275
+ if len(values) != len(cols):
276
+ # Mismatched data?
277
+ continue
278
+
279
+ for c_idx, val_str in enumerate(values):
280
+ mode_idx = cols[c_idx]
281
+ val = float(val_str)
282
+ if mode_idx not in mode_data:
283
+ mode_data[mode_idx] = []
284
+ mode_data[mode_idx].append(val)
285
+
286
+ curr = start_data + n_coords
287
+
288
+ # Convert mode_data to self.vib_modes aligned with self.frequencies?
289
+ # Frequencies list has indices 0...N.
290
+ # We only care about modes that match frequencies we found.
291
+ # Note ORCA freq list usually filters out 0.00 translations if "VIBRATIONAL FREQUENCIES" is used,
292
+ # BUT the list index in freq block (" 6: ...") matches the mode index.
293
+
294
+ # Let's align them.
295
+ # `self.frequencies` is just a flat list of values found. We need pairs (index, freq).
296
+ # The freq parsing above was simplistic. Let's re-parse freq to get IDs.
297
+
298
+ # Re-scanning frequencies for ID mapping:
299
+ freq_map = {} # id -> freq
300
+ if freq_start_line > 0:
301
+ curr = freq_start_line + 4
302
+ while curr < len(lines):
303
+ l = lines[curr].strip()
304
+ if ":" in l and "cm**-1" in l:
305
+ parts = l.split(':')
306
+ try:
307
+ mid = int(parts[0].strip())
308
+ freq_val = float(parts[1].split()[0])
309
+ freq_map[mid] = freq_val
310
+ except: pass
311
+ if "NORMAL MODES" in l: break
312
+ curr += 1
313
+
314
+ # Now build final list
315
+ sorted_mids = sorted(mode_data.keys())
316
+
317
+ # Only keep modes that have frequencies (or all?)
318
+ # Usually we only want non-zero modes (vibrations).
319
+ # ORCA often prints 0-5 as translations/rotations.
320
+
321
+ # Let's store pairs: (freq, vector)
322
+ # Filter: only if freq > 10.0 cm-1 (avoid translations)
323
+
324
+ self.final_modes = [] # item: {'freq': f, 'intensity': I, 'vector': [(x,y,z),...]}
325
+
326
+ for mid in sorted_mids:
327
+ freq = freq_map.get(mid, 0.0)
328
+ # Use abs() to preserve imaginary frequencies (negative values)
329
+ # Only exclude low-frequency modes (translations/rotations)
330
+ if abs(freq) < 10.0: continue
331
+
332
+ raw_vec = mode_data[mid]
333
+ if len(raw_vec) != n_coords: continue
334
+
335
+ # format to list of (dx,dy,dz)
336
+ vec_formatted = []
337
+ for k in range(0, len(raw_vec), 3):
338
+ vec_formatted.append((raw_vec[k], raw_vec[k+1], raw_vec[k+2]))
339
+
340
+ # Get intensity for this mode from intensity_map
341
+ intensity = intensity_map.get(mid, None)
342
+
343
+ self.final_modes.append({'freq': freq, 'intensity': intensity, 'vector': vec_formatted})
344
+
345
+
346
+ class OrcaOutFreqAnalyzer(QWidget):
347
+ def __init__(self, main_window, dock_widget=None):
348
+ super().__init__(main_window)
349
+ self.mw = main_window
350
+ self.dock = dock_widget # Store reference
351
+ self.setAcceptDrops(True)
352
+
353
+ self.parser = None
354
+ self.base_mol = None
355
+ self.timer = QTimer()
356
+ self.timer.timeout.connect(self.animate_frame)
357
+ self.animation_step = 0
358
+ self.is_playing = False
359
+ self.vector_actor = None
360
+
361
+ self.init_ui()
362
+
363
+
364
+ def init_ui(self):
365
+ layout = QVBoxLayout(self)
366
+
367
+ # Info Label
368
+ self.lbl_info = QLabel("Drop .out file here or click Open")
369
+ self.lbl_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
370
+ self.lbl_info.setStyleSheet("border: 2px dashed #AAA; padding: 20px; color: #555;")
371
+ layout.addWidget(self.lbl_info)
372
+
373
+ # Open Button
374
+ btn_open = QPushButton("Open ORCA Output")
375
+ btn_open.clicked.connect(self.open_file_dialog)
376
+ layout.addWidget(btn_open)
377
+
378
+
379
+ # Frequency List
380
+ # Frequency List
381
+ layout.addWidget(QLabel("Vibrational Frequencies:"))
382
+ self.list_freq = QTreeWidget()
383
+ self.list_freq.setColumnCount(3)
384
+ self.list_freq.setHeaderLabels(["No.", "Frequency (cm⁻¹)", "Intensity (km/mol)"])
385
+ self.list_freq.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
386
+ self.list_freq.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
387
+ self.list_freq.headerItem().setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) # Center No. header
388
+ self.list_freq.headerItem().setTextAlignment(1, Qt.AlignmentFlag.AlignCenter) # Center Frequency header
389
+ self.list_freq.headerItem().setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) # Center Intensity header
390
+ self.list_freq.currentItemChanged.connect(self.on_freq_selected)
391
+ layout.addWidget(self.list_freq)
392
+
393
+ # Spectrum Button
394
+ btn_spectrum = QPushButton("Show Spectrum")
395
+ btn_spectrum.clicked.connect(self.show_spectrum)
396
+ layout.addWidget(btn_spectrum)
397
+
398
+ # Animation Controls
399
+ anim_layout = QVBoxLayout()
400
+
401
+ # Vector Controls
402
+ vec_layout = QHBoxLayout()
403
+ self.chk_vectors = QCheckBox("Show Vectors")
404
+ self.chk_vectors.setChecked(False)
405
+ self.chk_vectors.stateChanged.connect(lambda state: self.update_vectors())
406
+ vec_layout.addWidget(self.chk_vectors)
407
+
408
+ vec_layout.addWidget(QLabel("Scale:"))
409
+ self.spin_vec_scale = QDoubleSpinBox()
410
+ self.spin_vec_scale.setRange(0.1, 200.0)
411
+ self.spin_vec_scale.setSingleStep(1.0)
412
+ self.spin_vec_scale.setValue(2.0)
413
+ self.spin_vec_scale.valueChanged.connect(lambda val: self.update_vectors())
414
+ vec_layout.addWidget(self.spin_vec_scale)
415
+ vec_layout.addStretch()
416
+
417
+ anim_layout.addLayout(vec_layout)
418
+
419
+ # Amplitude
420
+ amp_layout = QHBoxLayout()
421
+ amp_layout.addWidget(QLabel("Amplitude:"))
422
+ self.slider_amp = QSlider(Qt.Orientation.Horizontal)
423
+ self.slider_amp.setRange(1, 20)
424
+ self.slider_amp.setValue(5)
425
+
426
+ self.lbl_amp_val = QLabel("5")
427
+ self.slider_amp.valueChanged.connect(lambda v: self.lbl_amp_val.setText(str(v)))
428
+
429
+ amp_layout.addWidget(self.slider_amp)
430
+ amp_layout.addWidget(self.lbl_amp_val)
431
+ anim_layout.addLayout(amp_layout)
432
+
433
+ # FPS (Speed)
434
+ speed_layout = QHBoxLayout()
435
+ speed_layout.addWidget(QLabel("FPS:"))
436
+ self.slider_speed = QSlider(Qt.Orientation.Horizontal)
437
+ self.slider_speed.setRange(1, 60)
438
+ self.slider_speed.setValue(20)
439
+
440
+ self.lbl_speed_val = QLabel("20")
441
+ self.slider_speed.valueChanged.connect(lambda v: self.lbl_speed_val.setText(str(v)))
442
+
443
+ self.slider_speed.valueChanged.connect(self.update_timer_interval)
444
+ speed_layout.addWidget(self.slider_speed)
445
+ speed_layout.addWidget(self.lbl_speed_val)
446
+ anim_layout.addLayout(speed_layout)
447
+
448
+ # Buttons
449
+ btn_layout = QHBoxLayout()
450
+ self.btn_play = QPushButton("Play")
451
+ self.btn_play.clicked.connect(self.toggle_play)
452
+ self.btn_stop = QPushButton("Stop")
453
+ self.btn_stop.clicked.connect(self.stop_play)
454
+
455
+ btn_layout.addWidget(self.btn_play)
456
+ btn_layout.addWidget(self.btn_stop)
457
+ anim_layout.addLayout(btn_layout)
458
+
459
+ layout.addLayout(anim_layout)
460
+
461
+ # Export GIF Button
462
+ self.btn_gif = QPushButton("Export GIF")
463
+ self.btn_gif.clicked.connect(self.save_as_gif)
464
+ self.btn_gif.setEnabled(HAS_PIL)
465
+ layout.addWidget(self.btn_gif)
466
+
467
+ # Metadata Info
468
+ self.lbl_meta = QLabel("")
469
+ layout.addWidget(self.lbl_meta)
470
+
471
+ layout.addStretch()
472
+
473
+ # Close Button
474
+ btn_close = QPushButton("Close")
475
+ def close_action():
476
+ self.stop_play()
477
+ self.reset_geometry()
478
+ self.remove_vectors()
479
+ if self.dock:
480
+ self.dock.close()
481
+ else:
482
+ self.close()
483
+ btn_close.clicked.connect(close_action)
484
+ layout.addWidget(btn_close)
485
+
486
+ self.setLayout(layout)
487
+
488
+ def dragEnterEvent(self, event):
489
+ if event.mimeData().hasUrls():
490
+ for url in event.mimeData().urls():
491
+ fname = url.toLocalFile().lower()
492
+ if fname.endswith(".out") or fname.endswith(".log"):
493
+ event.acceptProposedAction()
494
+ return
495
+ event.ignore()
496
+
497
+ def dropEvent(self, event):
498
+ for url in event.mimeData().urls():
499
+ file_path = url.toLocalFile()
500
+ if file_path.lower().endswith((".out", ".log")):
501
+ self.load_file(file_path)
502
+ event.acceptProposedAction()
503
+ break
504
+
505
+ def open_file_dialog(self):
506
+ fname, _ = QFileDialog.getOpenFileName(self, "Open ORCA Out", "", "Output Files (*.out *.log)")
507
+ if fname:
508
+ self.load_file(fname)
509
+
510
+ def load_file(self, filename):
511
+ # Validation before parsing
512
+ if not is_valid_orca_file(filename):
513
+ QMessageBox.critical(self, "Invalid File", "The selected file does not appear to be a valid ORCA output file.\n(Missing 'ORCA' header)")
514
+ return
515
+
516
+ self.parser = OrcaParser()
517
+ try:
518
+ self.parser.parse(filename)
519
+ self.update_ui_after_load()
520
+ self.lbl_info.setText(os.path.basename(filename))
521
+ self.lbl_info.setStyleSheet("border: 2px solid #4CAF50; padding: 10px; color: #4CAF50;")
522
+
523
+ # Update Main Window Context
524
+ if hasattr(self.mw, 'current_file_path'):
525
+ self.mw.current_file_path = filename
526
+ if hasattr(self.mw, 'update_window_title'):
527
+ self.mw.update_window_title()
528
+ else:
529
+ self.mw.setWindowTitle(f"{os.path.basename(filename)} - MoleditPy")
530
+
531
+ except Exception as e:
532
+ QMessageBox.critical(self, "Error", f"Failed to parse Output:\n{e}")
533
+ traceback.print_exc()
534
+
535
+ def update_ui_after_load(self):
536
+ self.list_freq.clear()
537
+ if hasattr(self.parser, 'final_modes'):
538
+ for i, mode in enumerate(self.parser.final_modes):
539
+ freq = mode['freq']
540
+ item = QTreeWidgetItem()
541
+ item.setText(0, str(i + 1)) # Mode number
542
+ item.setText(1, f"{freq:.2f}")
543
+
544
+ # Get intensity from the mode dictionary
545
+ inten = mode.get('intensity')
546
+ if inten is not None:
547
+ item.setText(2, f"{inten:.2f}")
548
+ else:
549
+ item.setText(2, "-")
550
+ item.setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) # Center mode number
551
+ item.setTextAlignment(1, Qt.AlignmentFlag.AlignCenter) # Center frequency
552
+ item.setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) # Center intensity
553
+ self.list_freq.addTopLevelItem(item)
554
+
555
+ self.lbl_meta.setText(f"Charge: {self.parser.charge}, Multiplicity: {self.parser.multiplicity}\nAtoms: {len(self.parser.atoms)}")
556
+
557
+ # Load molecule into main window
558
+ if len(self.parser.atoms) > 0 and Chem:
559
+ self.create_base_molecule()
560
+
561
+ def create_base_molecule(self):
562
+ if not self.parser: return
563
+
564
+ mol = Chem.RWMol()
565
+
566
+ for sym in self.parser.atoms:
567
+ atom = Chem.Atom(sym)
568
+ mol.AddAtom(atom)
569
+
570
+ conf = Chem.Conformer(len(self.parser.atoms))
571
+ for idx, (x, y, z) in enumerate(self.parser.coords):
572
+ conf.SetAtomPosition(idx, Point3D(x, y, z))
573
+ mol.AddConformer(conf)
574
+
575
+ if hasattr(self.mw, 'estimate_bonds_from_distances'):
576
+ self.mw.estimate_bonds_from_distances(mol)
577
+
578
+ self.base_mol = mol.GetMol()
579
+ self.mw.current_mol = self.base_mol
580
+
581
+ if hasattr(self.mw, '_enter_3d_viewer_ui_mode'):
582
+ self.mw._enter_3d_viewer_ui_mode()
583
+
584
+ self.mw.draw_molecule_3d(self.base_mol)
585
+ if hasattr(self.mw, 'plotter'):
586
+ self.mw.plotter.reset_camera()
587
+
588
+ def on_freq_selected(self, current, previous):
589
+ if self.is_playing:
590
+ # Transition smoothly: Reset geometry base for next frame calculation?
591
+ # animate_frame uses base coords + displacement, so it handles switch naturally.
592
+ # Just ensure vectors update if needed (animate_frame updates them too)
593
+ pass
594
+ else:
595
+ self.reset_geometry()
596
+ self.update_vectors()
597
+
598
+ def toggle_play(self):
599
+ curr = self.list_freq.currentItem()
600
+ if not curr:
601
+ return
602
+
603
+ row = self.list_freq.indexOfTopLevelItem(curr)
604
+ if row < 0: return
605
+
606
+ if self.is_playing:
607
+ # Pause logic
608
+ self.is_playing = False
609
+ self.timer.stop()
610
+ self.btn_play.setText("Play")
611
+ # Do NOT reset geometry
612
+ return
613
+
614
+ self.is_playing = True
615
+ self.btn_play.setText("Pause")
616
+ self.timer.start(50)
617
+ self.update_timer_interval()
618
+
619
+ def stop_play(self):
620
+ self.is_playing = False
621
+ self.timer.stop()
622
+ self.btn_play.setText("Play")
623
+
624
+ self.reset_geometry()
625
+ QApplication.processEvents()
626
+
627
+ def update_timer_interval(self):
628
+ fps = self.slider_speed.value()
629
+ if fps <= 0: fps = 1
630
+ interval = 1000 / fps
631
+ self.timer.setInterval(int(interval))
632
+
633
+ def reset_geometry(self):
634
+ if not self.base_mol or not self.parser: return
635
+ conf = self.base_mol.GetConformer()
636
+ for idx, (x, y, z) in enumerate(self.parser.coords):
637
+ conf.SetAtomPosition(idx, Point3D(x, y, z))
638
+ self.mw.draw_molecule_3d(self.base_mol)
639
+ self.mw.draw_molecule_3d(self.base_mol)
640
+
641
+ self.update_vectors()
642
+
643
+ if hasattr(self.mw, 'plotter'):
644
+ self.mw.plotter.render()
645
+
646
+ def animate_frame(self):
647
+ if not self.parser or not self.base_mol:
648
+ self.stop_play()
649
+ return
650
+
651
+ curr = self.list_freq.currentItem()
652
+ if not curr:
653
+ self.stop_play()
654
+ return
655
+ row = self.list_freq.indexOfTopLevelItem(curr)
656
+ if row < 0 or row >= len(self.parser.final_modes):
657
+ return
658
+
659
+ mode_data = self.parser.final_modes[row]
660
+ mode_vecs = mode_data['vector']
661
+
662
+ self.animation_step += 1
663
+ cycle_pos = (self.animation_step % 20) / 20.0
664
+ phase = cycle_pos * 2 * np.pi
665
+
666
+ scale = self.slider_amp.value() / 20.0
667
+ factor = np.sin(phase) * scale
668
+
669
+ self.apply_displacement(mode_vecs, factor)
670
+ self.mw.draw_molecule_3d(self.base_mol)
671
+ self.update_vectors(mode_vecs=mode_vecs, scale_factor=factor)
672
+
673
+ def apply_displacement(self, mode_vecs, factor):
674
+ conf = self.base_mol.GetConformer()
675
+ base_coords = self.parser.coords
676
+
677
+ for idx, (bx, by, bz) in enumerate(base_coords):
678
+ if idx < len(mode_vecs):
679
+ dx, dy, dz = mode_vecs[idx]
680
+ nx = bx + dx * factor
681
+ ny = by + dy * factor
682
+ nz = bz + dz * factor
683
+ conf.SetAtomPosition(idx, Point3D(nx, ny, nz))
684
+
685
+ def remove_vectors(self):
686
+ if self.vector_actor and hasattr(self.mw, 'plotter'):
687
+ try:
688
+ self.mw.plotter.remove_actor(self.vector_actor)
689
+ except: pass
690
+ self.vector_actor = None
691
+
692
+ def update_vectors(self, mode_vecs=None, scale_factor=0.0):
693
+ # Clean up existing vectors
694
+ self.remove_vectors()
695
+
696
+ if not self.chk_vectors.isChecked():
697
+ return
698
+
699
+ if not self.parser or not self.base_mol or not hasattr(self.mw, 'plotter'):
700
+ return
701
+
702
+ # Get current frequency
703
+ curr = self.list_freq.currentItem()
704
+ if not curr: return
705
+ row = self.list_freq.indexOfTopLevelItem(curr)
706
+ if row < 0 or row >= len(self.parser.final_modes): return
707
+
708
+ # Get vectors if not provided
709
+ if mode_vecs is None:
710
+ mode_data = self.parser.final_modes[row]
711
+ mode_vecs = mode_data['vector']
712
+
713
+ # Current coords from molecule conformer
714
+ conf = self.base_mol.GetConformer()
715
+ coords = []
716
+ vectors = []
717
+
718
+ # Amplitude for vector length scaling
719
+ # Now decoupled from animation amplitude
720
+ vis_scale = self.spin_vec_scale.value()
721
+
722
+ for idx in range(len(mode_vecs)):
723
+ pos = conf.GetAtomPosition(idx)
724
+ coords.append([pos.x, pos.y, pos.z])
725
+
726
+ dx, dy, dz = mode_vecs[idx]
727
+ vectors.append([dx, dy, dz])
728
+
729
+ if not coords: return
730
+
731
+ coords = np.array(coords)
732
+ vectors = np.array(vectors)
733
+
734
+ try:
735
+ self.vector_actor = self.mw.plotter.add_arrows(coords, vectors, mag=vis_scale, color='lightgreen', show_scalar_bar=False)
736
+ except Exception as e:
737
+ print(f"Error adding arrows: {e}")
738
+
739
+ def save_as_gif(self):
740
+ if not self.parser or not self.base_mol: return
741
+
742
+ was_playing = self.is_playing
743
+ if self.is_playing:
744
+ self.toggle_play()
745
+
746
+ curr = self.list_freq.currentItem()
747
+ if not curr:
748
+ QMessageBox.warning(self, "Select Frequency", "Please select a frequency to export.")
749
+ return
750
+ row = self.list_freq.indexOfTopLevelItem(curr)
751
+
752
+ dialog = QDialog(self)
753
+ dialog.setWindowTitle("Export GIF Settings")
754
+ form = QFormLayout(dialog)
755
+
756
+ # Calculate current FPS
757
+ # Slider value is now FPS directly
758
+ current_fps = self.slider_speed.value()
759
+
760
+ spin_fps = QSpinBox()
761
+ spin_fps.setRange(1, 60)
762
+ spin_fps.setValue(current_fps)
763
+
764
+ chk_transparent = QCheckBox()
765
+ chk_transparent.setChecked(True)
766
+
767
+ chk_hq = QCheckBox()
768
+ chk_hq.setChecked(True)
769
+
770
+ form.addRow("FPS:", spin_fps)
771
+ form.addRow("Transparent Background:", chk_transparent)
772
+ form.addRow("High Quality (Adaptive):", chk_hq)
773
+
774
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
775
+ btns.accepted.connect(dialog.accept)
776
+ btns.rejected.connect(dialog.reject)
777
+ form.addRow(btns)
778
+
779
+ if dialog.exec() != QDialog.DialogCode.Accepted:
780
+ if was_playing: self.toggle_play()
781
+ return
782
+
783
+ target_fps = spin_fps.value()
784
+ use_transparent = chk_transparent.isChecked()
785
+ use_hq = chk_hq.isChecked()
786
+
787
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save GIF", "", "GIF Files (*.gif)")
788
+ if not file_path:
789
+ if was_playing: self.toggle_play()
790
+ return
791
+
792
+ if not file_path.lower().endswith('.gif'):
793
+ file_path += '.gif'
794
+
795
+ # Generate Frames
796
+ images = []
797
+ mode_data = self.parser.final_modes[row]
798
+ mode_vecs = mode_data['vector']
799
+
800
+ self.reset_geometry()
801
+
802
+ try:
803
+ for i in range(20):
804
+ cycle_pos = i / 20.0
805
+ phase = cycle_pos * 2 * np.pi
806
+ scale = self.slider_amp.value() / 20.0
807
+ factor = np.sin(phase) * scale
808
+
809
+ self.apply_displacement(mode_vecs, factor)
810
+ self.mw.draw_molecule_3d(self.base_mol)
811
+ self.update_vectors(mode_vecs, factor)
812
+ self.mw.plotter.render()
813
+
814
+ img_array = self.mw.plotter.screenshot(transparent_background=use_transparent, return_img=True)
815
+ if img_array is not None:
816
+ img = Image.fromarray(img_array)
817
+ images.append(img)
818
+
819
+ if images:
820
+ duration_ms = int(1000 / target_fps)
821
+ processed_images = []
822
+ for img in images:
823
+ if use_hq:
824
+ if use_transparent:
825
+ # Alpha preservation with adaptive palette wrapper
826
+ alpha = img.split()[3]
827
+ img_rgb = img.convert("RGB")
828
+ # Quantize to 255 colors to leave room for transparency
829
+ img_p = img_rgb.convert('P', palette=Image.Palette.ADAPTIVE, colors=255)
830
+ # Set simple transparency
831
+ mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
832
+ img_p.paste(255, mask)
833
+ img_p.info['transparency'] = 255
834
+ processed_images.append(img_p)
835
+ else:
836
+ processed_images.append(img.convert("P", palette=Image.Palette.ADAPTIVE, colors=256))
837
+ else:
838
+ if use_transparent:
839
+ processed_images.append(img.convert("RGBA"))
840
+ else:
841
+ processed_images.append(img.convert("RGB"))
842
+
843
+ processed_images[0].save(file_path, save_all=True, append_images=processed_images[1:], duration=duration_ms, loop=0, disposal=2)
844
+ QMessageBox.information(self, "Success", f"Saved GIF to:\n{file_path}")
845
+
846
+ except Exception as e:
847
+ QMessageBox.critical(self, "Error", f"Failed to save GIF: {e}")
848
+ traceback.print_exc()
849
+ finally:
850
+ self.reset_geometry()
851
+ if was_playing:
852
+ self.toggle_play()
853
+
854
+
855
+ def on_dock_visibility_changed(self, visible):
856
+ if not visible and self.is_playing:
857
+ self.stop_play()
858
+
859
+ def close_plugin(self):
860
+ self.stop_play()
861
+ self.remove_vectors()
862
+ self.animation_step = 0
863
+ def show_spectrum(self):
864
+ # Use final_modes which has proper freq-intensity alignment
865
+ # and already filters out low frequencies (translations/rotations)
866
+
867
+ if not hasattr(self.parser, 'final_modes') or not self.parser.final_modes:
868
+ QMessageBox.warning(self, "No Data", "No vibrational frequencies available.")
869
+ return
870
+
871
+ freqs = []
872
+ intensities = []
873
+
874
+ for mode in self.parser.final_modes:
875
+ freqs.append(mode['freq'])
876
+ intensities.append(mode.get('intensity', 0.0))
877
+
878
+ dlg = SpectrumDialog(freqs, intensities, self)
879
+ dlg.exec()
880
+
881
+
882
+ class SpectrumDialog(QDialog):
883
+ def __init__(self, freqs, intensities, parent=None):
884
+ super().__init__(parent)
885
+ self.setWindowTitle("IR Spectrum")
886
+ self.resize(800, 600)
887
+
888
+ self.freqs = np.array(freqs)
889
+ self.intensities = np.array(intensities)
890
+
891
+ # Layout
892
+ layout = QVBoxLayout(self)
893
+
894
+ # Plot Area
895
+ self.plot_widget = SpectrumPlotWidget(self.freqs, self.intensities)
896
+ layout.addWidget(self.plot_widget)
897
+
898
+ # Controls
899
+ controls = QHBoxLayout()
900
+
901
+ controls.addWidget(QLabel("Gaussian Broadening (FWHM, cm⁻¹):"))
902
+ self.spin_fwhm = QSpinBox()
903
+ self.spin_fwhm.setRange(1, 500)
904
+ self.spin_fwhm.setValue(50)
905
+ self.spin_fwhm.valueChanged.connect(self.on_fwhm_changed)
906
+ controls.addWidget(self.spin_fwhm)
907
+
908
+ controls.addWidget(self.spin_fwhm)
909
+
910
+ # Axis Range
911
+ controls.addWidget(QLabel("Min WN:"))
912
+ self.spin_min = QSpinBox()
913
+ self.spin_min.setRange(0, 5000)
914
+ self.spin_min.setValue(0)
915
+ self.spin_min.setSingleStep(100)
916
+ self.spin_min.valueChanged.connect(self.on_range_changed)
917
+ controls.addWidget(self.spin_min)
918
+
919
+ controls.addWidget(QLabel("Max WN:"))
920
+ self.spin_max = QSpinBox()
921
+ self.spin_max.setRange(0, 5000)
922
+ self.spin_max.setValue(4000)
923
+ self.spin_max.setSingleStep(100)
924
+ self.spin_max.valueChanged.connect(self.on_range_changed)
925
+ controls.addWidget(self.spin_max)
926
+
927
+ btn_csv = QPushButton("Export CSV")
928
+ btn_csv.clicked.connect(self.export_csv)
929
+ controls.addWidget(btn_csv)
930
+
931
+ btn_png = QPushButton("Export Image")
932
+ btn_png.clicked.connect(self.export_png)
933
+ controls.addWidget(btn_png)
934
+
935
+ btn_close = QPushButton("Close")
936
+ btn_close.clicked.connect(self.accept)
937
+ controls.addWidget(btn_close)
938
+
939
+ layout.addLayout(controls)
940
+ self.setLayout(layout)
941
+
942
+ # Initial Plot
943
+ self.on_range_changed()
944
+
945
+ def on_fwhm_changed(self, val):
946
+ self.plot_widget.set_fwhm(val)
947
+
948
+ def on_range_changed(self):
949
+ mn = self.spin_min.value()
950
+ mx = self.spin_max.value()
951
+ if mx > mn:
952
+ self.plot_widget.set_range(mn, mx)
953
+
954
+ def export_csv(self):
955
+ fname, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Data", "", "CSV Files (*.csv)")
956
+ if fname:
957
+ if not fname.lower().endswith('.csv'): fname += '.csv'
958
+ try:
959
+ x, y = self.plot_widget.get_curve_data()
960
+ with open(fname, 'w') as f:
961
+ f.write("Frequency,Intensity\n")
962
+ for xi, yi in zip(x, y):
963
+ f.write(f"{xi:.2f},{yi:.4f}\n")
964
+ QMessageBox.information(self, "Success", "Saved CSV.")
965
+ except Exception as e:
966
+ QMessageBox.critical(self, "Error", str(e))
967
+
968
+ def export_png(self):
969
+ fname, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Image", "", "PNG Files (*.png)")
970
+ if fname:
971
+ if not fname.lower().endswith('.png'): fname += '.png'
972
+ try:
973
+ # Capture the widget
974
+ pixmap = self.plot_widget.grab()
975
+ pixmap.save(fname)
976
+ QMessageBox.information(self, "Success", "Saved Image.")
977
+ except Exception as e:
978
+ QMessageBox.critical(self, "Error", str(e))
979
+
980
+ class SpectrumPlotWidget(QWidget):
981
+ def __init__(self, freqs, intensities, parent=None):
982
+ super().__init__(parent)
983
+ self.freqs = freqs
984
+ self.intensities = intensities
985
+ self.fwhm = 80.0
986
+ self.curve_x = []
987
+ self.curve_y = []
988
+
989
+ self.setAutoFillBackground(True)
990
+ self.setStyleSheet("background-color: white;")
991
+
992
+ self.min_x = 0.0
993
+ self.max_x = 4000.0
994
+
995
+ def set_fwhm(self, val):
996
+ self.fwhm = val
997
+ self.recalc_curve()
998
+ self.update()
999
+
1000
+ def set_range(self, mn, mx):
1001
+ self.min_x = float(mn)
1002
+ self.max_x = float(mx)
1003
+ self.recalc_curve()
1004
+ self.update()
1005
+
1006
+ def get_curve_data(self):
1007
+ return self.curve_x, self.curve_y
1008
+
1009
+ def recalc_curve(self):
1010
+ if len(self.freqs) == 0: return
1011
+
1012
+ # X resolution based on custom range
1013
+ self.curve_x = np.linspace(self.min_x, self.max_x, 1000)
1014
+ self.curve_y = np.zeros_like(self.curve_x)
1015
+
1016
+ # Sum Gaussians
1017
+ sigma = self.fwhm / 2.35482
1018
+
1019
+ for f, i in zip(self.freqs, self.intensities):
1020
+ self.curve_y += i * np.exp(-(self.curve_x - f)**2 / (2 * sigma**2))
1021
+
1022
+ # Do NOT normalize - preserve actual intensity values
1023
+
1024
+ def paintEvent(self, event):
1025
+ painter = QPainter(self)
1026
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1027
+
1028
+ w = self.width()
1029
+ h = self.height()
1030
+
1031
+ # Margins
1032
+ margin_l = 50
1033
+ margin_r = 20
1034
+ margin_t = 20
1035
+ margin_b = 60 # Increased from 40 for better spacing
1036
+
1037
+ plot_w = w - margin_l - margin_r
1038
+ plot_h = h - margin_t - margin_b
1039
+
1040
+ if len(self.curve_x) == 0:
1041
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No Data")
1042
+ return
1043
+
1044
+ # Calculate max_y from both curve AND raw intensities to prevent stick normalization
1045
+ # Add 10% padding for better visibility
1046
+ max_curve = np.max(self.curve_y) if len(self.curve_y) > 0 else 1.0
1047
+ max_intensity = np.max(self.intensities) if len(self.intensities) > 0 else 1.0
1048
+ max_y = max(max_curve, max_intensity) * 1.1 # 1.1x for padding
1049
+ if max_y == 0: max_y = 1.0
1050
+ min_x = np.min(self.curve_x)
1051
+ max_x = np.max(self.curve_x)
1052
+ range_x = max_x - min_x
1053
+ if range_x == 0: range_x = 100
1054
+
1055
+ # Helper to transform
1056
+ # Inverted X: Max (left) -> Min (right)
1057
+ # Inverted Y: 0 (top) -> Max (bottom)
1058
+
1059
+ def to_screen(x, y):
1060
+ # X: margin_l corresponds to max_x, w - margin_r corresponds to min_x
1061
+ # formula: x_ratio = (max_x - x) / range_x
1062
+ # sx = margin_l + x_ratio * plot_w
1063
+ sx = margin_l + (max_x - x) / range_x * plot_w
1064
+
1065
+ # Y: margin_t corresponds to 0, h - margin_b corresponds to max_y
1066
+ # formula: y_ratio = y / max_y
1067
+ # sy = margin_t + y_ratio * plot_h
1068
+ sy = margin_t + (y / max_y) * plot_h
1069
+ return sx, sy
1070
+
1071
+ # Draw Axes
1072
+ painter.setPen(QPen(Qt.GlobalColor.black, 2))
1073
+ painter.drawLine(margin_l, margin_t, w - margin_r, margin_t) # X-axis at top (Baseline)
1074
+ painter.drawLine(margin_l, h - margin_b, w - margin_r, h - margin_b) # X-axis at bottom
1075
+
1076
+ painter.drawLine(margin_l, h - margin_b, margin_l, margin_t) # Y (Left)
1077
+ painter.drawLine(w - margin_r, h - margin_b, w - margin_r, margin_t) # Y (Right border)
1078
+
1079
+ # Draw Ticks / Labels (Simplified)
1080
+ font = painter.font()
1081
+ font.setPointSize(12) # Increased from 8
1082
+ painter.setFont(font)
1083
+
1084
+ # X Ticks (approx 5)
1085
+ # Inverted: Left is Max, Right is Min
1086
+ n_ticks = 5
1087
+ for i in range(n_ticks + 1):
1088
+ # val goes from max_x to min_x
1089
+ val = max_x - (range_x * i / n_ticks)
1090
+ px, py = to_screen(val, 0)
1091
+ # Label at bottom
1092
+ painter.drawText(int(px)-20, h - margin_b + 5, 40, 20, Qt.AlignmentFlag.AlignCenter, f"{int(val)}")
1093
+ painter.drawLine(int(px), h - margin_b, int(px), h - margin_b + 5)
1094
+
1095
+ # Labels
1096
+ font.setPointSize(14) # Increased from 10
1097
+ font.setBold(True)
1098
+ painter.setFont(font)
1099
+ painter.drawText(0, h-25, w, 20, Qt.AlignmentFlag.AlignCenter, "Wavenumber (cm⁻¹)")
1100
+
1101
+ # Draw baseline at y=0
1102
+ baseline_x_start, baseline_y = to_screen(max_x, 0)
1103
+ baseline_x_end, _ = to_screen(min_x, 0)
1104
+ painter.setPen(QPen(QColor(150, 150, 150), 1, Qt.PenStyle.DashLine))
1105
+ painter.drawLine(int(baseline_x_start), int(baseline_y), int(baseline_x_end), int(baseline_y))
1106
+
1107
+ # Draw Curve
1108
+ painter.setPen(QPen(Qt.GlobalColor.blue, 2))
1109
+ path_points = []
1110
+ for x, y in zip(self.curve_x, self.curve_y):
1111
+ sx, sy = to_screen(x, y)
1112
+ path_points.append( (sx, sy) )
1113
+
1114
+ if len(path_points) > 1:
1115
+ from PyQt6.QtCore import QPointF
1116
+ qpoints = [QPointF(x, y) for x, y in path_points]
1117
+ painter.drawPolyline(qpoints)
1118
+
1119
+ # Draw Sticks (Bars) for original frequencies
1120
+ painter.setPen(QPen(QColor(255, 0, 0, 100), 1))
1121
+ for f, i in zip(self.freqs, self.intensities):
1122
+ sx, sy = to_screen(f, i)
1123
+ px_base, py_base = to_screen(f, 0)
1124
+ painter.drawLine(int(sx), int(py_base), int(sx), int(sy))
1125
+
1126
+ # def closeEvent(self, event):
1127
+ # self.close_plugin()
1128
+ # super().closeEvent(event)
1129
+
1130
+
1131
+
1132
+ def on_dock_visibility_changed(self, visible):
1133
+ if not visible:
1134
+ self.stop_play()
1135
+ self.animation_step = 0
1136
+ if self.base_mol:
1137
+ self.reset_geometry()
1138
+ # Force redraw
1139
+ if hasattr(self.mw, 'plotter'):
1140
+ self.mw.plotter.render()
1141
+
1142
+
1143
+ def load_from_file(main_window, fname):
1144
+ dock = None
1145
+ analyzer = None
1146
+
1147
+ # Check existing docks
1148
+ for d in main_window.findChildren(QDockWidget):
1149
+ if d.windowTitle() == "ORCA Output Freq Analyzer":
1150
+ dock = d
1151
+ analyzer = d.widget()
1152
+ break
1153
+
1154
+ if not dock:
1155
+ dock = QDockWidget("ORCA Output Freq Analyzer", main_window)
1156
+ dock.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
1157
+ analyzer = OrcaOutFreqAnalyzer(main_window, dock)
1158
+ dock.setWidget(analyzer)
1159
+ main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
1160
+
1161
+ # Connect visibility change
1162
+ dock.visibilityChanged.connect(analyzer.on_dock_visibility_changed)
1163
+
1164
+ dock.show()
1165
+ dock.raise_()
1166
+
1167
+ if analyzer:
1168
+ analyzer.load_file(fname)
1169
+
1170
+ def is_valid_orca_file(filepath):
1171
+ try:
1172
+ with open(filepath, 'r', errors='ignore') as f:
1173
+ # Check first 500 lines for "ORCA" keyword to be safe
1174
+ for _ in range(500):
1175
+ line = f.readline()
1176
+ if not line: break
1177
+ if "ORCA" in line or "O R C A" in line:
1178
+ return True
1179
+ return False
1180
+ except:
1181
+ return False
1182
+
1183
+ def run(mw):
1184
+ # Smart Open Logic
1185
+ if hasattr(mw, 'current_file_path') and mw.current_file_path:
1186
+ fpath = mw.current_file_path.lower()
1187
+ if fpath.endswith((".out", ".log")):
1188
+ if is_valid_orca_file(mw.current_file_path):
1189
+ load_from_file(mw, mw.current_file_path)
1190
+ return
1191
+
1192
+ fname, _ = QFileDialog.getOpenFileName(mw, "Open ORCA Output", "", "ORCA Output (*.out *.log);;All Files (*)")
1193
+ if not fname: return
1194
+
1195
+ if is_valid_orca_file(fname):
1196
+ load_from_file(mw, fname)
1197
+ else:
1198
+ QMessageBox.warning(mw, "Invalid File", "This does not appear to be a valid ORCA output file.")
1199
+
1200
+ def initialize(context):
1201
+ mw = context.get_main_window()
1202
+
1203
+ def load_wrapper(fname):
1204
+ # Validate ORCA file before loading
1205
+ if is_valid_orca_file(fname):
1206
+ load_from_file(mw, fname)
1207
+ else:
1208
+ print(f"Skipping invalid ORCA file: {fname}")
1209
+
1210
+ # 1. Register File Openers
1211
+ # Note: .log can be generic, but our valid check helps
1212
+ context.register_file_opener('.out', load_wrapper)
1213
+ context.register_file_opener('.log', load_wrapper)
1214
+
1215
+ # 2. Register Drop Handler
1216
+ def drop_handler(file_path):
1217
+ fpath = file_path.lower()
1218
+ if fpath.endswith(('.out', '.log')):
1219
+ if is_valid_orca_file(file_path):
1220
+ load_from_file(mw, file_path)
1221
+ return True
1222
+ return False
1223
+
1224
+ if hasattr(context, 'register_drop_handler'):
1225
+ context.register_drop_handler(drop_handler, priority=10)
1226
+