MoleditPy 2.2.0a2__py3-none-any.whl → 2.2.0a3__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.
- moleditpy/modules/constants.py +1 -1
- moleditpy/modules/main_window_main_init.py +31 -13
- moleditpy/modules/plugin_interface.py +1 -10
- moleditpy/modules/plugin_manager.py +0 -3
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/METADATA +1 -1
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/RECORD +10 -27
- moleditpy/plugins/Analysis/ms_spectrum_neo.py +0 -919
- moleditpy/plugins/File/animated_xyz_giffer.py +0 -583
- moleditpy/plugins/File/cube_viewer.py +0 -689
- moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +0 -1148
- moleditpy/plugins/File/mapped_cube_viewer.py +0 -552
- moleditpy/plugins/File/orca_out_freq_analyzer.py +0 -1226
- moleditpy/plugins/File/paste_xyz.py +0 -336
- moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +0 -930
- moleditpy/plugins/Input Generator/orca_input_generator_neo.py +0 -1028
- moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +0 -286
- moleditpy/plugins/Optimization/all-trans_optimizer.py +0 -65
- moleditpy/plugins/Optimization/complex_molecule_untangler.py +0 -268
- moleditpy/plugins/Optimization/conf_search.py +0 -224
- moleditpy/plugins/Utility/atom_colorizer.py +0 -262
- moleditpy/plugins/Utility/console.py +0 -163
- moleditpy/plugins/Utility/pubchem_ressolver.py +0 -244
- moleditpy/plugins/Utility/vdw_radii_overlay.py +0 -432
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/WHEEL +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/entry_points.txt +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/licenses/LICENSE +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/top_level.txt +0 -0
|
@@ -1,583 +0,0 @@
|
|
|
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()
|