MoleditPy 2.2.0a0__py3-none-any.whl → 2.2.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. moleditpy/modules/constants.py +1 -1
  2. moleditpy/modules/main_window_main_init.py +73 -103
  3. moleditpy/modules/plugin_manager.py +10 -0
  4. moleditpy/plugins/Analysis/ms_spectrum_neo.py +919 -0
  5. moleditpy/plugins/File/animated_xyz_giffer.py +583 -0
  6. moleditpy/plugins/File/cube_viewer.py +689 -0
  7. moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +1148 -0
  8. moleditpy/plugins/File/mapped_cube_viewer.py +552 -0
  9. moleditpy/plugins/File/orca_out_freq_analyzer.py +1226 -0
  10. moleditpy/plugins/File/paste_xyz.py +336 -0
  11. moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +930 -0
  12. moleditpy/plugins/Input Generator/orca_input_generator_neo.py +1028 -0
  13. moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +286 -0
  14. moleditpy/plugins/Optimization/all-trans_optimizer.py +65 -0
  15. moleditpy/plugins/Optimization/complex_molecule_untangler.py +268 -0
  16. moleditpy/plugins/Optimization/conf_search.py +224 -0
  17. moleditpy/plugins/Utility/atom_colorizer.py +547 -0
  18. moleditpy/plugins/Utility/console.py +163 -0
  19. moleditpy/plugins/Utility/pubchem_ressolver.py +244 -0
  20. moleditpy/plugins/Utility/vdw_radii_overlay.py +303 -0
  21. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/METADATA +1 -1
  22. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/RECORD +26 -9
  23. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/WHEEL +0 -0
  24. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/entry_points.txt +0 -0
  25. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/licenses/LICENSE +0 -0
  26. {moleditpy-2.2.0a0.dist-info → moleditpy-2.2.0a1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,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.")