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.
- moleditpy/modules/constants.py +1 -1
- moleditpy/modules/main_window_main_init.py +73 -103
- moleditpy/modules/plugin_manager.py +10 -0
- moleditpy/plugins/Analysis/ms_spectrum_neo.py +919 -0
- moleditpy/plugins/File/animated_xyz_giffer.py +583 -0
- moleditpy/plugins/File/cube_viewer.py +689 -0
- moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +1148 -0
- moleditpy/plugins/File/mapped_cube_viewer.py +552 -0
- moleditpy/plugins/File/orca_out_freq_analyzer.py +1226 -0
- moleditpy/plugins/File/paste_xyz.py +336 -0
- moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +930 -0
- moleditpy/plugins/Input Generator/orca_input_generator_neo.py +1028 -0
- moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +286 -0
- moleditpy/plugins/Optimization/all-trans_optimizer.py +65 -0
- moleditpy/plugins/Optimization/complex_molecule_untangler.py +268 -0
- moleditpy/plugins/Optimization/conf_search.py +224 -0
- moleditpy/plugins/Utility/atom_colorizer.py +547 -0
- moleditpy/plugins/Utility/console.py +163 -0
- moleditpy/plugins/Utility/pubchem_ressolver.py +244 -0
- moleditpy/plugins/Utility/vdw_radii_overlay.py +303 -0
- {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/METADATA +1 -1
- {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/RECORD +26 -9
- {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/WHEEL +0 -0
- {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/entry_points.txt +0 -0
- {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/licenses/LICENSE +0 -0
- {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pyvista as pv
|
|
3
|
+
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
|
4
|
+
QLabel, QColorDialog, QDockWidget, QMessageBox,
|
|
5
|
+
QLineEdit, QListWidget, QAbstractItemView, QGroupBox, QDialog)
|
|
6
|
+
from PyQt6.QtGui import QColor, QCloseEvent
|
|
7
|
+
from PyQt6.QtCore import Qt
|
|
8
|
+
import traceback
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
import functools # Added for polite patching
|
|
13
|
+
|
|
14
|
+
# Try importing from the installed package first (pip package structure)
|
|
15
|
+
try:
|
|
16
|
+
from moleditpy.modules.constants import CPK_COLORS_PV
|
|
17
|
+
except ImportError:
|
|
18
|
+
# Fallback to local 'modules' if running from source or sys.path is set that way
|
|
19
|
+
try:
|
|
20
|
+
from modules.constants import CPK_COLORS_PV
|
|
21
|
+
except ImportError:
|
|
22
|
+
# Final fallback map
|
|
23
|
+
CPK_COLORS_PV = {}
|
|
24
|
+
|
|
25
|
+
__version__="2025.12.25"
|
|
26
|
+
__author__="HiroYokoyama"
|
|
27
|
+
|
|
28
|
+
PLUGIN_NAME = "Atom Colorizer"
|
|
29
|
+
|
|
30
|
+
class AtomColorizerWindow(QDialog):
|
|
31
|
+
def __init__(self, main_window):
|
|
32
|
+
super().__init__(parent=main_window)
|
|
33
|
+
self.mw = main_window
|
|
34
|
+
# self.dock = dock_widget # Removed as per instruction
|
|
35
|
+
self.plotter = self.mw.plotter
|
|
36
|
+
|
|
37
|
+
# Set window properties for modeless behavior
|
|
38
|
+
self.setModal(False)
|
|
39
|
+
self.setWindowTitle(PLUGIN_NAME)
|
|
40
|
+
self.setWindowFlags(Qt.WindowType.Window) # Ensures it has min/max/close buttons
|
|
41
|
+
|
|
42
|
+
# Initialize current_color as QColor object
|
|
43
|
+
self.current_color = QColor(255, 0, 0) # Default red
|
|
44
|
+
|
|
45
|
+
self.init_ui()
|
|
46
|
+
|
|
47
|
+
# Auto-enable 3D Selection (Measurement Mode) if not already active
|
|
48
|
+
try:
|
|
49
|
+
if hasattr(self.mw, 'measurement_mode') and not self.mw.measurement_mode:
|
|
50
|
+
if hasattr(self.mw, 'toggle_measurement_mode'):
|
|
51
|
+
self.mw.toggle_measurement_mode(True)
|
|
52
|
+
# Sync UI button state if possible
|
|
53
|
+
if hasattr(self.mw, 'measurement_action'):
|
|
54
|
+
self.mw.measurement_action.setChecked(True)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"Failed to auto-enable 3D selection: {e}")
|
|
57
|
+
|
|
58
|
+
def init_ui(self):
|
|
59
|
+
layout = QVBoxLayout()
|
|
60
|
+
|
|
61
|
+
# Information Label
|
|
62
|
+
info_label = QLabel("Select atoms in the 3D viewer and apply color.")
|
|
63
|
+
info_label.setWordWrap(True)
|
|
64
|
+
layout.addWidget(info_label)
|
|
65
|
+
|
|
66
|
+
# Selection Group
|
|
67
|
+
sel_group = QGroupBox("Selection")
|
|
68
|
+
sel_layout = QVBoxLayout()
|
|
69
|
+
|
|
70
|
+
self.le_indices = QLineEdit()
|
|
71
|
+
self.le_indices.setPlaceholderText("e.g. 0, 1, 5")
|
|
72
|
+
sel_layout.addWidget(self.le_indices)
|
|
73
|
+
|
|
74
|
+
# 'Get Selection' button removed as per user request (auto-update is active)
|
|
75
|
+
|
|
76
|
+
# Auto-update timer
|
|
77
|
+
from PyQt6.QtCore import QTimer
|
|
78
|
+
self.sel_timer = QTimer(self)
|
|
79
|
+
self.sel_timer.timeout.connect(self._auto_update_selection)
|
|
80
|
+
self.sel_timer.start(200) # Check every 200ms
|
|
81
|
+
|
|
82
|
+
sel_group.setLayout(sel_layout)
|
|
83
|
+
layout.addWidget(sel_group)
|
|
84
|
+
|
|
85
|
+
# Color Group
|
|
86
|
+
col_group = QGroupBox("Color")
|
|
87
|
+
col_layout = QVBoxLayout()
|
|
88
|
+
|
|
89
|
+
self.btn_color = QPushButton("Choose Color")
|
|
90
|
+
# Initial style based on self.current_color (QColor object)
|
|
91
|
+
self.btn_color.setStyleSheet(f"background-color: {self.current_color.name()}; color: {'black' if self.current_color.lightness() > 128 else 'white'};")
|
|
92
|
+
self.btn_color.clicked.connect(self.choose_color)
|
|
93
|
+
col_layout.addWidget(self.btn_color)
|
|
94
|
+
|
|
95
|
+
btn_apply = QPushButton("Apply Color")
|
|
96
|
+
btn_apply.clicked.connect(self.apply_color)
|
|
97
|
+
col_layout.addWidget(btn_apply)
|
|
98
|
+
|
|
99
|
+
col_group.setLayout(col_layout)
|
|
100
|
+
layout.addWidget(col_group)
|
|
101
|
+
|
|
102
|
+
# Reset Group
|
|
103
|
+
reset_group = QGroupBox("Reset")
|
|
104
|
+
reset_layout = QVBoxLayout()
|
|
105
|
+
|
|
106
|
+
btn_reset = QPushButton("Reset to Element Colors")
|
|
107
|
+
btn_reset.clicked.connect(self.reset_colors)
|
|
108
|
+
reset_layout.addWidget(btn_reset)
|
|
109
|
+
|
|
110
|
+
reset_group.setLayout(reset_layout)
|
|
111
|
+
layout.addWidget(reset_group)
|
|
112
|
+
|
|
113
|
+
layout.addStretch()
|
|
114
|
+
|
|
115
|
+
# Close Button
|
|
116
|
+
close_btn = QPushButton("Close")
|
|
117
|
+
close_btn.clicked.connect(self.close)
|
|
118
|
+
layout.addWidget(close_btn)
|
|
119
|
+
|
|
120
|
+
self.setLayout(layout)
|
|
121
|
+
|
|
122
|
+
# Resize window to a reasonable default
|
|
123
|
+
self.resize(300, 400)
|
|
124
|
+
|
|
125
|
+
def get_selection_from_viewer(self):
|
|
126
|
+
"""
|
|
127
|
+
Get selected atom indices from the main window.
|
|
128
|
+
Only checks 3D selection and Measurement selection. 2D selection is ignored per request.
|
|
129
|
+
"""
|
|
130
|
+
indices = set()
|
|
131
|
+
|
|
132
|
+
# 1. Check direct 3D selection (e.g. from 3D Drag or specific 3D select tools)
|
|
133
|
+
if hasattr(self.mw, 'selected_atoms_3d') and self.mw.selected_atoms_3d:
|
|
134
|
+
indices.update(self.mw.selected_atoms_3d)
|
|
135
|
+
|
|
136
|
+
# 2. Check measurement selection (commonly used for picking atoms in 3D)
|
|
137
|
+
if hasattr(self.mw, 'selected_atoms_for_measurement') and self.mw.selected_atoms_for_measurement:
|
|
138
|
+
# selected_atoms_for_measurement might be list of int or objects, typically ints in this internal API
|
|
139
|
+
for item in self.mw.selected_atoms_for_measurement:
|
|
140
|
+
if isinstance(item, int):
|
|
141
|
+
indices.add(item)
|
|
142
|
+
|
|
143
|
+
# 2D Selection logic removed as per request ("2Dはいらない")
|
|
144
|
+
|
|
145
|
+
if not indices:
|
|
146
|
+
# Silent return if auto-updating, or maybe clear?
|
|
147
|
+
# If we invoke manually, we might want info, but generic message is okay if list is empty.
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# Update the line edit
|
|
151
|
+
sorted_indices = sorted(list(indices))
|
|
152
|
+
new_text = ",".join(map(str, sorted_indices))
|
|
153
|
+
if self.le_indices.text() != new_text:
|
|
154
|
+
self.le_indices.setText(new_text)
|
|
155
|
+
|
|
156
|
+
def _auto_update_selection(self):
|
|
157
|
+
"""Timer slot to auto-update selection."""
|
|
158
|
+
# Only update if the user is not actively typing?
|
|
159
|
+
# For now, just call get_selection_from_viewer which now checks for changes before setting text.
|
|
160
|
+
# However, checking if le_indices has focus might be good.
|
|
161
|
+
if self.le_indices.hasFocus():
|
|
162
|
+
return
|
|
163
|
+
self.get_selection_from_viewer()
|
|
164
|
+
|
|
165
|
+
def choose_color(self):
|
|
166
|
+
c = QColorDialog.getColor(initial=self.current_color, title="Select Color")
|
|
167
|
+
if c.isValid():
|
|
168
|
+
self.current_color = c
|
|
169
|
+
# Update button style
|
|
170
|
+
self.btn_color.setStyleSheet(f"background-color: {c.name()}; color: {'black' if c.lightness() > 128 else 'white'};")
|
|
171
|
+
|
|
172
|
+
def _update_3d_actor(self):
|
|
173
|
+
"""Re-generate glyphs and update the actor to reflect color changes."""
|
|
174
|
+
try:
|
|
175
|
+
# 1. Re-run glyph filter to propagate color changes from glyph_source to mesh
|
|
176
|
+
if hasattr(self.mw, 'glyph_source') and self.mw.glyph_source:
|
|
177
|
+
# Read resolution from settings or default
|
|
178
|
+
try:
|
|
179
|
+
style = self.mw.current_3d_style
|
|
180
|
+
if style == 'cpk':
|
|
181
|
+
resolution = self.mw.settings.get('cpk_resolution', 32)
|
|
182
|
+
elif style == 'stick':
|
|
183
|
+
resolution = self.mw.settings.get('stick_resolution', 16)
|
|
184
|
+
else: # ball_stick
|
|
185
|
+
resolution = self.mw.settings.get('ball_stick_resolution', 16)
|
|
186
|
+
except Exception:
|
|
187
|
+
resolution = 16
|
|
188
|
+
|
|
189
|
+
glyphs = self.mw.glyph_source.glyph(
|
|
190
|
+
scale='radii',
|
|
191
|
+
geom=pv.Sphere(radius=1.0, theta_resolution=resolution, phi_resolution=resolution),
|
|
192
|
+
orient=False
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# 2. Update the actor
|
|
196
|
+
if hasattr(self.mw, 'atom_actor') and self.mw.atom_actor:
|
|
197
|
+
self.mw.plotter.remove_actor(self.mw.atom_actor)
|
|
198
|
+
|
|
199
|
+
# Re-add mesh (copying properties logic from main_window_view_3d roughly)
|
|
200
|
+
is_lighting_enabled = self.mw.settings.get('lighting_enabled', True)
|
|
201
|
+
mesh_props = dict(
|
|
202
|
+
smooth_shading=True,
|
|
203
|
+
specular=self.mw.settings.get('specular', 0.2),
|
|
204
|
+
specular_power=self.mw.settings.get('specular_power', 20),
|
|
205
|
+
lighting=is_lighting_enabled,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
self.mw.atom_actor = self.mw.plotter.add_mesh(
|
|
209
|
+
glyphs, scalars='colors', rgb=True, **mesh_props
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self.mw.plotter.render()
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f"Error updating 3D actor: {e}")
|
|
215
|
+
traceback.print_exc()
|
|
216
|
+
|
|
217
|
+
def apply_color(self):
|
|
218
|
+
txt = self.le_indices.text().strip()
|
|
219
|
+
if not txt:
|
|
220
|
+
QMessageBox.warning(self, "Warning", "No atoms selected. Please select atoms in the 3D viewer first.")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
str_indices = [x.strip() for x in txt.split(',') if x.strip()]
|
|
225
|
+
target_indices = [int(x) for x in str_indices]
|
|
226
|
+
except ValueError:
|
|
227
|
+
QMessageBox.warning(self, "Error", "Invalid indices format.")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if not hasattr(self.mw, 'glyph_source') or self.mw.glyph_source is None:
|
|
231
|
+
QMessageBox.warning(self, "Error", "No 3D molecule found (glyph_source is None).")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# 1. Update glyph_source colors
|
|
236
|
+
try:
|
|
237
|
+
if not hasattr(self.mw, 'custom_atom_colors'):
|
|
238
|
+
self.mw.custom_atom_colors = {}
|
|
239
|
+
|
|
240
|
+
colors = self.mw.glyph_source.point_data['colors']
|
|
241
|
+
|
|
242
|
+
# Helper to normalize color to whatever format 'colors' is using
|
|
243
|
+
r, g, b = self.current_color.red(), self.current_color.green(), self.current_color.blue()
|
|
244
|
+
|
|
245
|
+
# Store simple 0-255 list for persistence to avoid numpy type issues in JSON
|
|
246
|
+
stored_color = [r, g, b]
|
|
247
|
+
|
|
248
|
+
# Check if colors are float (0-1) or uint8 (0-255)
|
|
249
|
+
is_float = (colors.dtype.kind == 'f')
|
|
250
|
+
|
|
251
|
+
new_color_val = [r/255.0, g/255.0, b/255.0] if is_float else [r, g, b]
|
|
252
|
+
|
|
253
|
+
for idx in target_indices:
|
|
254
|
+
if 0 <= idx < len(colors):
|
|
255
|
+
colors[idx] = new_color_val
|
|
256
|
+
# Update persistent storage
|
|
257
|
+
self.mw.custom_atom_colors[idx] = stored_color
|
|
258
|
+
|
|
259
|
+
# 2. Force update of the actor
|
|
260
|
+
self._update_3d_actor()
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
# QMessageBox.critical(self, "Error", f"Failed to apply color: {e}")
|
|
264
|
+
traceback.print_exc()
|
|
265
|
+
|
|
266
|
+
def reset_colors(self):
|
|
267
|
+
if not hasattr(self.mw, 'glyph_source') or self.mw.glyph_source is None:
|
|
268
|
+
return
|
|
269
|
+
if not self.mw.current_mol:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
# Clear persistent storage
|
|
274
|
+
if hasattr(self.mw, 'custom_atom_colors'):
|
|
275
|
+
self.mw.custom_atom_colors = {}
|
|
276
|
+
|
|
277
|
+
colors = self.mw.glyph_source.point_data['colors']
|
|
278
|
+
is_float = (colors.dtype.kind == 'f')
|
|
279
|
+
|
|
280
|
+
# Iterate atoms and reset to CPK
|
|
281
|
+
for i in range(self.mw.current_mol.GetNumAtoms()):
|
|
282
|
+
atom = self.mw.current_mol.GetAtomWithIdx(i)
|
|
283
|
+
sym = atom.GetSymbol()
|
|
284
|
+
# Get default color (float 0-1)
|
|
285
|
+
base_col = CPK_COLORS_PV.get(sym, [0.5, 0.5, 0.5])
|
|
286
|
+
|
|
287
|
+
if is_float:
|
|
288
|
+
colors[i] = base_col
|
|
289
|
+
else:
|
|
290
|
+
colors[i] = [int(c*255) for c in base_col]
|
|
291
|
+
|
|
292
|
+
self._update_3d_actor()
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
QMessageBox.critical(self, "Error", f"Failed to reset colors: {e}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _restore_colors_from_file(self):
|
|
299
|
+
"""
|
|
300
|
+
Check if the main window has a valid .pmeprj file open.
|
|
301
|
+
If so, read it manually to find 'custom_atom_colors' and apply them.
|
|
302
|
+
This handles the case where the file was loaded *before* this plugin started.
|
|
303
|
+
"""
|
|
304
|
+
# If no file path or not a .pmeprj, ignore
|
|
305
|
+
if not hasattr(self.mw, 'current_file_path') or not self.mw.current_file_path:
|
|
306
|
+
return
|
|
307
|
+
if not self.mw.current_file_path.lower().endswith('.pmeprj'):
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
# If we already have colors (unlikely if plugin just started, unless double-patched), skip
|
|
311
|
+
if hasattr(self.mw, 'custom_atom_colors') and self.mw.custom_atom_colors:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
with open(self.mw.current_file_path, 'r', encoding='utf-8') as f:
|
|
316
|
+
data = json.load(f)
|
|
317
|
+
|
|
318
|
+
if "3d_structure" in data and data["3d_structure"]:
|
|
319
|
+
raw_colors = data["3d_structure"].get("custom_atom_colors")
|
|
320
|
+
if raw_colors:
|
|
321
|
+
custom_colors = {int(k): v for k, v in raw_colors.items()}
|
|
322
|
+
|
|
323
|
+
# Apply to MainWindow
|
|
324
|
+
self.mw.custom_atom_colors = custom_colors
|
|
325
|
+
|
|
326
|
+
# Force update of 3D actor
|
|
327
|
+
# We might need to ensure glyph_source is ready; assuming file load populated it.
|
|
328
|
+
if hasattr(self.mw, 'glyph_source') and self.mw.glyph_source:
|
|
329
|
+
# We need to manually inject these colors into the polydata
|
|
330
|
+
colors = self.mw.glyph_source.point_data['colors']
|
|
331
|
+
is_float = (colors.dtype.kind == 'f')
|
|
332
|
+
|
|
333
|
+
for idx, col_val in custom_colors.items():
|
|
334
|
+
if 0 <= idx < len(colors):
|
|
335
|
+
if is_float:
|
|
336
|
+
# If stored as 0-255 but buffer is float 0-1
|
|
337
|
+
if any(c > 1.0 for c in col_val):
|
|
338
|
+
colors[idx] = [c/255.0 for c in col_val]
|
|
339
|
+
else:
|
|
340
|
+
colors[idx] = col_val
|
|
341
|
+
else:
|
|
342
|
+
# If stored as float 0-1 but buffer is uint8
|
|
343
|
+
if all(c <= 1.0 for c in col_val):
|
|
344
|
+
colors[idx] = [int(c*255) for c in col_val]
|
|
345
|
+
else:
|
|
346
|
+
colors[idx] = col_val
|
|
347
|
+
|
|
348
|
+
self._update_3d_actor()
|
|
349
|
+
print(f"Atom Colorizer: Restored {len(custom_colors)} custom colors from file.")
|
|
350
|
+
except Exception as e:
|
|
351
|
+
print(f"Atom Colorizer: Failed to lazy-load colors from file: {e}")
|
|
352
|
+
traceback.print_exc()
|
|
353
|
+
|
|
354
|
+
# Global reference to keep window alive
|
|
355
|
+
_atom_colorizer_window = None
|
|
356
|
+
_patches_installed = False
|
|
357
|
+
|
|
358
|
+
def run(mw):
|
|
359
|
+
global _atom_colorizer_window, _patches_installed
|
|
360
|
+
|
|
361
|
+
# Check if this is the first run (patches not installed)
|
|
362
|
+
first_run = not _patches_installed
|
|
363
|
+
|
|
364
|
+
# Install patches for persistence
|
|
365
|
+
install_patches(mw)
|
|
366
|
+
|
|
367
|
+
global _atom_colorizer_window
|
|
368
|
+
# Check if window already exists
|
|
369
|
+
if _atom_colorizer_window is None:
|
|
370
|
+
_atom_colorizer_window = AtomColorizerWindow(mw)
|
|
371
|
+
# Handle cleanup when window is closed
|
|
372
|
+
_atom_colorizer_window.finished.connect(lambda: _cleanup_window())
|
|
373
|
+
|
|
374
|
+
# Only restore from file if this is the first execution
|
|
375
|
+
if first_run:
|
|
376
|
+
_atom_colorizer_window._restore_colors_from_file()
|
|
377
|
+
|
|
378
|
+
_atom_colorizer_window.show()
|
|
379
|
+
_atom_colorizer_window.raise_()
|
|
380
|
+
_atom_colorizer_window.activateWindow()
|
|
381
|
+
|
|
382
|
+
# initialize removed as it only registered the menu action
|
|
383
|
+
|
|
384
|
+
def _cleanup_window():
|
|
385
|
+
global _atom_colorizer_window
|
|
386
|
+
_atom_colorizer_window = None
|
|
387
|
+
|
|
388
|
+
def install_patches(mw):
|
|
389
|
+
"""
|
|
390
|
+
Install monkey patches to core modules to ensure color persistence.
|
|
391
|
+
Checks `_patches_installed` to avoid double patching.
|
|
392
|
+
"""
|
|
393
|
+
global _patches_installed
|
|
394
|
+
if _patches_installed:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# Initialize persistent storage on MainWindow if not present
|
|
398
|
+
if not hasattr(mw, 'custom_atom_colors'):
|
|
399
|
+
mw.custom_atom_colors = {}
|
|
400
|
+
|
|
401
|
+
# --- Patch 1: MainWindowView3d.draw_molecule_3d ---
|
|
402
|
+
# Purpose: Re-apply colors after any redraw (e.g. style change, molecular edit)
|
|
403
|
+
# We patch the instance method on 'mw' which is the entry point for other modules.
|
|
404
|
+
|
|
405
|
+
original_draw_3d = mw.draw_molecule_3d
|
|
406
|
+
|
|
407
|
+
def patched_draw_3d(mol):
|
|
408
|
+
# Call original
|
|
409
|
+
res = original_draw_3d(mol)
|
|
410
|
+
|
|
411
|
+
# Apply custom colors if they exist
|
|
412
|
+
if hasattr(mw, 'custom_atom_colors') and mw.custom_atom_colors and hasattr(mw, 'glyph_source') and mw.glyph_source:
|
|
413
|
+
try:
|
|
414
|
+
import pyvista as pv # Ensure pyvista is available inside closure if needed
|
|
415
|
+
|
|
416
|
+
colors = mw.glyph_source.point_data['colors']
|
|
417
|
+
is_float = (colors.dtype.kind == 'f')
|
|
418
|
+
|
|
419
|
+
# 1. Update the source colors
|
|
420
|
+
for idx, col_val in mw.custom_atom_colors.items():
|
|
421
|
+
if isinstance(idx, str): idx = int(idx)
|
|
422
|
+
# Check index bounds
|
|
423
|
+
if 0 <= idx < len(colors):
|
|
424
|
+
if is_float:
|
|
425
|
+
if any(c > 1.0 for c in col_val):
|
|
426
|
+
colors[idx] = [c/255.0 for c in col_val]
|
|
427
|
+
else:
|
|
428
|
+
colors[idx] = col_val
|
|
429
|
+
else:
|
|
430
|
+
if all(c <= 1.0 for c in col_val):
|
|
431
|
+
colors[idx] = [int(c*255) for c in col_val]
|
|
432
|
+
else:
|
|
433
|
+
colors[idx] = col_val
|
|
434
|
+
|
|
435
|
+
# 2. Re-generate the actor (Glyph filter)
|
|
436
|
+
# Mimic common 3D view logic to respect resolution settings
|
|
437
|
+
try:
|
|
438
|
+
style = getattr(mw, 'current_3d_style', 'cpk')
|
|
439
|
+
if style == 'cpk':
|
|
440
|
+
resolution = mw.settings.get('cpk_resolution', 32)
|
|
441
|
+
elif style == 'stick':
|
|
442
|
+
resolution = mw.settings.get('stick_resolution', 16)
|
|
443
|
+
else: # ball_stick
|
|
444
|
+
resolution = mw.settings.get('ball_stick_resolution', 16)
|
|
445
|
+
except Exception:
|
|
446
|
+
resolution = 16
|
|
447
|
+
|
|
448
|
+
glyphs = mw.glyph_source.glyph(
|
|
449
|
+
scale='radii',
|
|
450
|
+
geom=pv.Sphere(radius=1.0, theta_resolution=resolution, phi_resolution=resolution),
|
|
451
|
+
orient=False
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Remove old actor
|
|
455
|
+
if hasattr(mw, 'atom_actor') and mw.atom_actor:
|
|
456
|
+
mw.plotter.remove_actor(mw.atom_actor)
|
|
457
|
+
|
|
458
|
+
# Add new actor
|
|
459
|
+
is_lighting_enabled = mw.settings.get('lighting_enabled', True)
|
|
460
|
+
mesh_props = dict(
|
|
461
|
+
smooth_shading=True,
|
|
462
|
+
specular=mw.settings.get('specular', 0.2),
|
|
463
|
+
specular_power=mw.settings.get('specular_power', 20),
|
|
464
|
+
lighting=is_lighting_enabled,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
mw.atom_actor = mw.plotter.add_mesh(
|
|
468
|
+
glyphs, scalars='colors', rgb=True, **mesh_props
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Force render
|
|
472
|
+
if hasattr(mw, 'plotter'):
|
|
473
|
+
mw.plotter.render()
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
print(f"Patched draw_3d error: {e}")
|
|
477
|
+
traceback.print_exc()
|
|
478
|
+
return res
|
|
479
|
+
|
|
480
|
+
mw.draw_molecule_3d = patched_draw_3d
|
|
481
|
+
|
|
482
|
+
# --- Patch 2: MainWindowAppState.create_json_data ---
|
|
483
|
+
# Purpose: Save colors to .pmeprj
|
|
484
|
+
|
|
485
|
+
original_create_json = mw.create_json_data
|
|
486
|
+
|
|
487
|
+
def patched_create_json():
|
|
488
|
+
data = original_create_json()
|
|
489
|
+
if hasattr(mw, 'custom_atom_colors') and mw.custom_atom_colors:
|
|
490
|
+
if "3d_structure" in data and data["3d_structure"]:
|
|
491
|
+
data["3d_structure"]["custom_atom_colors"] = mw.custom_atom_colors
|
|
492
|
+
return data
|
|
493
|
+
|
|
494
|
+
mw.create_json_data = patched_create_json
|
|
495
|
+
|
|
496
|
+
# --- Patch 3: MainWindowAppState.load_from_json_data ---
|
|
497
|
+
# Purpose: Load colors from .pmeprj
|
|
498
|
+
|
|
499
|
+
original_load_json = mw.load_from_json_data
|
|
500
|
+
|
|
501
|
+
def patched_load_json(json_data):
|
|
502
|
+
# Extract custom colors
|
|
503
|
+
custom_colors = {}
|
|
504
|
+
if "3d_structure" in json_data and json_data["3d_structure"]:
|
|
505
|
+
raw_colors = json_data["3d_structure"].get("custom_atom_colors")
|
|
506
|
+
if raw_colors:
|
|
507
|
+
# Ensure keys are ints (JSON keys are strings)
|
|
508
|
+
custom_colors = {int(k): v for k, v in raw_colors.items()}
|
|
509
|
+
|
|
510
|
+
# Set colors to mw BEFORE calling original logic
|
|
511
|
+
# (because original logic calls draw_molecule_3d, which uses our patch)
|
|
512
|
+
mw.custom_atom_colors = custom_colors
|
|
513
|
+
|
|
514
|
+
return original_load_json(json_data)
|
|
515
|
+
|
|
516
|
+
mw.load_from_json_data = patched_load_json
|
|
517
|
+
|
|
518
|
+
# --- Patch 4: MainWindow.clear_all (New / Clear) ---
|
|
519
|
+
# Purpose: Reset colors when starting fresh
|
|
520
|
+
|
|
521
|
+
original_clear_all = mw.clear_all
|
|
522
|
+
|
|
523
|
+
@functools.wraps(original_clear_all)
|
|
524
|
+
def patched_clear_all():
|
|
525
|
+
# Reset colors BEFORE clearing logic
|
|
526
|
+
if hasattr(mw, 'custom_atom_colors'):
|
|
527
|
+
mw.custom_atom_colors = {}
|
|
528
|
+
return original_clear_all()
|
|
529
|
+
|
|
530
|
+
mw.clear_all = patched_clear_all
|
|
531
|
+
|
|
532
|
+
# --- Patch 5: MainWindow.trigger_conversion (2D -> 3D) ---
|
|
533
|
+
# Purpose: Reset colors when generating new 3D structure
|
|
534
|
+
|
|
535
|
+
original_trigger_conversion = mw.trigger_conversion
|
|
536
|
+
|
|
537
|
+
@functools.wraps(original_trigger_conversion)
|
|
538
|
+
def patched_trigger_conversion():
|
|
539
|
+
# Reset colors because structure is being regenerated
|
|
540
|
+
if hasattr(mw, 'custom_atom_colors'):
|
|
541
|
+
mw.custom_atom_colors = {}
|
|
542
|
+
return original_trigger_conversion()
|
|
543
|
+
|
|
544
|
+
mw.trigger_conversion = patched_trigger_conversion
|
|
545
|
+
|
|
546
|
+
_patches_installed = True
|
|
547
|
+
print("Atom Colorizer patches installed.")
|