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,583 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Animated XYZ Player Plugin for MoleditPy
4
+
5
+ Allows loading and playing multi-frame XYZ files (e.g., MD trajectories).
6
+ """
7
+
8
+ import os
9
+ import time
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
12
+ QSlider, QLabel, QSpinBox, QFileDialog, QWidget,
13
+ QMessageBox, QDockWidget, QCheckBox, QFormLayout, QDialogButtonBox
14
+ )
15
+ try:
16
+ from PIL import Image
17
+ HAS_PIL = True
18
+ except ImportError:
19
+ HAS_PIL = False
20
+ from PyQt6.QtCore import Qt, QTimer, QSize
21
+ from rdkit import Chem
22
+ from rdkit.Chem import AllChem, rdGeometry
23
+
24
+ __version__="2025.12.25"
25
+ __author__="HiroYokoyama"
26
+ PLUGIN_NAME = "Animated XYZ Giffer"
27
+
28
+ class AnimatedXYZPlayer(QDialog):
29
+ def __init__(self, main_window):
30
+ super().__init__(main_window)
31
+ self.mw = main_window
32
+ self.setWindowTitle("Animated XYZ Player")
33
+ self.setWindowFlags(Qt.WindowType.Window) # Make it a separate window acting like a tool
34
+ self.resize(400, 150)
35
+
36
+ # Data
37
+ self.frames = [] # List of list of (symbol, x, y, z)
38
+ self.current_frame_idx = 0
39
+ self.target_frame_idx = 0
40
+ self.base_mol = None # RDKit Mol with topology
41
+ self.is_playing = False
42
+ self.fps = 10
43
+
44
+ # Update / Threading flags
45
+ self.is_updating_view = False
46
+ self.pending_update = False
47
+
48
+ # UI Layout
49
+ layout = QVBoxLayout(self)
50
+
51
+ # File controls
52
+ file_layout = QHBoxLayout()
53
+ self.btn_load = QPushButton("Load XYZ")
54
+ self.btn_load.clicked.connect(self.load_file)
55
+ self.lbl_file = QLabel("No file loaded")
56
+ file_layout.addWidget(self.btn_load)
57
+ file_layout.addWidget(self.lbl_file)
58
+ file_layout.addStretch()
59
+
60
+ layout.addLayout(file_layout)
61
+
62
+ # Status
63
+ self.lbl_status = QLabel("Frame: 0 / 0")
64
+ layout.addWidget(self.lbl_status)
65
+
66
+ # Slider
67
+ self.slider = QSlider(Qt.Orientation.Horizontal)
68
+ self.slider.setEnabled(False)
69
+ self.slider.valueChanged.connect(self.on_slider_changed)
70
+ layout.addWidget(self.slider)
71
+
72
+ # Playback controls
73
+ ctrl_layout = QHBoxLayout()
74
+
75
+ self.btn_prev = QPushButton("<<")
76
+ self.btn_prev.clicked.connect(self.prev_frame)
77
+ self.btn_prev.setEnabled(False)
78
+
79
+ self.btn_play = QPushButton("Play")
80
+ self.btn_play.clicked.connect(self.toggle_play)
81
+ self.btn_play.setEnabled(False)
82
+
83
+ self.btn_next = QPushButton(">>")
84
+ self.btn_next.clicked.connect(self.next_frame)
85
+ self.btn_next.setEnabled(False)
86
+
87
+ ctrl_layout.addWidget(self.btn_prev)
88
+ ctrl_layout.addWidget(self.btn_play)
89
+ ctrl_layout.addWidget(self.btn_next)
90
+ layout.addLayout(ctrl_layout)
91
+
92
+ # FPS control
93
+ fps_layout = QHBoxLayout()
94
+ fps_layout.addWidget(QLabel("FPS:"))
95
+ self.spin_fps = QSpinBox()
96
+ self.spin_fps.setRange(1, 60)
97
+ self.spin_fps.setValue(self.fps)
98
+ self.spin_fps.valueChanged.connect(self.set_fps)
99
+ fps_layout.addWidget(self.spin_fps)
100
+ layout.addLayout(fps_layout)
101
+
102
+ # Timer
103
+ self.timer = QTimer(self)
104
+ self.timer.timeout.connect(self.on_timer)
105
+
106
+ # Bottom layout for actions
107
+ bottom_layout = QHBoxLayout()
108
+ bottom_layout.addStretch()
109
+
110
+ self.btn_save_gif = QPushButton("Save GIF")
111
+ self.btn_save_gif.clicked.connect(self.save_as_gif)
112
+ self.btn_save_gif.setEnabled(False)
113
+ bottom_layout.addWidget(self.btn_save_gif)
114
+
115
+ layout.addLayout(bottom_layout)
116
+
117
+ # Try to import existing molecule from main window
118
+ self.try_import_from_mainwindow()
119
+
120
+ def try_import_from_mainwindow(self):
121
+ """
122
+ Check if the main window has an opened molecule (especially XYZ) and use it.
123
+ Uses the file path from the main window and reloads it to ensure all frames are captured.
124
+ """
125
+ if hasattr(self.mw, 'current_file_path') and self.mw.current_file_path:
126
+ fp = self.mw.current_file_path
127
+ # Basic check if it's an XYZ file or similar that we can handle
128
+ if fp.lower().endswith('.xyz') or fp.lower().endswith('.extxyz'):
129
+ self.load_from_path(fp)
130
+
131
+ def load_from_path(self, file_path):
132
+ """
133
+ Loads the animated XYZ from the given file path.
134
+ """
135
+ try:
136
+ frames = self.parse_multi_frame_xyz(file_path)
137
+ if not frames:
138
+ QMessageBox.warning(self, "Error", "No valid frames found in XYZ file.")
139
+ return
140
+
141
+ self.frames = frames
142
+ self.current_frame_idx = 0
143
+ self.target_frame_idx = 0
144
+ self.lbl_file.setText(os.path.basename(file_path))
145
+ self.slider.setRange(0, len(frames) - 1)
146
+ self.slider.setValue(0)
147
+ self.slider.setEnabled(True)
148
+ self.btn_prev.setEnabled(True)
149
+ self.btn_play.setEnabled(True)
150
+
151
+ self.btn_next.setEnabled(True)
152
+ self.btn_save_gif.setEnabled(HAS_PIL)
153
+
154
+ self.create_base_molecule()
155
+ self.update_view()
156
+ self.update_status()
157
+
158
+ except Exception as e:
159
+ QMessageBox.critical(self, "Error", f"Failed to load file:\n{e}")
160
+
161
+ def load_file(self):
162
+ file_path, _ = QFileDialog.getOpenFileName(
163
+ self, "Open Animated XYZ", "", "XYZ Files (*.xyz);;All Files (*)"
164
+ )
165
+ if file_path:
166
+ self.load_from_path(file_path)
167
+
168
+ def parse_multi_frame_xyz(self, file_path):
169
+ """
170
+ Parses a concatenated XYZ file.
171
+ Returns a list of frames, where each frame is tuple of (atoms, coords).
172
+ Wait, we just need coordinates if topology is constant.
173
+ Let's store: [ {'symbols': [str], 'coords': [(x,y,z)]}, ... ]
174
+ """
175
+ frames = []
176
+ with open(file_path, 'r', encoding='utf-8') as f:
177
+ lines = f.readlines()
178
+
179
+ i = 0
180
+ n_lines = len(lines)
181
+ while i < n_lines:
182
+ line = lines[i].strip()
183
+ if not line:
184
+ i += 1
185
+ continue
186
+
187
+ try:
188
+ num_atoms = int(line)
189
+ except ValueError:
190
+ # Might be a blank line or garbage
191
+ i += 1
192
+ continue
193
+
194
+ # Start of a frame
195
+ # i = atom count
196
+ # i+1 = comment
197
+ # i+2 ... i+2+num_atoms = atoms
198
+
199
+ if i + 2 + num_atoms > n_lines:
200
+ break # Incomplete frame
201
+
202
+ comment = lines[i+1].strip()
203
+ frame_atoms = []
204
+ frame_coords = []
205
+
206
+ start_data = i + 2
207
+ for j in range(num_atoms):
208
+ parts = lines[start_data + j].split()
209
+ if len(parts) >= 4:
210
+ sym = parts[0]
211
+ try:
212
+ x = float(parts[1])
213
+ y = float(parts[2])
214
+ z = float(parts[3])
215
+ frame_atoms.append(sym)
216
+ frame_coords.append((x, y, z))
217
+ except ValueError:
218
+ pass
219
+
220
+ frames.append({
221
+ 'symbols': frame_atoms,
222
+ 'coords': frame_coords,
223
+ 'comment': comment
224
+ })
225
+
226
+ i = start_data + num_atoms
227
+
228
+ return frames
229
+
230
+ def create_base_molecule(self):
231
+ """
232
+ Creates the RDKit Mol object from the first frame and establishes topology.
233
+ """
234
+ if not self.frames:
235
+ return
236
+
237
+ frame0 = self.frames[0]
238
+ mol = Chem.RWMol()
239
+
240
+ # Add atoms
241
+ for sym in frame0['symbols']:
242
+ # Handle unknown symbols or numbers
243
+ try:
244
+ atom = Chem.Atom(sym)
245
+ except:
246
+ atom = Chem.Atom('C') # Fallback
247
+ mol.AddAtom(atom)
248
+
249
+ # Add conformer
250
+ conf = Chem.Conformer(mol.GetNumAtoms())
251
+ for idx, (x, y, z) in enumerate(frame0['coords']):
252
+ conf.SetAtomPosition(idx, rdGeometry.Point3D(x, y, z))
253
+ mol.AddConformer(conf)
254
+
255
+ # Estimate bonds (topology)
256
+ # We try to use the main window's helper function if available,
257
+ # otherwise we manually do simple distance check or leave it unconnected
258
+ if hasattr(self.mw, 'estimate_bonds_from_distances'):
259
+ self.mw.estimate_bonds_from_distances(mol)
260
+
261
+ self.base_mol = mol.GetMol()
262
+
263
+ # Set as current mol in main window so it can be drawn
264
+ self.mw.current_mol = self.base_mol
265
+
266
+ # Ensure 3D capabilities are on
267
+ if hasattr(self.mw, '_enter_3d_viewer_ui_mode'):
268
+ self.mw._enter_3d_viewer_ui_mode()
269
+
270
+ # Reset camera on first load
271
+ if hasattr(self.mw, 'plotter'):
272
+ self.mw.plotter.reset_camera()
273
+
274
+ def update_view(self):
275
+ """
276
+ Legacy entry point, now forwards to schedule_update
277
+ """
278
+ self.schedule_update()
279
+
280
+ def schedule_update(self):
281
+ """
282
+ Schedules a view update.
283
+ Prevents recursion/stacking if draw_molecule_3d calls processEvents.
284
+ """
285
+ if self.is_updating_view:
286
+ self.pending_update = True
287
+ return
288
+
289
+ # Start update process
290
+ self.is_updating_view = True
291
+ self.pending_update = False
292
+ self.do_effective_update()
293
+
294
+ def do_effective_update(self):
295
+ """
296
+ Performs the actual update and handles queued updates.
297
+ """
298
+ try:
299
+ while True:
300
+ # Update logic
301
+ if not self.frames or self.base_mol is None:
302
+ break
303
+
304
+ # Use target frame
305
+ self.current_frame_idx = self.target_frame_idx
306
+
307
+ if self.current_frame_idx >= len(self.frames):
308
+ self.current_frame_idx = 0
309
+
310
+ frame = self.frames[self.current_frame_idx]
311
+
312
+ # Update conformer positions
313
+ # Assuming topology (atom count/order) hasn't changed
314
+ conf = self.base_mol.GetConformer()
315
+ coords = frame['coords']
316
+
317
+ # Safety check for atom count mismatch
318
+ if len(coords) == self.base_mol.GetNumAtoms():
319
+ for idx, (x, y, z) in enumerate(coords):
320
+ conf.SetAtomPosition(idx, rdGeometry.Point3D(x, y, z))
321
+
322
+ # Redraw
323
+ # This calls main_window.draw_molecule_3d which might call processEvents
324
+ if hasattr(self.mw, 'draw_molecule_3d'):
325
+ self.mw.draw_molecule_3d(self.base_mol)
326
+ # Update frame comment/title if possible
327
+ if 'comment' in frame:
328
+ self.mw.statusBar().showMessage(f"Frame {self.current_frame_idx+1}/{len(self.frames)}: {frame['comment']}")
329
+
330
+ # Update Status label (without feedback loop)
331
+ self.update_status_silent()
332
+
333
+ # Check if pending
334
+ if not self.pending_update:
335
+ break
336
+
337
+ # If pending is True, it means schedule_update was called AGAIN
338
+ # (likely via processEvents inside draw_molecule_3d)
339
+ # so we loop again to draw the LATEST target_frame_idx.
340
+ self.pending_update = False
341
+
342
+ finally:
343
+ self.is_updating_view = False
344
+
345
+ def update_status_silent(self):
346
+ self.lbl_status.setText(f"Frame: {self.current_frame_idx + 1} / {len(self.frames)}")
347
+ self.slider.blockSignals(True)
348
+ self.slider.setValue(self.current_frame_idx)
349
+ self.slider.blockSignals(False)
350
+
351
+ def update_status(self):
352
+ # Calls the silent one
353
+ self.update_status_silent()
354
+
355
+ def on_slider_changed(self, value):
356
+ self.target_frame_idx = value
357
+ self.schedule_update()
358
+
359
+ def toggle_play(self):
360
+ self.is_playing = not self.is_playing
361
+ if self.is_playing:
362
+ self.btn_play.setText("Pause")
363
+ self.timer.start(int(1000 / self.fps))
364
+ else:
365
+ self.btn_play.setText("Play")
366
+ self.timer.stop()
367
+ # Ensure the main window knows this is the generic current molecule
368
+ # so the user can use File->Save As... to export the current frame.
369
+ self.mw.current_mol = self.base_mol
370
+
371
+ def next_frame(self):
372
+ self.target_frame_idx = (self.current_frame_idx + 1) % len(self.frames)
373
+ self.schedule_update()
374
+
375
+ def prev_frame(self):
376
+ self.target_frame_idx = (self.current_frame_idx - 1) % len(self.frames)
377
+ self.schedule_update()
378
+
379
+ def on_timer(self):
380
+ self.next_frame()
381
+
382
+ def set_fps(self, value):
383
+ self.fps = value
384
+ if self.is_playing:
385
+ self.timer.start(int(1000 / self.fps))
386
+
387
+ def save_as_gif(self):
388
+ if not self.frames:
389
+ return
390
+
391
+ # Pause if playing
392
+ was_playing = self.is_playing
393
+ if self.is_playing:
394
+ self.toggle_play()
395
+
396
+ # Dialog for settings
397
+ dialog = QDialog(self)
398
+ dialog.setWindowTitle("Export GIF Settings")
399
+ form = QFormLayout(dialog)
400
+
401
+ spin_fps = QSpinBox()
402
+ spin_fps.setRange(1, 60)
403
+ spin_fps.setValue(self.fps)
404
+
405
+ chk_transparent = QCheckBox()
406
+ chk_transparent.setChecked(True)
407
+
408
+
409
+ form.addRow("FPS:", spin_fps)
410
+ form.addRow("Transparent Background:", chk_transparent)
411
+
412
+ chk_loop = QCheckBox()
413
+ chk_loop.setChecked(True)
414
+ form.addRow("Loop Animation:", chk_loop)
415
+
416
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
417
+ btns.accepted.connect(dialog.accept)
418
+ btns.rejected.connect(dialog.reject)
419
+ form.addRow(btns)
420
+
421
+ if dialog.exec() != QDialog.DialogCode.Accepted:
422
+ if was_playing:
423
+ self.toggle_play()
424
+ return
425
+
426
+ target_fps = spin_fps.value()
427
+ use_transparent = chk_transparent.isChecked()
428
+ use_loop = chk_loop.isChecked()
429
+
430
+ # File Dialog
431
+ file_path, _ = QFileDialog.getSaveFileName(
432
+ self, "Save GIF", "", "GIF Files (*.gif)"
433
+ )
434
+ if not file_path:
435
+ if was_playing:
436
+ self.toggle_play()
437
+ return
438
+
439
+ if not file_path.lower().endswith('.gif'):
440
+ file_path += '.gif'
441
+
442
+ # Progress Dialog? Or just blocking cursor
443
+ self.setCursor(Qt.CursorShape.WaitCursor)
444
+
445
+ try:
446
+ original_frame_idx = self.current_frame_idx
447
+ images = []
448
+
449
+ for i in range(len(self.frames)):
450
+ self.target_frame_idx = i
451
+ self.do_effective_update()
452
+
453
+ # Force repaint of the main window view to ensure updated frame is rendered
454
+ # We need to access the plotter widget
455
+ if hasattr(self.mw, 'plotter'):
456
+ # This might update the view
457
+ self.mw.plotter.update()
458
+
459
+ # Check if we can get image
460
+ # screenshot(transparent_background=..., return_img=True)
461
+ img_array = self.mw.plotter.screenshot(transparent_background=use_transparent, return_img=True)
462
+
463
+ if img_array is not None:
464
+ img = Image.fromarray(img_array)
465
+ images.append(img)
466
+
467
+ # Save
468
+ if images:
469
+ # Prepare images for GIF
470
+ gif_frames = []
471
+ duration_ms = int(1000 / target_fps)
472
+
473
+ for img in images:
474
+ if use_transparent:
475
+ # Advanced transparency handling for GIF
476
+ # 1. Ensure RGBA
477
+ img = img.convert("RGBA")
478
+
479
+ # 2. Extract Alpha
480
+ alpha = img.split()[3]
481
+
482
+ # 3. Create a white background for quantization (optional, prevents halos)
483
+ # or just convert RGB to P directly.
484
+ # We'll stick to standard quantize.
485
+ # map alpha to binary mask
486
+ mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
487
+
488
+ # 4. Quantize to 255 colors (leaving 1 for true transparency)
489
+ img_p = img.convert("RGB").quantize(colors=255)
490
+
491
+ # 5. Paste the transparent color index (255) into transparent regions
492
+ img_p.paste(255, mask)
493
+
494
+ gif_frames.append(img_p)
495
+ else:
496
+ gif_frames.append(img)
497
+
498
+ # Save
499
+ save_params = {
500
+ "save_all": True,
501
+ "append_images": gif_frames[1:],
502
+ "duration": duration_ms,
503
+ "disposal": 2,
504
+ }
505
+
506
+ if use_transparent:
507
+ save_params["transparency"] = 255
508
+
509
+ if use_loop:
510
+ # Pillow: 0 means infinite. If omitted, no Netscape loop block (plays once).
511
+ save_params["loop"] = 0
512
+
513
+ gif_frames[0].save(file_path, **save_params)
514
+ QMessageBox.information(self, "Success", f"Saved GIF to:\n{file_path}")
515
+ else:
516
+ QMessageBox.warning(self, "Error", "Failed to capture frames.")
517
+
518
+ # Restore
519
+ self.target_frame_idx = original_frame_idx
520
+ self.do_effective_update()
521
+
522
+ except Exception as e:
523
+ QMessageBox.critical(self, "Error", f"Failed to save GIF:\n{e}")
524
+ finally:
525
+ self.setCursor(Qt.CursorShape.ArrowCursor)
526
+ if was_playing:
527
+ self.toggle_play()
528
+
529
+ def closeEvent(self, event):
530
+ self.timer.stop()
531
+
532
+ '''
533
+
534
+ # Clear the main window view
535
+ try:
536
+ if hasattr(self.mw, 'plotter'):
537
+ self.mw.plotter.clear()
538
+ except:
539
+ pass
540
+
541
+ try:
542
+ self.mw.current_mol = None
543
+ except:
544
+ pass
545
+
546
+ # Exit 3D mode and restore 2D editor UI
547
+ # We try calling it directly.
548
+ try:
549
+ self.mw.restore_ui_for_editing()
550
+ except Exception as e:
551
+ print(f"Error restoring UI: {e}")
552
+
553
+ # Force a re-render/clear of the generic 3D draw function
554
+ try:
555
+ self.mw.draw_molecule_3d(None)
556
+ except:
557
+ pass
558
+
559
+
560
+ # Remove reference from main window so next run starts fresh check
561
+ if hasattr(self.mw, '_plugin_animated_xyz_player'):
562
+ del self.mw._plugin_animated_xyz_player
563
+ '''
564
+
565
+ super().closeEvent(event)
566
+
567
+ def run(mw):
568
+ # Always close/destroy old instance to reset variables and state
569
+ if hasattr(mw, '_plugin_animated_xyz_player'):
570
+ try:
571
+ mw._plugin_animated_xyz_player.close()
572
+ except:
573
+ pass
574
+ # Depending on if closeEvent did its job or not, strictly remove ref
575
+ if hasattr(mw, '_plugin_animated_xyz_player'):
576
+ del mw._plugin_animated_xyz_player
577
+
578
+ # Create fresh instance
579
+ player = AnimatedXYZPlayer(mw)
580
+ mw._plugin_animated_xyz_player = player
581
+ player.show()
582
+ player.raise_()
583
+ player.activateWindow()