MoleditPy-linux 2.4.1__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 (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1242 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ """
14
+ main_window_compute.py
15
+ MainWindow (main_window.py) から分離されたモジュール
16
+ 機能クラス: MainWindowCompute
17
+ """
18
+
19
+
20
+
21
+
22
+ # RDKit imports (explicit to satisfy flake8 and used features)
23
+ from rdkit import Chem
24
+ from rdkit.Chem import AllChem
25
+ try:
26
+ pass
27
+ except Exception:
28
+ pass
29
+
30
+ # PyQt6 Modules
31
+ from PyQt6.QtWidgets import (
32
+ QApplication, QMenu
33
+ )
34
+
35
+ from PyQt6.QtGui import (
36
+ QColor, QAction
37
+ )
38
+
39
+
40
+ from PyQt6.QtCore import (
41
+ QThread, QTimer
42
+ )
43
+
44
+
45
+ # Use centralized Open Babel availability from package-level __init__
46
+ # Use per-package modules availability (local __init__).
47
+ try:
48
+ from . import OBABEL_AVAILABLE
49
+ except Exception:
50
+ from modules import OBABEL_AVAILABLE
51
+ # Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
52
+ if OBABEL_AVAILABLE:
53
+ try:
54
+ from openbabel import pybel
55
+ except Exception:
56
+ # If import fails here, disable OBABEL locally; avoid raising
57
+ pybel = None
58
+ OBABEL_AVAILABLE = False
59
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
60
+ else:
61
+ pybel = None
62
+
63
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
64
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
65
+ # it once at module import time and expose a small, robust wrapper so callers
66
+ # can avoid re-importing sip repeatedly and so we centralize exception
67
+ # handling (this reduces crash risk during teardown and deletion operations).
68
+ try:
69
+ import sip as _sip # type: ignore
70
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
71
+ except Exception:
72
+ _sip = None
73
+ _sip_isdeleted = None
74
+
75
+ try:
76
+ # package relative imports (preferred when running as `python -m moleditpy`)
77
+ from .calculation_worker import CalculationWorker
78
+ except Exception:
79
+ # Fallback to absolute imports for script-style execution
80
+ from modules.calculation_worker import CalculationWorker
81
+
82
+
83
+ # --- クラス定義 ---
84
+ class MainWindowCompute(object):
85
+ """ main_window.py から分離された機能クラス """
86
+
87
+
88
+ def set_optimization_method(self, method_name):
89
+ """Set preferred 3D optimization method and persist to settings.
90
+
91
+ Supported values: 'GAFF', 'MMFF'
92
+ """
93
+ # Normalize input and validate
94
+ if not method_name:
95
+ return
96
+ method = str(method_name).strip().upper()
97
+ valid_methods = (
98
+ 'MMFF_RDKIT', 'MMFF94_RDKIT', 'UFF_RDKIT',
99
+ 'UFF_OBABEL', 'GAFF_OBABEL', 'MMFF94_OBABEL', 'GHEMICAL_OBABEL'
100
+ )
101
+ if method not in valid_methods:
102
+ # Unknown method: ignore but notify
103
+ self.statusBar().showMessage(f"Unknown 3D optimization method: {method_name}")
104
+ return
105
+
106
+ # Update internal state (store canonical uppercase key)
107
+ self.optimization_method = method
108
+
109
+ # Persist to settings
110
+ try:
111
+ self.settings['optimization_method'] = self.optimization_method
112
+ try:
113
+ self.settings_dirty = True
114
+ except Exception:
115
+ pass
116
+ except Exception:
117
+ pass
118
+
119
+ # Update menu checked state if actions mapping exists
120
+ try:
121
+ if hasattr(self, 'opt3d_actions') and self.opt3d_actions:
122
+ for k, act in self.opt3d_actions.items():
123
+ try:
124
+ # keys in opt3d_actions may be mixed-case; compare uppercased
125
+ act.setChecked(k.upper() == method)
126
+ except Exception:
127
+ pass
128
+ except Exception:
129
+ pass
130
+
131
+ # Also show user-friendly label if available
132
+ try:
133
+ label = self.opt3d_method_labels.get(self.optimization_method, self.optimization_method)
134
+ except Exception:
135
+ label = self.optimization_method
136
+ self.statusBar().showMessage(f"3D optimization method set to: {label}")
137
+
138
+
139
+
140
+ def show_convert_menu(self, pos):
141
+ """右クリックで表示する一時的な3D変換メニュー。
142
+ 選択したモードは一時フラグとして保持され、その後の変換で使用されます(永続化しません)。
143
+ """
144
+ # If button is disabled (during calculation), do not show menu
145
+ if not self.convert_button.isEnabled():
146
+ return
147
+
148
+
149
+ try:
150
+ menu = QMenu(self)
151
+ conv_options = [
152
+ ("RDKit -> Open Babel (fallback)", 'fallback'),
153
+ ("RDKit only", 'rdkit'),
154
+ ("Open Babel only", 'obabel'),
155
+ ("Direct (use 2D coords + add H)", 'direct')
156
+ ]
157
+ for label, key in conv_options:
158
+ a = QAction(label, self)
159
+ # If Open Babel is not available, disable actions that depend on it
160
+ if key in ('obabel', 'fallback') and not globals().get('OBABEL_AVAILABLE', False):
161
+ a.setEnabled(False)
162
+ a.triggered.connect(lambda checked=False, k=key: self._trigger_conversion_with_temp_mode(k))
163
+ menu.addAction(a)
164
+
165
+ # Show menu at button position
166
+ menu.exec_(self.convert_button.mapToGlobal(pos))
167
+ except Exception as e:
168
+ print(f"Error showing convert menu: {e}")
169
+
170
+
171
+
172
+
173
+ def _trigger_conversion_with_temp_mode(self, mode_key):
174
+ try:
175
+ # store temporary override and invoke conversion
176
+ self._temp_conv_mode = mode_key
177
+ # Call the normal conversion entry point (it will consume the temp)
178
+ QTimer.singleShot(0, self.trigger_conversion)
179
+ except Exception as e:
180
+ print(f"Failed to start conversion with temp mode {mode_key}: {e}")
181
+
182
+
183
+
184
+
185
+ def show_optimize_menu(self, pos):
186
+ """右クリックで表示する一時的な3D最適化メニュー。
187
+ 選択したメソッドは一時フラグとして保持され、その後の最適化で使用されます(永続化しません)。
188
+ """
189
+ try:
190
+ menu = QMenu(self)
191
+ opt_list = [
192
+ ("MMFF94s", 'MMFF_RDKIT'),
193
+ ("MMFF94", 'MMFF94_RDKIT'),
194
+ ("UFF", 'UFF_RDKIT')
195
+ ]
196
+ for label, key in opt_list:
197
+ a = QAction(label, self)
198
+ # If opt3d_actions exist, reflect their enabled state
199
+ try:
200
+ if hasattr(self, 'opt3d_actions') and key in self.opt3d_actions:
201
+ a.setEnabled(self.opt3d_actions[key].isEnabled())
202
+ except Exception:
203
+ pass
204
+ a.triggered.connect(lambda checked=False, k=key: self._trigger_optimize_with_temp_method(k))
205
+ menu.addAction(a)
206
+
207
+ # Add Plugin Optimization Methods
208
+ if hasattr(self, 'plugin_manager') and self.plugin_manager.optimization_methods:
209
+ methods = self.plugin_manager.optimization_methods
210
+ if methods:
211
+ menu.addSeparator()
212
+ for method_name, info in methods.items():
213
+ a = QAction(info.get('label', method_name), self)
214
+ a.triggered.connect(lambda checked=False, k=method_name: self._trigger_optimize_with_temp_method(k))
215
+ menu.addAction(a)
216
+
217
+ menu.exec_(self.optimize_3d_button.mapToGlobal(pos))
218
+ except Exception as e:
219
+ print(f"Error showing optimize menu: {e}")
220
+
221
+
222
+
223
+
224
+ def _trigger_optimize_with_temp_method(self, method_key):
225
+ try:
226
+ # store temporary override and invoke optimization
227
+ self._temp_optimization_method = method_key
228
+ # Run optimize on next event loop turn so UI updates first
229
+ QTimer.singleShot(0, self.optimize_3d_structure)
230
+ except Exception as e:
231
+ print(f"Failed to start optimization with temp method {method_key}: {e}")
232
+
233
+
234
+
235
+ def trigger_conversion(self):
236
+ # Reset last successful optimization method at start of new conversion
237
+ self.last_successful_optimization_method = None
238
+
239
+ # 3D変換時に既存の3D制約をクリア
240
+ self.constraints_3d = []
241
+
242
+ # 2Dエディタに原子が存在しない場合は3Dビューをクリア
243
+ if not self.data.atoms:
244
+ self.plotter.clear()
245
+ self.current_mol = None
246
+ self.analysis_action.setEnabled(False)
247
+ self.statusBar().showMessage("3D view cleared.")
248
+ self.view_2d.setFocus()
249
+ return
250
+
251
+ # 描画モード変更時に測定モードと3D編集モードをリセット
252
+ if self.measurement_mode:
253
+ self.measurement_action.setChecked(False)
254
+ self.toggle_measurement_mode(False) # 測定モードを無効化
255
+ if self.is_3d_edit_mode:
256
+ self.edit_3d_action.setChecked(False)
257
+ self.toggle_3d_edit_mode(False) # 3D編集モードを無効化
258
+
259
+ mol = self.data.to_rdkit_mol(use_2d_stereo=False)
260
+
261
+ # 分子オブジェクトが作成できない場合でも化学的問題をチェック
262
+ if not mol or mol.GetNumAtoms() == 0:
263
+ # RDKitでの変換に失敗した場合は、独自の化学的問題チェックを実行
264
+ self.check_chemistry_problems_fallback()
265
+ return
266
+
267
+ # 原子プロパティを保存(ワーカープロセスで失われるため)
268
+ self.original_atom_properties = {}
269
+ for i in range(mol.GetNumAtoms()):
270
+ atom = mol.GetAtomWithIdx(i)
271
+ try:
272
+ original_id = atom.GetIntProp("_original_atom_id")
273
+ self.original_atom_properties[i] = original_id
274
+ except KeyError:
275
+ pass
276
+
277
+ problems = Chem.DetectChemistryProblems(mol)
278
+ if problems:
279
+ # 化学的問題が見つかった場合は既存のフラグをクリアしてから新しい問題を表示
280
+ self.scene.clear_all_problem_flags()
281
+ self.statusBar().showMessage(f"Error: {len(problems)} chemistry problem(s) found.")
282
+ # 既存の選択状態をクリア
283
+ self.scene.clearSelection()
284
+
285
+ # 問題のある原子に赤枠フラグを立てる
286
+ for prob in problems:
287
+ atom_idx = prob.GetAtomIdx()
288
+ rdkit_atom = mol.GetAtomWithIdx(atom_idx)
289
+ # エディタ側での原子IDの取得と存在確認
290
+ if rdkit_atom.HasProp("_original_atom_id"):
291
+ original_id = rdkit_atom.GetIntProp("_original_atom_id")
292
+ if original_id in self.data.atoms and self.data.atoms[original_id]['item']:
293
+ item = self.data.atoms[original_id]['item']
294
+ item.has_problem = True
295
+ item.update()
296
+
297
+ self.view_2d.setFocus()
298
+ return
299
+
300
+ # 化学的問題がない場合のみフラグをクリアして3D変換を実行
301
+ self.scene.clear_all_problem_flags()
302
+
303
+ try:
304
+ Chem.SanitizeMol(mol)
305
+ except Exception:
306
+ self.statusBar().showMessage("Error: Invalid chemical structure.")
307
+ self.view_2d.setFocus()
308
+ return
309
+
310
+ # 複数分子の処理に対応
311
+ num_frags = len(Chem.GetMolFrags(mol))
312
+ if num_frags > 1:
313
+ self.statusBar().showMessage(f"Converting {num_frags} molecules to 3D with collision detection...")
314
+ else:
315
+ self.statusBar().showMessage("Calculating 3D structure...")
316
+
317
+ # CRITICAL FIX: Use the 2D editor's MOL block instead of RDKit's to preserve
318
+ # wedge/dash stereo information that is stored in the 2D editor data.
319
+ # RDKit's MolToMolBlock() doesn't preserve this information.
320
+ mol_block = self.data.to_mol_block()
321
+ if not mol_block:
322
+ mol_block = Chem.MolToMolBlock(mol, includeStereo=True)
323
+
324
+ # Additional E/Z stereo enhancement: add M CFG lines for explicit E/Z bonds
325
+ mol_lines = mol_block.split('\n')
326
+
327
+ # Find bonds with explicit E/Z labels from our data and map to RDKit bond indices
328
+ ez_bond_info = {}
329
+ for (id1, id2), bond_data in self.data.bonds.items():
330
+ if bond_data.get('stereo') in [3, 4]: # E/Z labels
331
+ # Find corresponding atoms in RDKit molecule by _original_atom_id property
332
+ rdkit_idx1 = None
333
+ rdkit_idx2 = None
334
+ for atom in mol.GetAtoms():
335
+ if atom.HasProp("_original_atom_id"):
336
+ orig_id = atom.GetIntProp("_original_atom_id")
337
+ if orig_id == id1:
338
+ rdkit_idx1 = atom.GetIdx()
339
+ elif orig_id == id2:
340
+ rdkit_idx2 = atom.GetIdx()
341
+
342
+ if rdkit_idx1 is not None and rdkit_idx2 is not None:
343
+ rdkit_bond = mol.GetBondBetweenAtoms(rdkit_idx1, rdkit_idx2)
344
+ if rdkit_bond and rdkit_bond.GetBondType() == Chem.BondType.DOUBLE:
345
+ ez_bond_info[rdkit_bond.GetIdx()] = bond_data['stereo']
346
+
347
+ # Add M CFG lines for E/Z stereo if needed
348
+ if ez_bond_info:
349
+ insert_idx = len(mol_lines) - 1 # Before M END
350
+ for bond_idx, stereo_type in ez_bond_info.items():
351
+ cfg_value = 1 if stereo_type == 3 else 2 # 1=Z, 2=E in MOL format
352
+ cfg_line = f"M CFG 1 {bond_idx + 1:3d} {cfg_value}"
353
+ mol_lines.insert(insert_idx, cfg_line)
354
+ insert_idx += 1
355
+ mol_block = '\n'.join(mol_lines)
356
+
357
+ # Assign a unique ID for this conversion run so it can be halted/validated
358
+ try:
359
+ run_id = int(self.next_conversion_id)
360
+ except Exception:
361
+ run_id = 1
362
+ try:
363
+ self.next_conversion_id = run_id + 1
364
+ except Exception:
365
+ self.next_conversion_id = getattr(self, 'next_conversion_id', 1) + 1
366
+
367
+ # Record this run as active. Use a set to track all active worker ids
368
+ # so a Halt request can target every running conversion.
369
+ try:
370
+ self.active_worker_ids.add(run_id)
371
+ except Exception:
372
+ # Ensure attribute exists in case of weird states
373
+ self.active_worker_ids = set([run_id])
374
+
375
+ # Change the convert button to a Halt button so user can cancel
376
+ try:
377
+ # keep it enabled so the user can click Halt
378
+ self.convert_button.setText("Halt conversion")
379
+ try:
380
+ self.convert_button.clicked.disconnect()
381
+ except Exception:
382
+ pass
383
+ self.convert_button.clicked.connect(self.halt_conversion)
384
+ except Exception:
385
+ pass
386
+
387
+ # Keep cleanup disabled while conversion is in progress
388
+ self.cleanup_button.setEnabled(False)
389
+ # Disable 3D features during calculation
390
+ self._enable_3d_features(False)
391
+ self.statusBar().showMessage("Calculating 3D structure...")
392
+ self.plotter.clear()
393
+ bg_color_hex = self.settings.get('background_color', '#919191')
394
+ bg_qcolor = QColor(bg_color_hex)
395
+
396
+ if bg_qcolor.isValid():
397
+ luminance = bg_qcolor.toHsl().lightness()
398
+ text_color = 'black' if luminance > 128 else 'white'
399
+ else:
400
+ text_color = 'white'
401
+
402
+ text_actor = self.plotter.add_text(
403
+ "Calculating...",
404
+ position='lower_right',
405
+ font_size=15,
406
+ color=text_color,
407
+ name='calculating_text'
408
+ )
409
+ # Keep a reference so we can reliably remove the text actor later
410
+ try:
411
+ self._calculating_text_actor = text_actor
412
+ except Exception:
413
+ # Best-effort: if storing fails, ignore — cleanup will still attempt renderer removal
414
+ pass
415
+ text_actor.GetTextProperty().SetOpacity(1)
416
+ self.plotter.render()
417
+ # Emit skip flag so the worker can ignore sanitization errors if user requested
418
+ # Determine conversion_mode from settings (default: 'fallback').
419
+ # If the user invoked conversion via the right-click menu, a temporary
420
+ # override may be set on self._temp_conv_mode and should be used once.
421
+ conv_mode = getattr(self, '_temp_conv_mode', None)
422
+ if conv_mode:
423
+ try:
424
+ del self._temp_conv_mode
425
+ except Exception:
426
+ try:
427
+ delattr(self, '_temp_conv_mode')
428
+ except Exception:
429
+ pass
430
+ else:
431
+ conv_mode = self.settings.get('3d_conversion_mode', 'fallback')
432
+
433
+ # Allow a temporary optimization method override as well (used when
434
+ # Optimize 3D is invoked via right-click menu). Do not persist here.
435
+ opt_method = getattr(self, '_temp_optimization_method', None) or self.optimization_method
436
+ if hasattr(self, '_temp_optimization_method'):
437
+ try:
438
+ del self._temp_optimization_method
439
+ except Exception:
440
+ try:
441
+ delattr(self, '_temp_optimization_method')
442
+ except Exception:
443
+ pass
444
+
445
+ options = {'conversion_mode': conv_mode, 'optimization_method': opt_method}
446
+ # Attach the run id so the worker and main thread can correlate
447
+ try:
448
+ # Attach the concrete run id rather than the single waiting id
449
+ options['worker_id'] = run_id
450
+ except Exception:
451
+ pass
452
+
453
+ # Create a fresh CalculationWorker + QThread for this run so multiple
454
+ # conversions can execute in parallel. The worker will be cleaned up
455
+ # automatically after it finishes/errors.
456
+ try:
457
+ thread = QThread()
458
+ worker = CalculationWorker()
459
+ # Share the halt_ids set so user can request cancellation
460
+ try:
461
+ worker.halt_ids = self.halt_ids
462
+ except Exception:
463
+ pass
464
+
465
+ worker.moveToThread(thread)
466
+
467
+ # Forward status signals to main window handlers
468
+ try:
469
+ worker.status_update.connect(self.update_status_bar)
470
+ except Exception:
471
+ pass
472
+
473
+ # When the worker finishes, call existing handler and then clean up
474
+ def _on_worker_finished(result, w=worker, t=thread):
475
+ try:
476
+ # deliver result to existing handler
477
+ self.on_calculation_finished(result)
478
+ finally:
479
+ # Clean up signal connections to avoid stale references
480
+ # worker used its own start_work signal; no shared-signal
481
+ # disconnect necessary here.
482
+ # Remove thread from active threads list
483
+ try:
484
+ self._active_calc_threads.remove(t)
485
+ except Exception:
486
+ pass
487
+ try:
488
+ # ask thread to quit; it will finish as worker returns
489
+ t.quit()
490
+ except Exception:
491
+ pass
492
+ try:
493
+ # ensure thread object is deleted when finished
494
+ t.finished.connect(t.deleteLater)
495
+ except Exception:
496
+ pass
497
+ try:
498
+ # schedule worker deletion
499
+ w.deleteLater()
500
+ except Exception:
501
+ pass
502
+
503
+ # When the worker errors (or halts), call existing handler and then clean up
504
+ def _on_worker_error(error_msg, w=worker, t=thread):
505
+ try:
506
+ # deliver error to existing handler
507
+ self.on_calculation_error(error_msg)
508
+ finally:
509
+ # Clean up signal connections to avoid stale references
510
+ # worker used its own start_work signal; no shared-signal
511
+ # disconnect necessary here.
512
+ # Remove thread from active threads list
513
+ try:
514
+ self._active_calc_threads.remove(t)
515
+ except Exception:
516
+ pass
517
+ try:
518
+ # ask thread to quit; it will finish as worker returns
519
+ t.quit()
520
+ except Exception:
521
+ pass
522
+ try:
523
+ # ensure thread object is deleted when finished
524
+ t.finished.connect(t.deleteLater)
525
+ except Exception:
526
+ pass
527
+ try:
528
+ # schedule worker deletion
529
+ w.deleteLater()
530
+ except Exception:
531
+ pass
532
+
533
+ try:
534
+ worker.error.connect(_on_worker_error)
535
+ except Exception:
536
+ pass
537
+
538
+ try:
539
+ worker.finished.connect(_on_worker_finished)
540
+ except Exception:
541
+ pass
542
+
543
+ # Start the thread
544
+ thread.start()
545
+
546
+ # Start the worker calculation via the worker's own start_work signal
547
+ # (queued to the worker thread). Capture variables into lambda defaults
548
+ # to avoid late-binding issues.
549
+ QTimer.singleShot(10, lambda w=worker, m=mol_block, o=options: w.start_work.emit(m, o))
550
+
551
+ # Track the thread so it isn't immediately garbage-collected (diagnostics)
552
+ try:
553
+ self._active_calc_threads.append(thread)
554
+ except Exception:
555
+ pass
556
+ except Exception as e:
557
+ # Fall back: if thread/worker creation failed, create a local
558
+ # worker and start it (runs in main thread). This preserves
559
+ # functionality without relying on the shared MainWindow signal.
560
+ try:
561
+ fallback_worker = CalculationWorker()
562
+ QTimer.singleShot(10, lambda w=fallback_worker, m=mol_block, o=options: w.start_work.emit(m, o))
563
+ except Exception:
564
+ # surface the original error via existing UI path
565
+ self.on_calculation_error(str(e))
566
+
567
+ # 状態をUndo履歴に保存
568
+ self.push_undo_state()
569
+ self.update_chiral_labels()
570
+
571
+ self.view_2d.setFocus()
572
+
573
+
574
+
575
+ def halt_conversion(self):
576
+ """User requested to halt the in-progress conversion.
577
+
578
+ This will mark the current waiting_worker_id as halted (added to halt_ids),
579
+ clear the waiting_worker_id, and immediately restore the UI (button text
580
+ and handlers). The worker thread will observe halt_ids and should stop.
581
+ """
582
+ try:
583
+ # Halt all currently-active workers by adding their ids to halt_ids
584
+ wids_to_halt = set(getattr(self, 'active_worker_ids', set()))
585
+ if wids_to_halt:
586
+ try:
587
+ self.halt_ids.update(wids_to_halt)
588
+ except Exception:
589
+ pass
590
+
591
+ # Clear the active set immediately so UI reflects cancellation
592
+ try:
593
+ if hasattr(self, 'active_worker_ids'):
594
+ self.active_worker_ids.clear()
595
+ except Exception:
596
+ pass
597
+
598
+ # Restore UI immediately
599
+ try:
600
+ try:
601
+ self.convert_button.clicked.disconnect()
602
+ except Exception:
603
+ pass
604
+ self.convert_button.setText("Convert 2D to 3D")
605
+ self.convert_button.clicked.connect(self.trigger_conversion)
606
+ self.convert_button.setEnabled(True)
607
+ except Exception:
608
+ pass
609
+
610
+ try:
611
+ self.cleanup_button.setEnabled(True)
612
+ except Exception:
613
+ pass
614
+
615
+ # Remove any calculating text actor if present
616
+ try:
617
+ actor = getattr(self, '_calculating_text_actor', None)
618
+ if actor is not None:
619
+ if hasattr(self.plotter, 'remove_actor'):
620
+ try:
621
+ self.plotter.remove_actor(actor)
622
+ except Exception:
623
+ pass
624
+ else:
625
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
626
+ try:
627
+ self.plotter.renderer.RemoveActor(actor)
628
+ except Exception:
629
+ pass
630
+ try:
631
+ delattr(self, '_calculating_text_actor')
632
+ except Exception:
633
+ try:
634
+ del self._calculating_text_actor
635
+ except Exception:
636
+ pass
637
+ except Exception:
638
+ pass
639
+
640
+ # Give immediate feedback
641
+ self.statusBar().showMessage("3D conversion halted. Waiting for the thread to finish")
642
+ except Exception:
643
+ pass
644
+
645
+
646
+
647
+ def check_chemistry_problems_fallback(self):
648
+ """RDKit変換が失敗した場合の化学的問題チェック(独自実装)"""
649
+ try:
650
+ # 既存のフラグをクリア
651
+ self.scene.clear_all_problem_flags()
652
+
653
+ # 簡易的な化学的問題チェック
654
+ problem_atoms = []
655
+
656
+ for atom_id, atom_data in self.data.atoms.items():
657
+ atom_item = atom_data.get('item')
658
+ if not atom_item:
659
+ continue
660
+
661
+ symbol = atom_data['symbol']
662
+ charge = atom_data.get('charge', 0)
663
+
664
+ # 結合数を計算
665
+ bond_count = 0
666
+ for (id1, id2), bond_data in self.data.bonds.items():
667
+ if id1 == atom_id or id2 == atom_id:
668
+ bond_count += bond_data.get('order', 1)
669
+
670
+ # 基本的な価数チェック
671
+ is_problematic = False
672
+ if symbol == 'C' and bond_count > 4:
673
+ is_problematic = True
674
+ elif symbol == 'N' and bond_count > 3 and charge == 0:
675
+ is_problematic = True
676
+ elif symbol == 'O' and bond_count > 2 and charge == 0:
677
+ is_problematic = True
678
+ elif symbol == 'H' and bond_count > 1:
679
+ is_problematic = True
680
+ elif symbol in ['F', 'Cl', 'Br', 'I'] and bond_count > 1 and charge == 0:
681
+ is_problematic = True
682
+
683
+ if is_problematic:
684
+ problem_atoms.append(atom_item)
685
+
686
+ if problem_atoms:
687
+ # 問題のある原子に赤枠を設定
688
+ for atom_item in problem_atoms:
689
+ atom_item.has_problem = True
690
+ atom_item.update()
691
+
692
+ self.statusBar().showMessage(f"Error: {len(problem_atoms)} chemistry problem(s) found (valence issues).")
693
+ else:
694
+ self.statusBar().showMessage("Error: Invalid chemical structure (RDKit conversion failed).")
695
+
696
+ self.scene.clearSelection()
697
+ self.view_2d.setFocus()
698
+
699
+ except Exception as e:
700
+ print(f"Error in fallback chemistry check: {e}")
701
+ self.statusBar().showMessage("Error: Invalid chemical structure.")
702
+ self.view_2d.setFocus()
703
+
704
+
705
+
706
+ def optimize_3d_structure(self):
707
+ """現在の3D分子構造を力場で最適化する"""
708
+ if not self.current_mol:
709
+ self.statusBar().showMessage("No 3D molecule to optimize.")
710
+ return
711
+
712
+ # If a prior chemical/sanitization check was attempted and failed, do not run optimization
713
+ if getattr(self, 'chem_check_tried', False) and getattr(self, 'chem_check_failed', False):
714
+ self.statusBar().showMessage("3D optimization disabled: molecule failed chemical sanitization.")
715
+ # Ensure the Optimize 3D button is disabled to reflect this
716
+ if hasattr(self, 'optimize_3d_button'):
717
+ try:
718
+ self.optimize_3d_button.setEnabled(False)
719
+ except Exception:
720
+ pass
721
+ return
722
+
723
+ self.statusBar().showMessage("Optimizing 3D structure...")
724
+ QApplication.processEvents() # UIの更新を確実に行う
725
+
726
+ try:
727
+ # Allow a temporary optimization method override (right-click menu)
728
+ method = getattr(self, '_temp_optimization_method', None) or getattr(self, 'optimization_method', 'MMFF_RDKIT')
729
+ # Clear temporary override if present
730
+ if hasattr(self, '_temp_optimization_method'):
731
+ try:
732
+ del self._temp_optimization_method
733
+ except Exception:
734
+ try:
735
+ delattr(self, '_temp_optimization_method')
736
+ except Exception:
737
+ pass
738
+ method = method.upper() if method else 'MMFF_RDKIT'
739
+ # 事前チェック:コンフォーマがあるか
740
+ if self.current_mol.GetNumConformers() == 0:
741
+ self.statusBar().showMessage("No conformer found: cannot optimize. Embed molecule first.")
742
+ return
743
+ if method in ('MMFF_RDKIT', 'MMFF94_RDKIT'):
744
+ try:
745
+ # Choose concrete mmffVariant string
746
+ mmff_variant = "MMFF94s" if method == 'MMFF_RDKIT' else "MMFF94"
747
+ res = AllChem.MMFFOptimizeMolecule(self.current_mol, maxIters=4000, mmffVariant=mmff_variant)
748
+ if res != 0:
749
+ # 非収束や何らかの問題が起きた可能性 -> ForceField API で詳細に試す
750
+ try:
751
+ mmff_props = AllChem.MMFFGetMoleculeProperties(self.current_mol)
752
+ ff = AllChem.MMFFGetMoleculeForceField(self.current_mol, mmff_props, confId=0)
753
+ ff_ret = ff.Minimize(maxIts=4000)
754
+ if ff_ret != 0:
755
+ self.statusBar().showMessage(f"{mmff_variant} minimize returned non-zero status: {ff_ret}")
756
+ return
757
+ except Exception as e:
758
+ self.statusBar().showMessage(f"{mmff_variant} parameterization/minimize failed: {e}")
759
+ return
760
+ except Exception as e:
761
+ self.statusBar().showMessage(f"{mmff_variant} (RDKit) optimization error: {e}")
762
+ return
763
+ elif method == 'UFF_RDKIT':
764
+ try:
765
+ res = AllChem.UFFOptimizeMolecule(self.current_mol, maxIters=4000)
766
+ if res != 0:
767
+ try:
768
+ ff = AllChem.UFFGetMoleculeForceField(self.current_mol, confId=0)
769
+ ff_ret = ff.Minimize(maxIts=4000)
770
+ if ff_ret != 0:
771
+ self.statusBar().showMessage(f"UFF minimize returned non-zero status: {ff_ret}")
772
+ return
773
+ except Exception as e:
774
+ self.statusBar().showMessage(f"UFF parameterization/minimize failed: {e}")
775
+ return
776
+ except Exception as e:
777
+ self.statusBar().showMessage(f"UFF (RDKit) optimization error: {e}")
778
+ return
779
+ # Plugin method dispatch
780
+ # Plugin method dispatch
781
+ elif hasattr(self, 'plugin_manager') and hasattr(self.plugin_manager, 'optimization_methods') and method in self.plugin_manager.optimization_methods:
782
+ info = self.plugin_manager.optimization_methods[method]
783
+ callback = info['callback']
784
+ try:
785
+ success = callback(self.current_mol)
786
+ if not success:
787
+ self.statusBar().showMessage(f"Optimization method '{method}' returned failure.")
788
+ return
789
+ except Exception as e:
790
+ self.statusBar().showMessage(f"Plugin optimization error ({method}): {e}")
791
+ return
792
+ else:
793
+ self.statusBar().showMessage("Selected optimization method is not available. Use MMFF94 (RDKit) or UFF (RDKit).")
794
+ return
795
+ except Exception as e:
796
+ self.statusBar().showMessage(f"3D optimization error: {e}")
797
+
798
+ # 最適化後の構造で3Dビューを再描画
799
+ try:
800
+ # Remember which concrete optimizer variant succeeded so it
801
+ # can be saved with the project. Normalize internal flags to
802
+ # a human-friendly label: MMFF94s, MMFF94, or UFF.
803
+ try:
804
+ norm_method = None
805
+ m = method.upper() if method else None
806
+ if m in ('MMFF_RDKIT', 'MMFF94_RDKIT'):
807
+ # The code above uses mmffVariant="MMFF94s" when
808
+ # method == 'MMFF_RDKIT' and "MMFF94" otherwise.
809
+ norm_method = 'MMFF94s' if m == 'MMFF_RDKIT' else 'MMFF94'
810
+ elif m == 'UFF_RDKIT' or m == 'UFF':
811
+ norm_method = 'UFF'
812
+ else:
813
+ norm_method = getattr(self, 'optimization_method', None)
814
+
815
+ # store for later serialization
816
+ if norm_method:
817
+ self.last_successful_optimization_method = norm_method
818
+ except Exception:
819
+ pass
820
+ # 3D最適化後は3D座標から立体化学を再計算(2回目以降は3D優先)
821
+ if self.current_mol.GetNumConformers() > 0:
822
+ Chem.AssignAtomChiralTagsFromStructure(self.current_mol, confId=0)
823
+ self.update_chiral_labels() # キラル中心のラベルも更新
824
+ except Exception:
825
+ pass
826
+
827
+ self.draw_molecule_3d(self.current_mol)
828
+
829
+ # Show which method was used in the status bar (prefer human-readable label).
830
+ # Prefer the actual method used during this run (last_successful_optimization_method
831
+ # set earlier), then any temporary/local override used for this call (method),
832
+ # and finally the persisted preference (self.optimization_method).
833
+ try:
834
+ used_method = (
835
+ getattr(self, 'last_successful_optimization_method', None)
836
+ or locals().get('method', None)
837
+ or getattr(self, 'optimization_method', None)
838
+ )
839
+ used_label = None
840
+ if used_method:
841
+ # opt3d_method_labels keys are stored upper-case; normalize for lookup
842
+ used_label = (getattr(self, 'opt3d_method_labels', {}) or {}).get(str(used_method).upper(), used_method)
843
+ except Exception:
844
+ used_label = None
845
+
846
+ if used_label:
847
+ self.statusBar().showMessage(f"3D structure optimization successful. Method: {used_label}")
848
+ else:
849
+ self.statusBar().showMessage("3D structure optimization successful.")
850
+ self.push_undo_state() # Undo履歴に保存
851
+ self.view_2d.setFocus()
852
+
853
+
854
+
855
+ def on_calculation_finished(self, result):
856
+ # Accept either (worker_id, mol) tuple or legacy single mol arg
857
+ worker_id = None
858
+ mol = None
859
+ try:
860
+ if isinstance(result, tuple) and len(result) == 2:
861
+ worker_id, mol = result
862
+ else:
863
+ mol = result
864
+ except Exception:
865
+ mol = result
866
+
867
+ # If this finished result is from a stale/halting run, discard it
868
+ try:
869
+ if worker_id is not None:
870
+ # If this worker_id is not in the active set, it's stale/halting
871
+ if worker_id not in getattr(self, 'active_worker_ids', set()):
872
+ # Cleanup calculating UI and ignore
873
+ try:
874
+ actor = getattr(self, '_calculating_text_actor', None)
875
+ if actor is not None:
876
+ if hasattr(self.plotter, 'remove_actor'):
877
+ try:
878
+ self.plotter.remove_actor(actor)
879
+ except Exception:
880
+ pass
881
+ else:
882
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
883
+ try:
884
+ self.plotter.renderer.RemoveActor(actor)
885
+ except Exception:
886
+ pass
887
+ try:
888
+ delattr(self, '_calculating_text_actor')
889
+ except Exception:
890
+ try:
891
+ del self._calculating_text_actor
892
+ except Exception:
893
+ pass
894
+ except Exception:
895
+ pass
896
+ # Ensure Convert button is restored
897
+ try:
898
+ try:
899
+ self.convert_button.clicked.disconnect()
900
+ except Exception:
901
+ pass
902
+ self.convert_button.setText("Convert 2D to 3D")
903
+ self.convert_button.clicked.connect(self.trigger_conversion)
904
+ self.convert_button.setEnabled(True)
905
+ except Exception:
906
+ pass
907
+ try:
908
+ self.cleanup_button.setEnabled(True)
909
+ except Exception:
910
+ pass
911
+ self.statusBar().showMessage("Ignored result from stale conversion.")
912
+ return
913
+ except Exception:
914
+ pass
915
+
916
+ # Remove the finished worker id from the active set and any halt set
917
+ try:
918
+ if worker_id is not None:
919
+ try:
920
+ self.active_worker_ids.discard(worker_id)
921
+ except Exception:
922
+ pass
923
+ # Also remove id from halt set if present
924
+ if worker_id is not None:
925
+ try:
926
+ if worker_id in getattr(self, 'halt_ids', set()):
927
+ try:
928
+ self.halt_ids.discard(worker_id)
929
+ except Exception:
930
+ pass
931
+ except Exception:
932
+ pass
933
+ except Exception:
934
+ pass
935
+
936
+ self.dragged_atom_info = None
937
+ self.current_mol = mol
938
+ self.is_xyz_derived = False # 2Dから生成した3D構造はXYZ由来ではない
939
+ # Record the optimization method used for this conversion if available.
940
+ try:
941
+ opt_method = None
942
+ try:
943
+ # Worker or molecule may have attached a prop with the used method
944
+ if hasattr(mol, 'HasProp') and mol is not None:
945
+ try:
946
+ if mol.HasProp('_pme_optimization_method'):
947
+ opt_method = mol.GetProp('_pme_optimization_method')
948
+ except Exception:
949
+ # not all Mol objects support HasProp/GetProp safely
950
+ pass
951
+ except Exception:
952
+ pass
953
+ if not opt_method:
954
+ opt_method = getattr(self, 'optimization_method', None)
955
+ # normalize common forms
956
+ if opt_method:
957
+ om = str(opt_method).upper()
958
+ if 'MMFF94S' in om or 'MMFF_RDKIT' in om:
959
+ self.last_successful_optimization_method = 'MMFF94s'
960
+ elif 'MMFF94' in om:
961
+ self.last_successful_optimization_method = 'MMFF94'
962
+ elif 'UFF' in om:
963
+ self.last_successful_optimization_method = 'UFF'
964
+ else:
965
+ # store raw value otherwise
966
+ self.last_successful_optimization_method = opt_method
967
+ except Exception:
968
+ # non-fatal
969
+ pass
970
+
971
+ # 原子プロパティを復元(ワーカープロセスで失われたため)
972
+ if hasattr(self, 'original_atom_properties'):
973
+ for i, original_id in self.original_atom_properties.items():
974
+ if i < mol.GetNumAtoms():
975
+ atom = mol.GetAtomWithIdx(i)
976
+ atom.SetIntProp("_original_atom_id", original_id)
977
+
978
+ # 原子IDマッピングを作成
979
+ self.create_atom_id_mapping()
980
+
981
+ # キラル中心を初回変換時は2Dの立体情報を考慮して設定
982
+ try:
983
+ if mol.GetNumConformers() > 0:
984
+ # 初回変換では、2Dで設定したwedge/dashボンドの立体情報を保持
985
+
986
+ # 3D立体化学計算で上書きされる前に、2D由来の立体化学情報をプロパティとして保存
987
+ for bond in mol.GetBonds():
988
+ if bond.GetBondType() == Chem.BondType.DOUBLE:
989
+ bond.SetIntProp("_original_2d_stereo", bond.GetStereo())
990
+
991
+ # 立体化学の割り当てを行うが、既存の2D立体情報を尊重
992
+ Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
993
+
994
+ self.update_chiral_labels()
995
+ except Exception:
996
+ # 念のためエラーを握り潰して UI を壊さない
997
+ pass
998
+
999
+ self.draw_molecule_3d(mol)
1000
+
1001
+ # 複数分子の場合、衝突検出と配置調整を実行
1002
+ try:
1003
+ frags = Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=False)
1004
+ if len(frags) > 1:
1005
+ self.statusBar().showMessage(f"Detecting collisions among {len(frags)} molecules...")
1006
+ QApplication.processEvents()
1007
+ self.adjust_molecule_positions_to_avoid_collisions(mol, frags)
1008
+ self.draw_molecule_3d(mol)
1009
+ self.update_chiral_labels()
1010
+ self.statusBar().showMessage(f"{len(frags)} molecules converted with collision avoidance.")
1011
+ except Exception as e:
1012
+ print(f"Warning: Collision detection failed: {e}")
1013
+ # 衝突検出に失敗してもエラーにはしない
1014
+
1015
+ # Ensure any 'Calculating...' text is removed and the plotter is refreshed
1016
+ try:
1017
+ actor = getattr(self, '_calculating_text_actor', None)
1018
+ if actor is not None:
1019
+ try:
1020
+ # Prefer plotter API if available
1021
+ if hasattr(self.plotter, 'remove_actor'):
1022
+ try:
1023
+ self.plotter.remove_actor(actor)
1024
+ except Exception:
1025
+ # Some pyvista versions use renderer.RemoveActor
1026
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
1027
+ try:
1028
+ self.plotter.renderer.RemoveActor(actor)
1029
+ except Exception:
1030
+ pass
1031
+ else:
1032
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
1033
+ try:
1034
+ self.plotter.renderer.RemoveActor(actor)
1035
+ except Exception:
1036
+ pass
1037
+ finally:
1038
+ try:
1039
+ delattr(self, '_calculating_text_actor')
1040
+ except Exception:
1041
+ try:
1042
+ del self._calculating_text_actor
1043
+ except Exception:
1044
+ pass
1045
+ # Re-render to ensure the UI updates immediately
1046
+ try:
1047
+ self.plotter.render()
1048
+ except Exception:
1049
+ pass
1050
+ except Exception:
1051
+ pass
1052
+
1053
+ #self.statusBar().showMessage("3D conversion successful.")
1054
+ self.convert_button.setEnabled(True)
1055
+ # Restore Convert button text/handler in case it was changed to Halt
1056
+ try:
1057
+ try:
1058
+ self.convert_button.clicked.disconnect()
1059
+ except Exception:
1060
+ pass
1061
+ self.convert_button.setText("Convert 2D to 3D")
1062
+ self.convert_button.clicked.connect(self.trigger_conversion)
1063
+ except Exception:
1064
+ pass
1065
+ self.push_undo_state()
1066
+ self.view_2d.setFocus()
1067
+ self.cleanup_button.setEnabled(True)
1068
+
1069
+ # 3D関連機能を統一的に有効化
1070
+ self._enable_3d_features(True)
1071
+
1072
+ self.plotter.reset_camera()
1073
+
1074
+ # 3D原子情報ホバー表示を再設定
1075
+ self.setup_3d_hover()
1076
+
1077
+ # メニューテキストと状態を更新
1078
+ self.update_atom_id_menu_text()
1079
+ self.update_atom_id_menu_state()
1080
+
1081
+
1082
+
1083
+ def create_atom_id_mapping(self):
1084
+ """2D原子IDから3D RDKit原子インデックスへのマッピングを作成する(RDKitの原子プロパティ使用)"""
1085
+ if not self.current_mol:
1086
+ return
1087
+
1088
+ self.atom_id_to_rdkit_idx_map = {}
1089
+
1090
+ # RDKitの原子プロパティから直接マッピングを作成
1091
+ for i in range(self.current_mol.GetNumAtoms()):
1092
+ rdkit_atom = self.current_mol.GetAtomWithIdx(i)
1093
+ try:
1094
+ original_atom_id = rdkit_atom.GetIntProp("_original_atom_id")
1095
+ self.atom_id_to_rdkit_idx_map[original_atom_id] = i
1096
+ except KeyError:
1097
+ # プロパティが設定されていない場合(外部ファイル読み込み時など)
1098
+ continue
1099
+
1100
+
1101
+
1102
+ def on_calculation_error(self, result):
1103
+ """ワーカースレッドからのエラー(またはHalt)を処理する"""
1104
+ worker_id = None
1105
+ error_message = ""
1106
+ try:
1107
+ if isinstance(result, tuple) and len(result) == 2:
1108
+ worker_id, error_message = result
1109
+ else:
1110
+ error_message = str(result)
1111
+ except Exception:
1112
+ error_message = str(result)
1113
+
1114
+ # If this error is from a stale/previous worker (not in active set), ignore it.
1115
+ if worker_id is not None and worker_id not in getattr(self, 'active_worker_ids', set()):
1116
+ # Stale/late error from a previously-halted worker; ignore to avoid clobbering newer runs
1117
+ print(f"Ignored stale error from worker {worker_id}: {error_message}")
1118
+ return
1119
+
1120
+ # Clear temporary plotter content and remove calculating text if present
1121
+ try:
1122
+ self.plotter.clear()
1123
+ except Exception:
1124
+ pass
1125
+
1126
+ # Also attempt to explicitly remove the calculating text actor if it was stored
1127
+ try:
1128
+ actor = getattr(self, '_calculating_text_actor', None)
1129
+ if actor is not None:
1130
+ try:
1131
+ if hasattr(self.plotter, 'remove_actor'):
1132
+ try:
1133
+ self.plotter.remove_actor(actor)
1134
+ except Exception:
1135
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
1136
+ try:
1137
+ self.plotter.renderer.RemoveActor(actor)
1138
+ except Exception:
1139
+ pass
1140
+ else:
1141
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
1142
+ try:
1143
+ self.plotter.renderer.RemoveActor(actor)
1144
+ except Exception:
1145
+ pass
1146
+ finally:
1147
+ try:
1148
+ delattr(self, '_calculating_text_actor')
1149
+ except Exception:
1150
+ try:
1151
+ del self._calculating_text_actor
1152
+ except Exception:
1153
+ pass
1154
+ except Exception:
1155
+ pass
1156
+
1157
+ self.dragged_atom_info = None
1158
+ # Remove this worker id from active set (error belongs to this worker)
1159
+ try:
1160
+ if worker_id is not None:
1161
+ try:
1162
+ self.active_worker_ids.discard(worker_id)
1163
+ except Exception:
1164
+ pass
1165
+ except Exception:
1166
+ pass
1167
+
1168
+ # If this error was caused by an intentional halt and the main thread
1169
+ # already cleared waiting_worker_id earlier for other reasons, suppress the error noise.
1170
+ try:
1171
+ low = (error_message or '').lower()
1172
+ # If a halt message and there are no active workers left, the user
1173
+ # already saw the halt message — suppress duplicate noise.
1174
+ if 'halt' in low and not getattr(self, 'active_worker_ids', set()):
1175
+ return
1176
+ except Exception:
1177
+ pass
1178
+
1179
+ self.statusBar().showMessage(f"Error: {error_message}")
1180
+
1181
+ try:
1182
+ self.cleanup_button.setEnabled(True)
1183
+ except Exception:
1184
+ pass
1185
+ try:
1186
+ # Restore Convert button text/handler
1187
+ try:
1188
+ self.convert_button.clicked.disconnect()
1189
+ except Exception:
1190
+ pass
1191
+ self.convert_button.setText("Convert 2D to 3D")
1192
+ self.convert_button.clicked.connect(self.trigger_conversion)
1193
+ self.convert_button.setEnabled(True)
1194
+ except Exception:
1195
+ pass
1196
+
1197
+ # On calculation error we should NOT enable 3D-only features.
1198
+ # Explicitly disable Optimize and Export so the user can't try to operate
1199
+ # on an invalid or missing 3D molecule.
1200
+ try:
1201
+ if hasattr(self, 'optimize_3d_button'):
1202
+ self.optimize_3d_button.setEnabled(False)
1203
+ except Exception:
1204
+ pass
1205
+ try:
1206
+ if hasattr(self, 'export_button'):
1207
+ self.export_button.setEnabled(False)
1208
+ except Exception:
1209
+ pass
1210
+
1211
+ # Keep 3D feature buttons disabled to avoid inconsistent UI state
1212
+ try:
1213
+ self._enable_3d_features(False)
1214
+ except Exception:
1215
+ pass
1216
+
1217
+ # Keep 3D edit actions disabled (no molecule to edit)
1218
+ try:
1219
+ self._enable_3d_edit_actions(False)
1220
+ except Exception:
1221
+ pass
1222
+ # Some menu items are explicitly disabled on error
1223
+ try:
1224
+ if hasattr(self, 'analysis_action'):
1225
+ self.analysis_action.setEnabled(False)
1226
+ except Exception:
1227
+ pass
1228
+ try:
1229
+ if hasattr(self, 'edit_3d_action'):
1230
+ self.edit_3d_action.setEnabled(False)
1231
+ except Exception:
1232
+ pass
1233
+
1234
+ # Force a UI refresh
1235
+ try:
1236
+ self.plotter.render()
1237
+ except Exception:
1238
+ pass
1239
+
1240
+ # Ensure focus returns to 2D editor
1241
+ self.view_2d.setFocus()
1242
+