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.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- 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
|
+
|