MoleditPy 2.2.0a0__py3-none-any.whl → 2.2.0a2__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 (27) hide show
  1. moleditpy/modules/constants.py +1 -1
  2. moleditpy/modules/main_window_main_init.py +73 -103
  3. moleditpy/modules/main_window_ui_manager.py +21 -2
  4. moleditpy/modules/plugin_manager.py +10 -0
  5. moleditpy/plugins/Analysis/ms_spectrum_neo.py +919 -0
  6. moleditpy/plugins/File/animated_xyz_giffer.py +583 -0
  7. moleditpy/plugins/File/cube_viewer.py +689 -0
  8. moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +1148 -0
  9. moleditpy/plugins/File/mapped_cube_viewer.py +552 -0
  10. moleditpy/plugins/File/orca_out_freq_analyzer.py +1226 -0
  11. moleditpy/plugins/File/paste_xyz.py +336 -0
  12. moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +930 -0
  13. moleditpy/plugins/Input Generator/orca_input_generator_neo.py +1028 -0
  14. moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +286 -0
  15. moleditpy/plugins/Optimization/all-trans_optimizer.py +65 -0
  16. moleditpy/plugins/Optimization/complex_molecule_untangler.py +268 -0
  17. moleditpy/plugins/Optimization/conf_search.py +224 -0
  18. moleditpy/plugins/Utility/atom_colorizer.py +262 -0
  19. moleditpy/plugins/Utility/console.py +163 -0
  20. moleditpy/plugins/Utility/pubchem_ressolver.py +244 -0
  21. moleditpy/plugins/Utility/vdw_radii_overlay.py +432 -0
  22. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a2.dist-info}/METADATA +1 -1
  23. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a2.dist-info}/RECORD +27 -10
  24. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a2.dist-info}/WHEEL +0 -0
  25. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a2.dist-info}/entry_points.txt +0 -0
  26. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a2.dist-info}/licenses/LICENSE +0 -0
  27. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,689 @@
1
+
2
+ import os
3
+ import tempfile
4
+ import numpy as np
5
+ import pyvista as pv
6
+ from PyQt6.QtWidgets import (QFileDialog, QDockWidget, QWidget, QVBoxLayout,
7
+ QSlider, QLabel, QHBoxLayout, QPushButton, QMessageBox,
8
+ QDoubleSpinBox, QColorDialog, QInputDialog, QDialog,
9
+ QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox)
10
+ from PyQt6.QtGui import QColor
11
+ from PyQt6.QtCore import Qt
12
+
13
+ # RDKit imports for molecule construction
14
+ try:
15
+ from rdkit import Chem
16
+ from rdkit import Geometry
17
+ try:
18
+ from rdkit.Chem import rdDetermineBonds
19
+ except ImportError:
20
+ rdDetermineBonds = None
21
+ except ImportError:
22
+ Chem = None
23
+ Geometry = None
24
+ rdDetermineBonds = None
25
+
26
+ __version__="2025.12.20"
27
+ __author__="HiroYokoyama"
28
+ PLUGIN_NAME = "Cube File Viewer"
29
+
30
+ def parse_cube_data(filename):
31
+ """
32
+ Parses a Gaussian Cube file and returns raw data structures.
33
+ Adapted from test.py with robust header handling.
34
+ """
35
+ with open(filename, 'r') as f:
36
+ lines = f.readlines()
37
+
38
+ if len(lines) < 6:
39
+ raise ValueError("File too short to be a Cube file.")
40
+
41
+ # --- Header Parsing ---
42
+ # Line 3: Natoms, Origin
43
+ tokens = lines[2].split()
44
+ n_atoms_raw = int(tokens[0])
45
+ n_atoms = abs(n_atoms_raw)
46
+ origin_raw = np.array([float(tokens[1]), float(tokens[2]), float(tokens[3])])
47
+
48
+ # Lines 4-6: NX, NY, NZ and vectors
49
+ def parse_vec(line):
50
+ t = line.split()
51
+ return int(t[0]), np.array([float(t[1]), float(t[2]), float(t[3])])
52
+
53
+ nx, x_vec_raw = parse_vec(lines[3])
54
+ ny, y_vec_raw = parse_vec(lines[4])
55
+ nz, z_vec_raw = parse_vec(lines[5])
56
+
57
+ # Auto-detect units based on sign of NX/NY/NZ (Gaussian standard)
58
+ is_angstrom_header = (nx < 0 or ny < 0 or nz < 0)
59
+
60
+ nx, ny, nz = abs(nx), abs(ny), abs(nz)
61
+
62
+ # --- Atoms Parsing ---
63
+ atoms = []
64
+ current_line = 6
65
+ if n_atoms_raw < 0:
66
+ # Smart check: if next line does not look like an atom line, skip it.
67
+ try:
68
+ parts = lines[current_line].split()
69
+ if len(parts) != 5:
70
+ current_line += 1
71
+ except:
72
+ current_line += 1
73
+
74
+ for _ in range(n_atoms):
75
+ line = lines[current_line].split()
76
+ current_line += 1
77
+ atomic_num = int(line[0])
78
+ try:
79
+ x, y, z = float(line[2]), float(line[3]), float(line[4])
80
+ except:
81
+ x, y, z = 0.0, 0.0, 0.0
82
+ atoms.append((atomic_num, np.array([x, y, z])))
83
+
84
+ # --- Volumetric Data Parsing ---
85
+
86
+ # Skip metadata lines (e.g. "1 150") before data starts
87
+ while current_line < len(lines):
88
+ line_content = lines[current_line].strip()
89
+ parts = line_content.split()
90
+
91
+ # Skip empty lines
92
+ if not parts:
93
+ current_line += 1
94
+ continue
95
+
96
+ # Skip short lines (metadata often has few columns, data usually has 6)
97
+ if len(parts) < 6:
98
+ current_line += 1
99
+ continue
100
+
101
+ # Check if start is numeric
102
+ try:
103
+ float(parts[0])
104
+ except ValueError:
105
+ current_line += 1
106
+ continue
107
+
108
+ # If we get here, it's likely data
109
+ break
110
+
111
+ # Read rest of file
112
+ full_str = " ".join(lines[current_line:])
113
+ try:
114
+ data_values = np.fromstring(full_str, sep=' ')
115
+ except:
116
+ data_values = np.array([])
117
+
118
+ expected_size = nx * ny * nz
119
+ actual_size = len(data_values)
120
+
121
+ # FIX: Trim from START if excess > 0 (The header values are at the start)
122
+ # This logic fixes the shift issue test.py had.
123
+ if actual_size > expected_size:
124
+ excess = actual_size - expected_size
125
+ data_values = data_values[excess:]
126
+ elif actual_size < expected_size:
127
+ pad = np.zeros(expected_size - actual_size)
128
+ data_values = np.concatenate((data_values, pad))
129
+
130
+ return {
131
+ "atoms": atoms,
132
+ "origin": origin_raw,
133
+ "x_vec": x_vec_raw,
134
+ "y_vec": y_vec_raw,
135
+ "z_vec": z_vec_raw,
136
+ "dims": (nx, ny, nz),
137
+ "data_flat": data_values,
138
+ "is_angstrom_header": is_angstrom_header
139
+ }
140
+
141
+ def build_grid_from_meta(meta):
142
+ """
143
+ Reconstructs the PyVista grid.
144
+ Correctly handles standard Cube (Z-fast) mapping.
145
+ """
146
+ nx, ny, nz = meta['dims']
147
+ origin = meta['origin'].copy()
148
+ x_vec = meta['x_vec'].copy()
149
+ y_vec = meta['y_vec'].copy()
150
+ z_vec = meta['z_vec'].copy()
151
+ atoms = []
152
+
153
+ # Units Handling (replicated logic)
154
+ BOHR_TO_ANGSTROM = 0.529177210903
155
+ convert_to_ang = True
156
+ if meta['is_angstrom_header']:
157
+ convert_to_ang = False
158
+
159
+ if convert_to_ang:
160
+ origin *= BOHR_TO_ANGSTROM
161
+ x_vec *= BOHR_TO_ANGSTROM
162
+ y_vec *= BOHR_TO_ANGSTROM
163
+ z_vec *= BOHR_TO_ANGSTROM
164
+
165
+ for anum, pos in meta['atoms']:
166
+ p = pos.copy()
167
+ if convert_to_ang:
168
+ p *= BOHR_TO_ANGSTROM
169
+ atoms.append((anum, p))
170
+
171
+ # Grid Points Generation (Matches test.py logic for F-order consistency)
172
+ x_range = np.arange(nx)
173
+ y_range = np.arange(ny)
174
+ z_range = np.arange(nz)
175
+
176
+ gx, gy, gz = np.meshgrid(x_range, y_range, z_range, indexing='ij')
177
+
178
+ # Flatten using Fortran order (X-fast) for VTK Structure
179
+ gx_f = gx.flatten(order='F')
180
+ gy_f = gy.flatten(order='F')
181
+ gz_f = gz.flatten(order='F')
182
+
183
+ points = (origin +
184
+ np.outer(gx_f, x_vec) +
185
+ np.outer(gy_f, y_vec) +
186
+ np.outer(gz_f, z_vec))
187
+
188
+ grid = pv.StructuredGrid()
189
+ grid.points = points
190
+ grid.dimensions = [nx, ny, nz]
191
+
192
+ # Data Mapping
193
+ raw_data = meta['data_flat']
194
+
195
+ # Standard Cube: Z-Fast (C-order reshape)
196
+ # Then flatten F-order to match the X-fast points we just generated
197
+ # This logic matches test.py's implementation which user preferred
198
+ vol_3d = raw_data.reshape((nx, ny, nz), order='C')
199
+ grid.point_data["values"] = vol_3d.flatten(order='F')
200
+
201
+ return {"atoms": atoms}, grid
202
+
203
+ def read_cube(filename):
204
+ meta = parse_cube_data(filename)
205
+ return build_grid_from_meta(meta)
206
+
207
+ class CubeViewerWidget(QWidget):
208
+ def __init__(self, parent_window, dock_widget, grid, data_max=1.0):
209
+ super().__init__(parent_window)
210
+ self.mw = parent_window
211
+ self.dock = dock_widget
212
+ self.grid = grid
213
+ # Ensure we have a reasonable positive max value
214
+ self.data_max = max(abs(float(data_max)), 1e-6)
215
+
216
+ self.iso_actor_p = None
217
+ self.iso_actor_n = None
218
+
219
+ self.plotter = self.mw.plotter
220
+
221
+ # Initial Colors
222
+ self.color_p = "#0000FF" # Blue
223
+ self.color_n = "#FF0000" # Red
224
+
225
+ self.init_ui()
226
+ self.update_iso()
227
+
228
+ def init_ui(self):
229
+ layout = QVBoxLayout()
230
+
231
+ # --- Isovalue Controls ---
232
+ ctrl_layout = QHBoxLayout()
233
+ ctrl_layout.addWidget(QLabel("Isovalue:"))
234
+
235
+ # Spinbox: Fixed max 1.0 as requested
236
+ # Spinbox: Dynamic max based on data
237
+ # We allow going a bit higher than the max found in data, e.g. 1.2x, just in case
238
+ self.max_val = self.data_max * 1.2
239
+
240
+ self.spin = QDoubleSpinBox()
241
+ self.spin.setRange(0.0001, self.max_val)
242
+ self.spin.setSingleStep(0.01) # User requested 0.01 step
243
+ self.spin.setDecimals(5)
244
+
245
+ # Default value strategy:
246
+ # User requested hardcoded 0.05
247
+ default_val = 0.05
248
+
249
+ # If default is outside the max range, adjust it
250
+ if default_val > self.max_val:
251
+ default_val = self.max_val * 0.1
252
+
253
+ self.spin.setValue(default_val)
254
+
255
+ self.spin.valueChanged.connect(self.on_spin_changed)
256
+ ctrl_layout.addWidget(self.spin)
257
+
258
+ # Slider
259
+ self.slider_max_int = 1000
260
+
261
+ self.slider = QSlider(Qt.Orientation.Horizontal)
262
+ self.slider.setRange(0, self.slider_max_int)
263
+
264
+ # Default 0.05
265
+ # Default set from spinbox value
266
+ self.slider.setValue(int((default_val / self.max_val) * self.slider_max_int))
267
+
268
+ self.slider.valueChanged.connect(self.on_slider_changed)
269
+ ctrl_layout.addWidget(self.slider)
270
+
271
+ layout.addLayout(ctrl_layout)
272
+
273
+ # --- Color Controls ---
274
+ # Fixed size for color buttons to align them nicely
275
+ btn_size = (50, 24)
276
+
277
+ # Positive
278
+ pos_color_layout = QHBoxLayout()
279
+ pos_color_layout.addWidget(QLabel("Pos Color:"))
280
+ self.btn_color_p = QPushButton()
281
+ self.btn_color_p.setFixedSize(*btn_size)
282
+ self.btn_color_p.setStyleSheet(f"background-color: {self.color_p}; border: 1px solid gray;")
283
+ self.btn_color_p.clicked.connect(self.choose_color_p)
284
+ pos_color_layout.addWidget(self.btn_color_p)
285
+ pos_color_layout.addStretch() # Align left
286
+ layout.addLayout(pos_color_layout)
287
+
288
+ # Negative
289
+ neg_color_layout = QHBoxLayout()
290
+ neg_color_layout.addWidget(QLabel("Neg Color:"))
291
+ self.btn_color_n = QPushButton()
292
+ self.btn_color_n.setFixedSize(*btn_size)
293
+ self.btn_color_n.setStyleSheet(f"background-color: {self.color_n}; border: 1px solid gray;")
294
+ self.btn_color_n.clicked.connect(self.choose_color_n)
295
+ neg_color_layout.addWidget(self.btn_color_n)
296
+ neg_color_layout.addStretch() # Align left
297
+ layout.addLayout(neg_color_layout)
298
+
299
+ # Complementary Checkbox (Next line)
300
+ comp_layout = QHBoxLayout()
301
+ self.check_comp_color = QCheckBox("Use complementary color for neg")
302
+ self.check_comp_color.toggled.connect(self.on_comp_color_toggled)
303
+ comp_layout.addWidget(self.check_comp_color)
304
+ comp_layout.addStretch()
305
+ layout.addLayout(comp_layout)
306
+
307
+ # --- Opacity Controls ---
308
+ opacity_layout = QHBoxLayout()
309
+ # Static label (no numeric value displayed)
310
+ self.opacity_label = QLabel("Opacity:")
311
+ opacity_layout.addWidget(self.opacity_label)
312
+
313
+ # Numeric opacity input: place before the slider to match isovalue controls
314
+ self.opacity_spin = QDoubleSpinBox()
315
+ self.opacity_spin.setRange(0.0, 1.0)
316
+ self.opacity_spin.setSingleStep(0.01)
317
+ self.opacity_spin.setDecimals(2)
318
+ self.opacity_spin.setValue(0.4)
319
+ self.opacity_spin.valueChanged.connect(self.on_opacity_spin_changed)
320
+ opacity_layout.addWidget(self.opacity_spin)
321
+
322
+ self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
323
+ self.opacity_slider.setRange(0, 100)
324
+ self.opacity_slider.setValue(40) # Match spinbox
325
+ self.opacity_slider.valueChanged.connect(self.on_opacity_changed)
326
+ opacity_layout.addWidget(self.opacity_slider)
327
+
328
+ layout.addLayout(opacity_layout)
329
+
330
+ close_btn = QPushButton("Close Plugin")
331
+ close_btn.clicked.connect(self.close_plugin)
332
+ layout.addWidget(close_btn)
333
+
334
+ layout.addStretch()
335
+ self.setLayout(layout)
336
+
337
+ def on_comp_color_toggled(self, checked):
338
+ self.btn_color_n.setEnabled(not checked)
339
+ if checked:
340
+ self.update_complementary_color()
341
+
342
+ def update_complementary_color(self):
343
+ # Convert hex/name to QColor
344
+ col_p = QColor(self.color_p)
345
+ if not col_p.isValid():
346
+ return
347
+
348
+ h = col_p.hue()
349
+ s = col_p.saturation()
350
+ v = col_p.value()
351
+
352
+ # Complementary = hue + 180 degrees
353
+ if h != -1: # -1 means grayscale/achromatic
354
+ new_h = (h + 180) % 360
355
+ else:
356
+ new_h = h # Keep achromatic
357
+
358
+ col_n = QColor.fromHsv(new_h, s, v)
359
+ self.color_n = col_n.name()
360
+ self.btn_color_n.setStyleSheet(f"background-color: {self.color_n}; border: 1px solid gray;")
361
+ self.update_iso()
362
+
363
+ def choose_color_p(self):
364
+ c = QColorDialog.getColor(initial=QColor(self.color_p), title="Select Positive Lobe Color")
365
+ if c.isValid():
366
+ self.color_p = c.name()
367
+ self.btn_color_p.setStyleSheet(f"background-color: {self.color_p}; border: 1px solid gray;")
368
+
369
+ if self.check_comp_color.isChecked():
370
+ self.update_complementary_color()
371
+ else:
372
+ self.update_iso()
373
+
374
+ def choose_color_n(self):
375
+ c = QColorDialog.getColor(initial=QColor(self.color_n), title="Select Negative Lobe Color")
376
+ if c.isValid():
377
+ self.color_n = c.name()
378
+ self.btn_color_n.setStyleSheet(f"background-color: {self.color_n}; border: 1px solid gray;")
379
+ self.update_iso()
380
+
381
+ def update_iso(self):
382
+ val = self.spin.value()
383
+ # Prefer numeric spinbox value (kept in sync with slider)
384
+ opacity = self.opacity_spin.value() if hasattr(self, 'opacity_spin') else self.opacity_slider.value() / 100.0
385
+
386
+ try:
387
+ # Cleanup previous
388
+ if self.iso_actor_p: self.plotter.remove_actor(self.iso_actor_p)
389
+ if self.iso_actor_n: self.plotter.remove_actor(self.iso_actor_n)
390
+
391
+ # Additional safety cleanup by name
392
+ self.plotter.remove_actor("cube_iso_p")
393
+ self.plotter.remove_actor("cube_iso_n")
394
+
395
+ # Using full grid
396
+ using_grid = self.grid
397
+
398
+ # Positive lobe
399
+ iso_p = using_grid.contour(isosurfaces=[val])
400
+ if iso_p.n_points > 0:
401
+ self.iso_actor_p = self.plotter.add_mesh(iso_p, color=self.color_p, opacity=opacity, name="cube_iso_p", reset_camera=False)
402
+
403
+ # Negative lobe
404
+ iso_n = using_grid.contour(isosurfaces=[-val])
405
+ if iso_n.n_points > 0:
406
+ self.iso_actor_n = self.plotter.add_mesh(iso_n, color=self.color_n, opacity=opacity, name="cube_iso_n", reset_camera=False)
407
+
408
+ self.plotter.render()
409
+
410
+ except Exception as e:
411
+ print(f"Iso update error: {e}")
412
+ import traceback
413
+ traceback.print_exc()
414
+
415
+ def on_opacity_changed(self, val):
416
+ opacity = val / 100.0
417
+ # Sync numeric spinbox without re-triggering signals
418
+ if hasattr(self, 'opacity_spin'):
419
+ self.opacity_spin.blockSignals(True)
420
+ self.opacity_spin.setValue(opacity)
421
+ self.opacity_spin.blockSignals(False)
422
+ self.update_iso()
423
+
424
+ def on_opacity_spin_changed(self, val):
425
+ # val is between 0.0 and 1.0
426
+ # Sync slider (0-100)
427
+ if hasattr(self, 'opacity_slider'):
428
+ int_val = int(round(val * 100))
429
+ self.opacity_slider.blockSignals(True)
430
+ self.opacity_slider.setValue(int_val)
431
+ self.opacity_slider.blockSignals(False)
432
+ self.update_iso()
433
+
434
+ def on_slider_changed(self, val):
435
+ float_val = (val / self.slider_max_int) * self.max_val
436
+ self.spin.blockSignals(True)
437
+ self.spin.setValue(float_val)
438
+ self.spin.blockSignals(False)
439
+ self.update_iso()
440
+
441
+ def on_spin_changed(self, val):
442
+ if self.max_val > 0:
443
+ int_val = int((val / self.max_val) * self.slider_max_int)
444
+ self.slider.blockSignals(True)
445
+ self.slider.setValue(int_val)
446
+ self.slider.blockSignals(False)
447
+ self.update_iso()
448
+
449
+ def close_plugin(self):
450
+ try:
451
+ # Full cleanup
452
+ self.mw.plotter.clear()
453
+ self.mw.current_mol = None
454
+ self.mw.current_file_path = None
455
+ self.mw.plotter.render()
456
+
457
+ # Restore UI state
458
+ if hasattr(self.mw, 'restore_ui_for_editing'):
459
+ self.mw.restore_ui_for_editing()
460
+ except: pass
461
+
462
+ if self.dock:
463
+ self.mw.removeDockWidget(self.dock)
464
+ self.dock.deleteLater()
465
+ self.dock = None
466
+
467
+ self.deleteLater()
468
+
469
+ class ChargeDialog(QDialog):
470
+ def __init__(self, parent=None, current_charge=0):
471
+ super().__init__(parent)
472
+ self.setWindowTitle("Bond Connectivity Error")
473
+ self.result_action = "cancel" # retry, skip, cancel
474
+ self.charge = current_charge
475
+
476
+ self.init_ui()
477
+
478
+ def init_ui(self):
479
+ layout = QVBoxLayout()
480
+ layout.addWidget(QLabel("Could not determine connectivity with charge: " + str(self.charge)))
481
+ layout.addWidget(QLabel("Please specificy correct charge or skip chemistry check."))
482
+
483
+ form = QFormLayout()
484
+ self.spin = QSpinBox()
485
+ self.spin.setRange(-20, 20)
486
+ self.spin.setValue(self.charge)
487
+ form.addRow("Charge:", self.spin)
488
+ layout.addLayout(form)
489
+
490
+ btns = QHBoxLayout()
491
+ retry_btn = QPushButton("Retry")
492
+ retry_btn.clicked.connect(self.on_retry)
493
+
494
+ skip_btn = QPushButton("Skip Chemistry")
495
+ skip_btn.clicked.connect(self.on_skip)
496
+
497
+ cancel_btn = QPushButton("Cancel")
498
+ cancel_btn.clicked.connect(self.reject)
499
+
500
+ btns.addWidget(retry_btn)
501
+ btns.addWidget(skip_btn)
502
+ btns.addWidget(cancel_btn)
503
+ layout.addLayout(btns)
504
+
505
+ self.setLayout(layout)
506
+
507
+ def on_retry(self):
508
+ self.charge = self.spin.value()
509
+ self.result_action = "retry"
510
+ self.accept()
511
+
512
+ def on_skip(self):
513
+ self.result_action = "skip"
514
+ self.accept()
515
+
516
+ def open_cube_viewer(main_window, fname):
517
+ """Core logic to open cube viewer with a specific file."""
518
+ if Chem is None:
519
+ QMessageBox.critical(main_window, "Error", "RDKit is required for this plugin.")
520
+ return
521
+
522
+ # Close existing docks
523
+ docks_to_close = []
524
+ for dock in main_window.findChildren(QDockWidget):
525
+ if dock.windowTitle() == "Cube Viewer":
526
+ docks_to_close.append(dock)
527
+
528
+ for dock in docks_to_close:
529
+ try:
530
+ widget = dock.widget()
531
+ if hasattr(widget, 'close_plugin'):
532
+ widget.close_plugin()
533
+ else:
534
+ main_window.removeDockWidget(dock)
535
+ dock.deleteLater()
536
+ except:
537
+ pass
538
+
539
+ try:
540
+ if not fname: # Should not happen if called correctly
541
+ return
542
+
543
+ try:
544
+ meta, grid = read_cube(fname)
545
+ except Exception as e:
546
+ import traceback
547
+ traceback.print_exc()
548
+ QMessageBox.critical(main_window, "Error", f"Failed to parse Cube file:\n{e}")
549
+ return
550
+
551
+ if hasattr(main_window, 'plotter'):
552
+ main_window.plotter.clear()
553
+
554
+ if hasattr(main_window, 'main_window_ui_manager'):
555
+ main_window.main_window_ui_manager._enter_3d_viewer_ui_mode()
556
+
557
+ # Create Molecule (XYZ)
558
+ atoms = meta['atoms']
559
+ xyz_lines = [f"{len(atoms)}", "Generated by Cube Plugin"]
560
+ pt = Chem.GetPeriodicTable()
561
+ for atomic_num, pos in atoms:
562
+ symbol = pt.GetElementSymbol(atomic_num)
563
+ xyz_lines.append(f"{symbol} {pos[0]:.4f} {pos[1]:.4f} {pos[2]:.4f}")
564
+
565
+ xyz_content = "\n".join(xyz_lines)
566
+
567
+ mol = Chem.MolFromXYZBlock(xyz_content)
568
+
569
+ # Use rdDetermineBonds if available and no bonds found
570
+ current_charge = 0
571
+ if mol is not None and mol.GetNumBonds() == 0 and rdDetermineBonds is not None:
572
+ while True:
573
+ # Re-create fresh molecule for this attempt to avoid dirty state on retry
574
+ mol = Chem.MolFromXYZBlock(xyz_content)
575
+
576
+ try:
577
+ rdDetermineBonds.DetermineConnectivity(mol, charge=current_charge)
578
+ rdDetermineBonds.DetermineBondOrders(mol, charge=current_charge)
579
+ break # Success
580
+ except Exception as e:
581
+ # Show Dialog
582
+ dlg = ChargeDialog(main_window, current_charge)
583
+ if dlg.exec() == QDialog.DialogCode.Accepted:
584
+ if dlg.result_action == "retry":
585
+ current_charge = dlg.charge
586
+ continue # Loop again with new charge
587
+ elif dlg.result_action == "skip":
588
+ # Ensure we have a clean unbonded mol
589
+ mol = Chem.MolFromXYZBlock(xyz_content)
590
+ rdDetermineBonds.DetermineConnectivity(mol, charge=0)
591
+ break # Break loop, accept no bonds/bad connectivity
592
+ else:
593
+ return # User Cancelled plugin load
594
+
595
+ if mol is None:
596
+ # Fallback
597
+ mol = Chem.RWMol()
598
+ conf = Chem.Conformer()
599
+ for i, (atomic_num, pos) in enumerate(atoms):
600
+ idx = mol.AddAtom(Chem.Atom(atomic_num))
601
+ conf.SetAtomPosition(idx, Geometry.Point3D(pos[0], pos[1], pos[2]))
602
+ mol.AddConformer(conf)
603
+
604
+ # Set current molecular data in main window for consistency
605
+ main_window.current_mol = mol
606
+ main_window.current_file_path = fname
607
+
608
+ # Draw
609
+ if hasattr(main_window, 'draw_molecule_3d'):
610
+ main_window.draw_molecule_3d(mol)
611
+ elif hasattr(main_window, 'main_window_view_3d'):
612
+ main_window.main_window_view_3d.draw_molecule_3d(mol)
613
+
614
+ # Report bonds
615
+ nb = mol.GetNumBonds() if mol else 0
616
+ if hasattr(main_window, 'statusBar'):
617
+ main_window.statusBar().showMessage(f"Loaded Cube. Atoms: {len(atoms)}, Bonds: {nb}")
618
+
619
+ # Setup Dock
620
+ dock = QDockWidget("Cube Viewer", main_window)
621
+ dock.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
622
+
623
+ # Calculate max absolute value in data for dynamic scaling
624
+ try:
625
+ # Data is stored in grid.point_data["values"]
626
+ # "make sure that the data is only in the plot data"
627
+ flat_data = grid.point_data["values"]
628
+ if len(flat_data) > 0:
629
+ data_max = float(np.max(np.abs(flat_data)))
630
+ else:
631
+ data_max = 1.0
632
+ except:
633
+ data_max = 1.0
634
+
635
+ viewer = CubeViewerWidget(main_window, dock, grid, data_max=data_max)
636
+ dock.setWidget(viewer)
637
+
638
+ main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
639
+
640
+ main_window.plotter.reset_camera()
641
+
642
+ except Exception as e:
643
+ import traceback
644
+ traceback.print_exc()
645
+ print(f"Plugin Error: {e}")
646
+
647
+ def run(mw):
648
+ if Chem is None:
649
+ QMessageBox.critical(mw, "Error", "RDKit is required for this plugin.")
650
+ return
651
+
652
+ fname, _ = QFileDialog.getOpenFileName(mw, "Open Gaussian Cube File", "", "Cube Files (*.cube *.cub);;All Files (*)")
653
+ if fname:
654
+ open_cube_viewer(mw, fname)
655
+
656
+ def initialize(context):
657
+ """
658
+ New Plugin System Entry Point
659
+ """
660
+ mw = context.get_main_window()
661
+
662
+ def open_cube_wrapper(fname):
663
+ open_cube_viewer(mw, fname)
664
+
665
+ # 1. Register File Opener (Handle File > Import)
666
+ context.register_file_opener('.cube', open_cube_wrapper)
667
+ context.register_file_opener('.cub', open_cube_wrapper)
668
+
669
+ # 2. Register Drop Handler (for robustness)
670
+ # The system iterates drop handlers. Return True if we handled it.
671
+ def drop_handler(file_path):
672
+ if file_path.lower().endswith(('.cube', '.cub')):
673
+ open_cube_viewer(mw, file_path)
674
+ return True
675
+ return False
676
+
677
+ if hasattr(context, 'register_drop_handler'):
678
+ context.register_drop_handler(drop_handler, priority=10)
679
+
680
+ # 4. Command Line Args (Legacy support logic moved here)
681
+ import sys
682
+ import os
683
+ from PyQt6.QtCore import QTimer
684
+ # Simple check for CLI args
685
+ for arg in sys.argv[1:]:
686
+ if arg.lower().endswith(('.cube', '.cub')) and os.path.exists(arg):
687
+ QTimer.singleShot(100, lambda f=arg: open_cube_viewer(mw, f))
688
+ break
689
+