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,1565 @@
|
|
|
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
|
+
"""
|
|
15
|
+
main_window_edit_actions.py
|
|
16
|
+
MainWindow (main_window.py) から分離されたモジュール
|
|
17
|
+
機能クラス: MainWindowEditActions
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pickle
|
|
23
|
+
import math
|
|
24
|
+
import io
|
|
25
|
+
import itertools
|
|
26
|
+
import traceback
|
|
27
|
+
|
|
28
|
+
from collections import deque
|
|
29
|
+
|
|
30
|
+
# RDKit imports (explicit to satisfy flake8 and used features)
|
|
31
|
+
from rdkit import Chem
|
|
32
|
+
from rdkit.Chem import AllChem
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# PyQt6 Modules
|
|
36
|
+
from PyQt6.QtWidgets import (
|
|
37
|
+
QApplication, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QSlider, QPushButton
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from PyQt6.QtGui import (
|
|
41
|
+
QCursor
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
from PyQt6.QtCore import (
|
|
46
|
+
QPointF, QLineF, QMimeData, QByteArray, QTimer, Qt
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
class Rotate2DDialog(QDialog):
|
|
50
|
+
def __init__(self, parent=None):
|
|
51
|
+
super().__init__(parent)
|
|
52
|
+
self.setWindowTitle("Rotate 2D")
|
|
53
|
+
self.setFixedWidth(300)
|
|
54
|
+
|
|
55
|
+
layout = QVBoxLayout(self)
|
|
56
|
+
|
|
57
|
+
# Angle input
|
|
58
|
+
input_layout = QHBoxLayout()
|
|
59
|
+
input_layout.addWidget(QLabel("Angle (degrees):"))
|
|
60
|
+
self.angle_spin = QSpinBox()
|
|
61
|
+
self.angle_spin.setRange(-360, 360)
|
|
62
|
+
self.angle_spin.setValue(45)
|
|
63
|
+
input_layout.addWidget(self.angle_spin)
|
|
64
|
+
layout.addLayout(input_layout)
|
|
65
|
+
|
|
66
|
+
# Slider
|
|
67
|
+
self.slider = QSlider(Qt.Orientation.Horizontal)
|
|
68
|
+
self.slider.setRange(-180, 180)
|
|
69
|
+
self.slider.setValue(45)
|
|
70
|
+
self.slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
71
|
+
self.slider.setTickInterval(15)
|
|
72
|
+
layout.addWidget(self.slider)
|
|
73
|
+
|
|
74
|
+
# Sync slider and spinbox
|
|
75
|
+
self.angle_spin.valueChanged.connect(self.slider.setValue)
|
|
76
|
+
self.slider.valueChanged.connect(self.angle_spin.setValue)
|
|
77
|
+
|
|
78
|
+
# Buttons
|
|
79
|
+
btn_layout = QHBoxLayout()
|
|
80
|
+
ok_btn = QPushButton("Rotate")
|
|
81
|
+
ok_btn.clicked.connect(self.accept)
|
|
82
|
+
cancel_btn = QPushButton("Cancel")
|
|
83
|
+
cancel_btn.clicked.connect(self.reject)
|
|
84
|
+
btn_layout.addWidget(ok_btn)
|
|
85
|
+
btn_layout.addWidget(cancel_btn)
|
|
86
|
+
layout.addLayout(btn_layout)
|
|
87
|
+
|
|
88
|
+
def get_angle(self):
|
|
89
|
+
return self.angle_spin.value()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Use centralized Open Babel availability from package-level __init__
|
|
93
|
+
# Use per-package modules availability (local __init__).
|
|
94
|
+
try:
|
|
95
|
+
from . import OBABEL_AVAILABLE
|
|
96
|
+
except Exception:
|
|
97
|
+
from modules import OBABEL_AVAILABLE
|
|
98
|
+
# Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
|
|
99
|
+
if OBABEL_AVAILABLE:
|
|
100
|
+
try:
|
|
101
|
+
from openbabel import pybel
|
|
102
|
+
except Exception:
|
|
103
|
+
# If import fails here, disable OBABEL locally; avoid raising
|
|
104
|
+
pybel = None
|
|
105
|
+
OBABEL_AVAILABLE = False
|
|
106
|
+
print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
|
|
107
|
+
else:
|
|
108
|
+
pybel = None
|
|
109
|
+
|
|
110
|
+
# Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
|
|
111
|
+
# allows safely detecting C++ wrapper objects that have been deleted. Import
|
|
112
|
+
# it once at module import time and expose a small, robust wrapper so callers
|
|
113
|
+
# can avoid re-importing sip repeatedly and so we centralize exception
|
|
114
|
+
# handling (this reduces crash risk during teardown and deletion operations).
|
|
115
|
+
try:
|
|
116
|
+
import sip as _sip # type: ignore
|
|
117
|
+
_sip_isdeleted = getattr(_sip, 'isdeleted', None)
|
|
118
|
+
except Exception:
|
|
119
|
+
_sip = None
|
|
120
|
+
_sip_isdeleted = None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# package relative imports (preferred when running as `python -m moleditpy`)
|
|
124
|
+
from .constants import CLIPBOARD_MIME_TYPE
|
|
125
|
+
from .molecular_data import MolecularData
|
|
126
|
+
from .atom_item import AtomItem
|
|
127
|
+
from .bond_item import BondItem
|
|
128
|
+
except Exception:
|
|
129
|
+
# Fallback to absolute imports for script-style execution
|
|
130
|
+
from modules.constants import CLIPBOARD_MIME_TYPE
|
|
131
|
+
from modules.molecular_data import MolecularData
|
|
132
|
+
from modules.atom_item import AtomItem
|
|
133
|
+
from modules.bond_item import BondItem
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Import the shared SIP helper used across the package. This is
|
|
138
|
+
# defined in modules/__init__.py and centralizes sip.isdeleted checks.
|
|
139
|
+
from . import sip_isdeleted_safe
|
|
140
|
+
except Exception:
|
|
141
|
+
from modules import sip_isdeleted_safe
|
|
142
|
+
|
|
143
|
+
# --- クラス定義 ---
|
|
144
|
+
class MainWindowEditActions(object):
|
|
145
|
+
""" main_window.py から分離された機能クラス """
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def copy_selection(self):
|
|
149
|
+
"""選択された原子と結合をクリップボードにコピーする"""
|
|
150
|
+
try:
|
|
151
|
+
selected_atoms = [item for item in self.scene.selectedItems() if isinstance(item, AtomItem)]
|
|
152
|
+
if not selected_atoms:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# 選択された原子のIDセットを作成
|
|
156
|
+
selected_atom_ids = {atom.atom_id for atom in selected_atoms}
|
|
157
|
+
|
|
158
|
+
# 選択された原子の幾何学的中心を計算
|
|
159
|
+
center = QPointF(
|
|
160
|
+
sum(atom.pos().x() for atom in selected_atoms) / len(selected_atoms),
|
|
161
|
+
sum(atom.pos().y() for atom in selected_atoms) / len(selected_atoms)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# コピー対象の原子データをリストに格納(位置は中心からの相対座標)
|
|
165
|
+
# 同時に、元のatom_idから新しいインデックス(0, 1, 2...)へのマッピングを作成
|
|
166
|
+
atom_id_to_idx_map = {}
|
|
167
|
+
fragment_atoms = []
|
|
168
|
+
for i, atom in enumerate(selected_atoms):
|
|
169
|
+
atom_id_to_idx_map[atom.atom_id] = i
|
|
170
|
+
fragment_atoms.append({
|
|
171
|
+
'symbol': atom.symbol,
|
|
172
|
+
'rel_pos': atom.pos() - center,
|
|
173
|
+
'charge': atom.charge,
|
|
174
|
+
'radical': atom.radical,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
# 選択された原子同士を結ぶ結合のみをリストに格納
|
|
178
|
+
fragment_bonds = []
|
|
179
|
+
for (id1, id2), bond_data in self.data.bonds.items():
|
|
180
|
+
if id1 in selected_atom_ids and id2 in selected_atom_ids:
|
|
181
|
+
fragment_bonds.append({
|
|
182
|
+
'idx1': atom_id_to_idx_map[id1],
|
|
183
|
+
'idx2': atom_id_to_idx_map[id2],
|
|
184
|
+
'order': bond_data['order'],
|
|
185
|
+
'stereo': bond_data.get('stereo', 0), # E/Z立体化学情報も保存
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
# pickleを使ってデータをバイト配列にシリアライズ
|
|
189
|
+
data_to_pickle = {'atoms': fragment_atoms, 'bonds': fragment_bonds}
|
|
190
|
+
byte_array = QByteArray()
|
|
191
|
+
buffer = io.BytesIO()
|
|
192
|
+
pickle.dump(data_to_pickle, buffer)
|
|
193
|
+
byte_array.append(buffer.getvalue())
|
|
194
|
+
|
|
195
|
+
# カスタムMIMEタイプでクリップボードに設定
|
|
196
|
+
mime_data = QMimeData()
|
|
197
|
+
mime_data.setData(CLIPBOARD_MIME_TYPE, byte_array)
|
|
198
|
+
QApplication.clipboard().setMimeData(mime_data)
|
|
199
|
+
self.statusBar().showMessage(f"Copied {len(fragment_atoms)} atoms and {len(fragment_bonds)} bonds.")
|
|
200
|
+
|
|
201
|
+
except Exception as e:
|
|
202
|
+
print(f"Error during copy operation: {e}")
|
|
203
|
+
|
|
204
|
+
traceback.print_exc()
|
|
205
|
+
self.statusBar().showMessage(f"Error during copy operation: {e}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cut_selection(self):
|
|
210
|
+
"""選択されたアイテムを切り取り(コピーしてから削除)"""
|
|
211
|
+
try:
|
|
212
|
+
selected_items = self.scene.selectedItems()
|
|
213
|
+
if not selected_items:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# 最初にコピー処理を実行
|
|
217
|
+
self.copy_selection()
|
|
218
|
+
|
|
219
|
+
if self.scene.delete_items(set(selected_items)):
|
|
220
|
+
self.push_undo_state()
|
|
221
|
+
self.statusBar().showMessage("Cut selection.", 2000)
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print(f"Error during cut operation: {e}")
|
|
225
|
+
|
|
226
|
+
traceback.print_exc()
|
|
227
|
+
self.statusBar().showMessage(f"Error during cut operation: {e}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def paste_from_clipboard(self):
|
|
232
|
+
"""クリップボードから分子フラグメントを貼り付け"""
|
|
233
|
+
try:
|
|
234
|
+
clipboard = QApplication.clipboard()
|
|
235
|
+
mime_data = clipboard.mimeData()
|
|
236
|
+
if not mime_data.hasFormat(CLIPBOARD_MIME_TYPE):
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
byte_array = mime_data.data(CLIPBOARD_MIME_TYPE)
|
|
240
|
+
buffer = io.BytesIO(byte_array)
|
|
241
|
+
try:
|
|
242
|
+
fragment_data = pickle.load(buffer)
|
|
243
|
+
except pickle.UnpicklingError:
|
|
244
|
+
self.statusBar().showMessage("Error: Invalid clipboard data format")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
paste_center_pos = self.view_2d.mapToScene(self.view_2d.mapFromGlobal(QCursor.pos()))
|
|
248
|
+
self.scene.clearSelection()
|
|
249
|
+
|
|
250
|
+
new_atoms = []
|
|
251
|
+
for atom_data in fragment_data['atoms']:
|
|
252
|
+
pos = paste_center_pos + atom_data['rel_pos']
|
|
253
|
+
new_id = self.scene.create_atom(
|
|
254
|
+
atom_data['symbol'], pos,
|
|
255
|
+
charge=atom_data.get('charge', 0),
|
|
256
|
+
radical=atom_data.get('radical', 0)
|
|
257
|
+
)
|
|
258
|
+
new_item = self.data.atoms[new_id]['item']
|
|
259
|
+
new_atoms.append(new_item)
|
|
260
|
+
new_item.setSelected(True)
|
|
261
|
+
|
|
262
|
+
for bond_data in fragment_data['bonds']:
|
|
263
|
+
atom1 = new_atoms[bond_data['idx1']]
|
|
264
|
+
atom2 = new_atoms[bond_data['idx2']]
|
|
265
|
+
self.scene.create_bond(
|
|
266
|
+
atom1, atom2,
|
|
267
|
+
bond_order=bond_data.get('order', 1),
|
|
268
|
+
bond_stereo=bond_data.get('stereo', 0) # E/Z立体化学情報も復元
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
self.push_undo_state()
|
|
272
|
+
self.statusBar().showMessage(f"Pasted {len(fragment_data['atoms'])} atoms and {len(fragment_data['bonds'])} bonds.", 2000)
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
print(f"Error during paste operation: {e}")
|
|
276
|
+
|
|
277
|
+
traceback.print_exc()
|
|
278
|
+
self.statusBar().showMessage(f"Error during paste operation: {e}")
|
|
279
|
+
self.statusBar().showMessage(f"Pasted {len(new_atoms)} atoms.", 2000)
|
|
280
|
+
self.activate_select_mode()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def remove_hydrogen_atoms(self):
|
|
285
|
+
"""2Dビューで水素原子とその結合を削除する"""
|
|
286
|
+
try:
|
|
287
|
+
# Collect hydrogen atom items robustly (store atom_id -> item)
|
|
288
|
+
hydrogen_map = {}
|
|
289
|
+
|
|
290
|
+
# Iterate over a snapshot of atoms to avoid "dictionary changed size"
|
|
291
|
+
for atom_id, atom_data in list(self.data.atoms.items()):
|
|
292
|
+
try:
|
|
293
|
+
if atom_data.get('symbol') != 'H':
|
|
294
|
+
continue
|
|
295
|
+
item = atom_data.get('item')
|
|
296
|
+
# Only collect live AtomItem wrappers
|
|
297
|
+
if item is None:
|
|
298
|
+
continue
|
|
299
|
+
if sip_isdeleted_safe(item):
|
|
300
|
+
continue
|
|
301
|
+
if not isinstance(item, AtomItem):
|
|
302
|
+
continue
|
|
303
|
+
# Prefer storing by original atom id to detect actual removals later
|
|
304
|
+
hydrogen_map[atom_id] = item
|
|
305
|
+
except Exception:
|
|
306
|
+
# Ignore problematic entries and continue scanning
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
if not hydrogen_map:
|
|
310
|
+
self.statusBar().showMessage("No hydrogen atoms found to remove.", 2000)
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# To avoid blocking the UI or causing large, monolithic deletions that may
|
|
314
|
+
# trigger internal re-entrancy issues, delete in batches and process UI events
|
|
315
|
+
items = list(hydrogen_map.values())
|
|
316
|
+
total = len(items)
|
|
317
|
+
batch_size = 200 # tuned conservative batch size
|
|
318
|
+
deleted_any = False
|
|
319
|
+
|
|
320
|
+
for start in range(0, total, batch_size):
|
|
321
|
+
end = min(start + batch_size, total)
|
|
322
|
+
batch = set()
|
|
323
|
+
# Filter out items that are already deleted or invalid just before deletion
|
|
324
|
+
for it in items[start:end]:
|
|
325
|
+
try:
|
|
326
|
+
if it is None:
|
|
327
|
+
continue
|
|
328
|
+
if sip_isdeleted_safe(it):
|
|
329
|
+
continue
|
|
330
|
+
if not isinstance(it, AtomItem):
|
|
331
|
+
continue
|
|
332
|
+
batch.add(it)
|
|
333
|
+
except Exception:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
if not batch:
|
|
337
|
+
# Nothing valid to delete in this batch
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# scene.delete_items is expected to handle bond cleanup; call it per-batch
|
|
342
|
+
success = False
|
|
343
|
+
try:
|
|
344
|
+
success = bool(self.scene.delete_items(batch))
|
|
345
|
+
except Exception:
|
|
346
|
+
# If scene.delete_items raises for a batch, attempt a safe per-item fallback
|
|
347
|
+
success = False
|
|
348
|
+
|
|
349
|
+
if not success:
|
|
350
|
+
# Fallback: try deleting items one-by-one to isolate problematic items
|
|
351
|
+
for it in list(batch):
|
|
352
|
+
try:
|
|
353
|
+
# Use scene.delete_items for single-item as well
|
|
354
|
+
ok = bool(self.scene.delete_items({it}))
|
|
355
|
+
if ok:
|
|
356
|
+
deleted_any = True
|
|
357
|
+
except Exception:
|
|
358
|
+
# If single deletion also fails, skip that item
|
|
359
|
+
continue
|
|
360
|
+
else:
|
|
361
|
+
deleted_any = True
|
|
362
|
+
|
|
363
|
+
except Exception:
|
|
364
|
+
# Continue with next batch on unexpected errors
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# Allow the GUI to process events between batches to remain responsive
|
|
368
|
+
try:
|
|
369
|
+
QApplication.processEvents()
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Determine how many hydrogens actually were removed by re-scanning data
|
|
374
|
+
remaining_h = 0
|
|
375
|
+
try:
|
|
376
|
+
for _, atom_data in list(self.data.atoms.items()):
|
|
377
|
+
try:
|
|
378
|
+
if atom_data.get('symbol') == 'H':
|
|
379
|
+
remaining_h += 1
|
|
380
|
+
except Exception:
|
|
381
|
+
continue
|
|
382
|
+
except Exception:
|
|
383
|
+
remaining_h = 0
|
|
384
|
+
|
|
385
|
+
removed_count = max(0, len(hydrogen_map) - remaining_h)
|
|
386
|
+
|
|
387
|
+
if removed_count > 0:
|
|
388
|
+
# Only push a single undo state once for the whole operation
|
|
389
|
+
try:
|
|
390
|
+
self.push_undo_state()
|
|
391
|
+
except Exception:
|
|
392
|
+
# Do not allow undo stack problems to crash the app
|
|
393
|
+
pass
|
|
394
|
+
self.statusBar().showMessage(f"Removed {removed_count} hydrogen atoms.", 2000)
|
|
395
|
+
else:
|
|
396
|
+
# If nothing removed but we attempted, show an informative message
|
|
397
|
+
if deleted_any:
|
|
398
|
+
# Deleted something but couldn't determine count reliably
|
|
399
|
+
self.statusBar().showMessage("Removed hydrogen atoms (count unknown).", 2000)
|
|
400
|
+
else:
|
|
401
|
+
self.statusBar().showMessage("Failed to remove hydrogen atoms or none found.")
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
# Capture and log unexpected errors but don't let them crash the UI
|
|
405
|
+
print(f"Error during hydrogen removal: {e}")
|
|
406
|
+
traceback.print_exc()
|
|
407
|
+
try:
|
|
408
|
+
self.statusBar().showMessage(f"Error removing hydrogen atoms: {e}")
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def add_hydrogen_atoms(self):
|
|
415
|
+
"""RDKitで各原子の暗黙の水素数を調べ、その数だけ明示的な水素原子と単結合を作成する(2Dビュー)。
|
|
416
|
+
|
|
417
|
+
実装上の仮定:
|
|
418
|
+
- `self.data.to_rdkit_mol()` は各RDKit原子に `_original_atom_id` プロパティを設定している。
|
|
419
|
+
- 原子の2D座標は `self.data.atoms[orig_id]['item'].pos()` で得られる。
|
|
420
|
+
- 新しい原子は `self.scene.create_atom(symbol, pos, ...)` で追加し、
|
|
421
|
+
結合は `self.scene.create_bond(atom_item, hydrogen_item, bond_order=1)` で作成する。
|
|
422
|
+
"""
|
|
423
|
+
try:
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
mol = self.data.to_rdkit_mol(use_2d_stereo=False)
|
|
427
|
+
if not mol or mol.GetNumAtoms() == 0:
|
|
428
|
+
self.statusBar().showMessage("No molecule available to compute hydrogens.", 2000)
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
added_count = 0
|
|
432
|
+
added_items = []
|
|
433
|
+
|
|
434
|
+
# すべてのRDKit原子について暗黙水素数を確認
|
|
435
|
+
for idx in range(mol.GetNumAtoms()):
|
|
436
|
+
rd_atom = mol.GetAtomWithIdx(idx)
|
|
437
|
+
try:
|
|
438
|
+
orig_id = rd_atom.GetIntProp("_original_atom_id")
|
|
439
|
+
except Exception:
|
|
440
|
+
# 元のエディタ側のIDがない場合はスキップ
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
if orig_id not in self.data.atoms:
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
# 暗黙水素数を優先して取得。存在しない場合は総水素数 - 明示水素数を使用
|
|
447
|
+
implicit_h = int(rd_atom.GetNumImplicitHs()) if hasattr(rd_atom, 'GetNumImplicitHs') else 0
|
|
448
|
+
if implicit_h is None or implicit_h < 0:
|
|
449
|
+
implicit_h = 0
|
|
450
|
+
if implicit_h == 0:
|
|
451
|
+
# フォールバック
|
|
452
|
+
try:
|
|
453
|
+
total_h = int(rd_atom.GetTotalNumHs())
|
|
454
|
+
explicit_h = int(rd_atom.GetNumExplicitHs()) if hasattr(rd_atom, 'GetNumExplicitHs') else 0
|
|
455
|
+
implicit_h = max(0, total_h - explicit_h)
|
|
456
|
+
except Exception:
|
|
457
|
+
implicit_h = 0
|
|
458
|
+
|
|
459
|
+
if implicit_h <= 0:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
parent_item = self.data.atoms[orig_id]['item']
|
|
463
|
+
parent_pos = parent_item.pos()
|
|
464
|
+
|
|
465
|
+
# 周囲の近接原子の方向を取得して、水素を邪魔しないように角度を決定
|
|
466
|
+
neighbor_angles = []
|
|
467
|
+
try:
|
|
468
|
+
for (a1, a2), bdata in self.data.bonds.items():
|
|
469
|
+
# 対象原子に結合している近傍の原子角度を収集する。
|
|
470
|
+
# ただし既存の水素は配置に影響させない(すでにあるHで埋めない)。
|
|
471
|
+
try:
|
|
472
|
+
if a1 == orig_id and a2 in self.data.atoms:
|
|
473
|
+
neigh = self.data.atoms[a2]
|
|
474
|
+
if neigh.get('symbol') == 'H':
|
|
475
|
+
continue
|
|
476
|
+
if neigh.get('item') is None:
|
|
477
|
+
continue
|
|
478
|
+
if sip_isdeleted_safe(neigh.get('item')):
|
|
479
|
+
continue
|
|
480
|
+
vec = neigh['item'].pos() - parent_pos
|
|
481
|
+
neighbor_angles.append(math.atan2(vec.y(), vec.x()))
|
|
482
|
+
elif a2 == orig_id and a1 in self.data.atoms:
|
|
483
|
+
neigh = self.data.atoms[a1]
|
|
484
|
+
if neigh.get('symbol') == 'H':
|
|
485
|
+
continue
|
|
486
|
+
if neigh.get('item') is None:
|
|
487
|
+
continue
|
|
488
|
+
if sip_isdeleted_safe(neigh.get('item')):
|
|
489
|
+
continue
|
|
490
|
+
vec = neigh['item'].pos() - parent_pos
|
|
491
|
+
neighbor_angles.append(math.atan2(vec.y(), vec.x()))
|
|
492
|
+
except Exception:
|
|
493
|
+
# 個々の近傍読み取りの問題は無視して続行
|
|
494
|
+
continue
|
|
495
|
+
except Exception:
|
|
496
|
+
neighbor_angles = []
|
|
497
|
+
|
|
498
|
+
# 画面上の適当な結合長(ピクセル)を使用
|
|
499
|
+
bond_length = 75
|
|
500
|
+
|
|
501
|
+
# ヘルパー: 指定インデックスの水素に使うbond_stereoを決定
|
|
502
|
+
def _choose_stereo(i):
|
|
503
|
+
# 0: plain, 1: wedge, 2: dash, 3: plain, 4+: all plain
|
|
504
|
+
if i == 0:
|
|
505
|
+
return 0
|
|
506
|
+
if i == 1:
|
|
507
|
+
return 1
|
|
508
|
+
if i == 2:
|
|
509
|
+
return 2
|
|
510
|
+
return 0 #4th+ hydrogens are all plain
|
|
511
|
+
|
|
512
|
+
# 角度配置を改善: 既存の結合角度の最大ギャップを見つけ、
|
|
513
|
+
# そこに水素を均等配置する。既存結合が無ければ全周に均等配置。
|
|
514
|
+
target_angles = []
|
|
515
|
+
try:
|
|
516
|
+
if not neighbor_angles:
|
|
517
|
+
# 既存結合が無い -> 全円周に均等配置
|
|
518
|
+
for h_idx in range(implicit_h):
|
|
519
|
+
angle = (2.0 * math.pi * h_idx) / implicit_h
|
|
520
|
+
target_angles.append(angle)
|
|
521
|
+
else:
|
|
522
|
+
# 正規化してソート
|
|
523
|
+
angs = [((a + 2.0 * math.pi) if a < 0 else a) for a in neighbor_angles]
|
|
524
|
+
angs = sorted(angs)
|
|
525
|
+
# ギャップを計算(循環含む)
|
|
526
|
+
gaps = [] # list of (gap_size, start_angle, end_angle)
|
|
527
|
+
for i in range(len(angs)):
|
|
528
|
+
a1 = angs[i]
|
|
529
|
+
a2 = angs[(i + 1) % len(angs)]
|
|
530
|
+
if i == len(angs) - 1:
|
|
531
|
+
# wrap-around gap
|
|
532
|
+
gap = (a2 + 2.0 * math.pi) - a1
|
|
533
|
+
start = a1
|
|
534
|
+
end = a2 + 2.0 * math.pi
|
|
535
|
+
else:
|
|
536
|
+
gap = a2 - a1
|
|
537
|
+
start = a1
|
|
538
|
+
end = a2
|
|
539
|
+
gaps.append((gap, start, end))
|
|
540
|
+
|
|
541
|
+
# 最大ギャップを選ぶ
|
|
542
|
+
gaps.sort(key=lambda x: x[0], reverse=True)
|
|
543
|
+
max_gap, gstart, gend = gaps[0]
|
|
544
|
+
# もし最大ギャップが小さい(つまり周りに均等に原子がある)でも
|
|
545
|
+
# そのギャップ内に均等配置することで既存結合と重ならないようにする
|
|
546
|
+
# ギャップ内に implicit_h 個を等間隔で配置(分割数 = implicit_h + 1)
|
|
547
|
+
for i in range(implicit_h):
|
|
548
|
+
seg = max_gap / (implicit_h + 1)
|
|
549
|
+
angle = gstart + (i + 1) * seg
|
|
550
|
+
# 折り返しを戻して 0..2pi に正規化
|
|
551
|
+
angle = angle % (2.0 * math.pi)
|
|
552
|
+
target_angles.append(angle)
|
|
553
|
+
except Exception:
|
|
554
|
+
# フォールバック: 単純な等間隔配置
|
|
555
|
+
for h_idx in range(implicit_h):
|
|
556
|
+
angle = (2.0 * math.pi * h_idx) / implicit_h
|
|
557
|
+
target_angles.append(angle)
|
|
558
|
+
|
|
559
|
+
# 角度から位置を計算して原子と結合を追加
|
|
560
|
+
for h_idx, angle in enumerate(target_angles):
|
|
561
|
+
dx = bond_length * math.cos(angle)
|
|
562
|
+
dy = bond_length * math.sin(angle)
|
|
563
|
+
pos = QPointF(parent_pos.x() + dx, parent_pos.y() + dy)
|
|
564
|
+
|
|
565
|
+
# 新しい水素原子を作成
|
|
566
|
+
try:
|
|
567
|
+
new_id = self.scene.create_atom('H', pos)
|
|
568
|
+
new_item = self.data.atoms[new_id]['item']
|
|
569
|
+
# bond_stereo を指定(最初は plain=0, 次に wedge/dash)
|
|
570
|
+
stereo = _choose_stereo(h_idx)
|
|
571
|
+
self.scene.create_bond(parent_item, new_item, bond_order=1, bond_stereo=stereo)
|
|
572
|
+
added_items.append(new_item)
|
|
573
|
+
added_count += 1
|
|
574
|
+
except Exception as e:
|
|
575
|
+
# 個々の追加失敗はログに残して続行
|
|
576
|
+
print(f"Failed to add H for atom {orig_id}: {e}")
|
|
577
|
+
|
|
578
|
+
if added_count > 0:
|
|
579
|
+
self.push_undo_state()
|
|
580
|
+
self.statusBar().showMessage(f"Added {added_count} hydrogen atoms.", 2000)
|
|
581
|
+
# 選択を有効化して追加した原子を選択状態にする
|
|
582
|
+
try:
|
|
583
|
+
self.scene.clearSelection()
|
|
584
|
+
for it in added_items:
|
|
585
|
+
it.setSelected(True)
|
|
586
|
+
except Exception:
|
|
587
|
+
pass
|
|
588
|
+
else:
|
|
589
|
+
self.statusBar().showMessage("No implicit hydrogens found to add.", 2000)
|
|
590
|
+
|
|
591
|
+
except Exception as e:
|
|
592
|
+
print(f"Error during hydrogen addition: {e}")
|
|
593
|
+
traceback.print_exc()
|
|
594
|
+
self.statusBar().showMessage(f"Error adding hydrogen atoms: {e}")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def update_edit_menu_actions(self):
|
|
599
|
+
"""選択状態やクリップボードの状態に応じて編集メニューを更新"""
|
|
600
|
+
try:
|
|
601
|
+
has_selection = len(self.scene.selectedItems()) > 0
|
|
602
|
+
self.cut_action.setEnabled(has_selection)
|
|
603
|
+
self.copy_action.setEnabled(has_selection)
|
|
604
|
+
|
|
605
|
+
clipboard = QApplication.clipboard()
|
|
606
|
+
mime_data = clipboard.mimeData()
|
|
607
|
+
self.paste_action.setEnabled(mime_data is not None and mime_data.hasFormat(CLIPBOARD_MIME_TYPE))
|
|
608
|
+
except RuntimeError:
|
|
609
|
+
pass
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def open_rotate_2d_dialog(self):
|
|
614
|
+
"""2D回転ダイアログを開く"""
|
|
615
|
+
dialog = Rotate2DDialog(self)
|
|
616
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
617
|
+
angle = dialog.get_angle()
|
|
618
|
+
self.rotate_molecule_2d(angle)
|
|
619
|
+
|
|
620
|
+
def rotate_molecule_2d(self, angle_degrees):
|
|
621
|
+
"""2D分子を指定角度回転させる(選択範囲があればそれのみ、なければ全体)"""
|
|
622
|
+
try:
|
|
623
|
+
# Determine target atoms
|
|
624
|
+
selected_items = self.scene.selectedItems()
|
|
625
|
+
target_atoms = [item for item in selected_items if isinstance(item, AtomItem)]
|
|
626
|
+
|
|
627
|
+
# If no selection, rotate everything
|
|
628
|
+
if not target_atoms:
|
|
629
|
+
target_atoms = [data['item'] for data in self.data.atoms.values() if data.get('item') and not sip_isdeleted_safe(data['item'])]
|
|
630
|
+
|
|
631
|
+
if not target_atoms:
|
|
632
|
+
self.statusBar().showMessage("No atoms to rotate.")
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# Calculate Center
|
|
636
|
+
xs = [atom.pos().x() for atom in target_atoms]
|
|
637
|
+
ys = [atom.pos().y() for atom in target_atoms]
|
|
638
|
+
if not xs: return
|
|
639
|
+
|
|
640
|
+
center_x = sum(xs) / len(xs)
|
|
641
|
+
center_y = sum(ys) / len(ys)
|
|
642
|
+
center = QPointF(center_x, center_y)
|
|
643
|
+
|
|
644
|
+
rad = math.radians(angle_degrees)
|
|
645
|
+
cos_a = math.cos(rad)
|
|
646
|
+
sin_a = math.sin(rad)
|
|
647
|
+
|
|
648
|
+
for atom in target_atoms:
|
|
649
|
+
# Relative pos
|
|
650
|
+
dx = atom.pos().x() - center_x
|
|
651
|
+
dy = atom.pos().y() - center_y
|
|
652
|
+
|
|
653
|
+
# Rotate
|
|
654
|
+
new_dx = dx * cos_a - dy * sin_a
|
|
655
|
+
new_dy = dx * sin_a + dy * cos_a
|
|
656
|
+
|
|
657
|
+
new_pos = QPointF(center_x + new_dx, center_y + new_dy)
|
|
658
|
+
atom.setPos(new_pos)
|
|
659
|
+
|
|
660
|
+
# Update bonds
|
|
661
|
+
self.scene.update_connected_bonds(target_atoms)
|
|
662
|
+
|
|
663
|
+
self.push_undo_state()
|
|
664
|
+
self.statusBar().showMessage(f"Rotated {len(target_atoms)} atoms by {angle_degrees} degrees.")
|
|
665
|
+
self.scene.update()
|
|
666
|
+
|
|
667
|
+
except Exception as e:
|
|
668
|
+
print(f"Error rotating molecule: {e}")
|
|
669
|
+
traceback.print_exc()
|
|
670
|
+
self.statusBar().showMessage(f"Error rotating: {e}")
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def select_all(self):
|
|
676
|
+
for item in self.scene.items():
|
|
677
|
+
if isinstance(item, (AtomItem, BondItem)):
|
|
678
|
+
item.setSelected(True)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def clear_all(self):
|
|
683
|
+
# 未保存の変更があるかチェック
|
|
684
|
+
if not self.check_unsaved_changes():
|
|
685
|
+
return # ユーザーがキャンセルした場合は何もしない
|
|
686
|
+
|
|
687
|
+
self.restore_ui_for_editing()
|
|
688
|
+
|
|
689
|
+
# データが存在しない場合は何もしない
|
|
690
|
+
if not self.data.atoms and self.current_mol is None:
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
# 3Dモードをリセット
|
|
694
|
+
if self.measurement_mode:
|
|
695
|
+
self.measurement_action.setChecked(False)
|
|
696
|
+
self.toggle_measurement_mode(False) # 測定モードを無効化
|
|
697
|
+
|
|
698
|
+
if self.is_3d_edit_mode:
|
|
699
|
+
self.edit_3d_action.setChecked(False)
|
|
700
|
+
self.toggle_3d_edit_mode(False) # 3D編集モードを無効化
|
|
701
|
+
|
|
702
|
+
# 3D原子選択をクリア
|
|
703
|
+
self.clear_3d_selection()
|
|
704
|
+
|
|
705
|
+
self.dragged_atom_info = None
|
|
706
|
+
|
|
707
|
+
# 2Dエディタをクリアする(Undoスタックにはプッシュしない)
|
|
708
|
+
self.clear_2d_editor(push_to_undo=False)
|
|
709
|
+
|
|
710
|
+
# 3Dモデルをクリアする
|
|
711
|
+
self.current_mol = None
|
|
712
|
+
self.plotter.clear()
|
|
713
|
+
self.constraints_3d = []
|
|
714
|
+
|
|
715
|
+
# 3D関連機能を統一的に無効化
|
|
716
|
+
self._enable_3d_features(False)
|
|
717
|
+
|
|
718
|
+
# Undo/Redoスタックをリセットする
|
|
719
|
+
self.reset_undo_stack()
|
|
720
|
+
|
|
721
|
+
# ファイル状態をリセット(新規ファイル状態に)
|
|
722
|
+
self.has_unsaved_changes = False
|
|
723
|
+
self.current_file_path = None
|
|
724
|
+
self.update_window_title()
|
|
725
|
+
|
|
726
|
+
# 2Dビューのズームをリセット
|
|
727
|
+
self.reset_zoom()
|
|
728
|
+
|
|
729
|
+
# シーンとビューの明示的な更新
|
|
730
|
+
self.scene.update()
|
|
731
|
+
if self.view_2d:
|
|
732
|
+
self.view_2d.viewport().update()
|
|
733
|
+
|
|
734
|
+
# 3D関連機能を統一的に無効化
|
|
735
|
+
self._enable_3d_features(False)
|
|
736
|
+
|
|
737
|
+
# 3Dプロッターの再描画
|
|
738
|
+
self.plotter.render()
|
|
739
|
+
|
|
740
|
+
# メニューテキストと状態を更新(分子がクリアされたので通常の表示に戻す)
|
|
741
|
+
self.update_atom_id_menu_text()
|
|
742
|
+
self.update_atom_id_menu_state()
|
|
743
|
+
|
|
744
|
+
# アプリケーションのイベントループを強制的に処理し、画面の再描画を確実に行う
|
|
745
|
+
QApplication.processEvents()
|
|
746
|
+
|
|
747
|
+
# Call plugin document reset handlers
|
|
748
|
+
if hasattr(self, 'plugin_manager') and self.plugin_manager:
|
|
749
|
+
self.plugin_manager.invoke_document_reset_handlers()
|
|
750
|
+
|
|
751
|
+
self.statusBar().showMessage("Cleared all data.")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def clear_2d_editor(self, push_to_undo=True):
|
|
756
|
+
self.data = MolecularData()
|
|
757
|
+
self.scene.data = self.data
|
|
758
|
+
self.scene.clear()
|
|
759
|
+
self.scene.reinitialize_items()
|
|
760
|
+
self.is_xyz_derived = False # 2Dエディタをクリアする際にXYZ由来フラグもリセット
|
|
761
|
+
|
|
762
|
+
# 測定ラベルもクリア
|
|
763
|
+
self.clear_2d_measurement_labels()
|
|
764
|
+
|
|
765
|
+
# Clear 3D data and disable 3D-related menus
|
|
766
|
+
self.current_mol = None
|
|
767
|
+
self.plotter.clear()
|
|
768
|
+
# 3D関連機能を統一的に無効化
|
|
769
|
+
self._enable_3d_features(False)
|
|
770
|
+
|
|
771
|
+
if push_to_undo:
|
|
772
|
+
self.push_undo_state()
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def update_implicit_hydrogens(self):
|
|
777
|
+
"""現在の2D構造に基づいて各原子の暗黙の水素数を計算し、AtomItemに反映する"""
|
|
778
|
+
# Quick guards: nothing to do if no atoms or no QApplication
|
|
779
|
+
if not self.data.atoms:
|
|
780
|
+
return
|
|
781
|
+
|
|
782
|
+
# If called from non-GUI thread, schedule the heavy RDKit work here but
|
|
783
|
+
# always perform UI mutations on the main thread via QTimer.singleShot.
|
|
784
|
+
try:
|
|
785
|
+
# Bump a local token to identify this request. The closure we
|
|
786
|
+
# schedule below will capture `my_token` and will only apply UI
|
|
787
|
+
# changes if the token still matches the most recent global
|
|
788
|
+
# counter. This avoids applying stale updates after deletions or
|
|
789
|
+
# teardown.
|
|
790
|
+
try:
|
|
791
|
+
self._ih_update_counter += 1
|
|
792
|
+
except Exception:
|
|
793
|
+
self._ih_update_counter = getattr(self, '_ih_update_counter', 0) or 1
|
|
794
|
+
my_token = self._ih_update_counter
|
|
795
|
+
|
|
796
|
+
mol = None
|
|
797
|
+
try:
|
|
798
|
+
mol = self.data.to_rdkit_mol()
|
|
799
|
+
except Exception:
|
|
800
|
+
mol = None
|
|
801
|
+
|
|
802
|
+
# Build a mapping of original_id -> hydrogen count without touching Qt items
|
|
803
|
+
h_count_map = {}
|
|
804
|
+
|
|
805
|
+
if mol is None:
|
|
806
|
+
# Invalid/unsanitizable structure: reset all counts to 0
|
|
807
|
+
for atom_id in list(self.data.atoms.keys()):
|
|
808
|
+
h_count_map[atom_id] = 0
|
|
809
|
+
else:
|
|
810
|
+
for atom in mol.GetAtoms():
|
|
811
|
+
try:
|
|
812
|
+
if not atom.HasProp("_original_atom_id"):
|
|
813
|
+
continue
|
|
814
|
+
original_id = atom.GetIntProp("_original_atom_id")
|
|
815
|
+
|
|
816
|
+
# Robust retrieval of H counts: prefer implicit, fallback to total or 0
|
|
817
|
+
try:
|
|
818
|
+
h_count = int(atom.GetNumImplicitHs())
|
|
819
|
+
except Exception:
|
|
820
|
+
try:
|
|
821
|
+
h_count = int(atom.GetTotalNumHs())
|
|
822
|
+
except Exception:
|
|
823
|
+
h_count = 0
|
|
824
|
+
|
|
825
|
+
h_count_map[int(original_id)] = h_count
|
|
826
|
+
except Exception:
|
|
827
|
+
# Skip problematic RDKit atoms
|
|
828
|
+
continue
|
|
829
|
+
|
|
830
|
+
# Compute a per-atom problem map (original_id -> bool) so the
|
|
831
|
+
# UI closure can safely set AtomItem.has_problem on the main thread.
|
|
832
|
+
problem_map = {}
|
|
833
|
+
try:
|
|
834
|
+
if mol is not None:
|
|
835
|
+
try:
|
|
836
|
+
problems = Chem.DetectChemistryProblems(mol)
|
|
837
|
+
except Exception:
|
|
838
|
+
problems = None
|
|
839
|
+
|
|
840
|
+
if problems:
|
|
841
|
+
for prob in problems:
|
|
842
|
+
try:
|
|
843
|
+
atom_idx = prob.GetAtomIdx()
|
|
844
|
+
rd_atom = mol.GetAtomWithIdx(atom_idx)
|
|
845
|
+
if rd_atom and rd_atom.HasProp("_original_atom_id"):
|
|
846
|
+
orig = int(rd_atom.GetIntProp("_original_atom_id"))
|
|
847
|
+
problem_map[orig] = True
|
|
848
|
+
except Exception:
|
|
849
|
+
continue
|
|
850
|
+
else:
|
|
851
|
+
# Fallback: use a lightweight valence heuristic similar to
|
|
852
|
+
# check_chemistry_problems_fallback() so we still flag atoms
|
|
853
|
+
# when RDKit conversion wasn't possible.
|
|
854
|
+
for atom_id, atom_data in self.data.atoms.items():
|
|
855
|
+
try:
|
|
856
|
+
symbol = atom_data.get('symbol')
|
|
857
|
+
charge = atom_data.get('charge', 0)
|
|
858
|
+
bond_count = 0
|
|
859
|
+
for (id1, id2), bond_data in self.data.bonds.items():
|
|
860
|
+
if id1 == atom_id or id2 == atom_id:
|
|
861
|
+
bond_count += bond_data.get('order', 1)
|
|
862
|
+
|
|
863
|
+
is_problematic = False
|
|
864
|
+
if symbol == 'C' and bond_count > 4:
|
|
865
|
+
is_problematic = True
|
|
866
|
+
elif symbol == 'N' and bond_count > 3 and charge == 0:
|
|
867
|
+
is_problematic = True
|
|
868
|
+
elif symbol == 'O' and bond_count > 2 and charge == 0:
|
|
869
|
+
is_problematic = True
|
|
870
|
+
elif symbol == 'H' and bond_count > 1:
|
|
871
|
+
is_problematic = True
|
|
872
|
+
elif symbol in ['F', 'Cl', 'Br', 'I'] and bond_count > 1 and charge == 0:
|
|
873
|
+
is_problematic = True
|
|
874
|
+
|
|
875
|
+
if is_problematic:
|
|
876
|
+
problem_map[atom_id] = True
|
|
877
|
+
except Exception:
|
|
878
|
+
continue
|
|
879
|
+
except Exception:
|
|
880
|
+
# If any unexpected error occurs while building the map, fall back
|
|
881
|
+
# to an empty map so we don't accidentally crash the UI.
|
|
882
|
+
problem_map = {}
|
|
883
|
+
|
|
884
|
+
# Schedule UI updates on the main thread to avoid calling Qt methods from
|
|
885
|
+
# background threads or during teardown (which can crash the C++ layer).
|
|
886
|
+
def _apply_ui_updates():
|
|
887
|
+
# If the global counter changed since this closure was
|
|
888
|
+
# created, bail out — the update is stale.
|
|
889
|
+
try:
|
|
890
|
+
if my_token != getattr(self, '_ih_update_counter', None):
|
|
891
|
+
return
|
|
892
|
+
except Exception:
|
|
893
|
+
# If anything goes wrong checking the token, be conservative
|
|
894
|
+
# and skip the update to avoid touching possibly-damaged
|
|
895
|
+
# Qt wrappers.
|
|
896
|
+
return
|
|
897
|
+
|
|
898
|
+
# Work on a shallow copy/snapshot of the data.atoms mapping so
|
|
899
|
+
# that concurrent mutations won't raise KeyError during
|
|
900
|
+
# iteration. We still defensively check each item below.
|
|
901
|
+
try:
|
|
902
|
+
atoms_snapshot = dict(self.data.atoms)
|
|
903
|
+
except Exception:
|
|
904
|
+
atoms_snapshot = {}
|
|
905
|
+
# Prefer the module-level SIP helper to avoid repeated imports
|
|
906
|
+
# and centralize exception handling. Use the safe wrapper
|
|
907
|
+
# `sip_isdeleted_safe` provided by the package which already
|
|
908
|
+
# handles the optional presence of sip.isdeleted.
|
|
909
|
+
is_deleted_func = sip_isdeleted_safe
|
|
910
|
+
|
|
911
|
+
items_to_update = []
|
|
912
|
+
for atom_id, atom_data in atoms_snapshot.items():
|
|
913
|
+
try:
|
|
914
|
+
item = atom_data.get('item')
|
|
915
|
+
if not item:
|
|
916
|
+
continue
|
|
917
|
+
|
|
918
|
+
# If sip.isdeleted is available, skip deleted C++ wrappers
|
|
919
|
+
try:
|
|
920
|
+
if is_deleted_func and is_deleted_func(item):
|
|
921
|
+
continue
|
|
922
|
+
except Exception:
|
|
923
|
+
# If sip check itself fails, continue with other lightweight guards
|
|
924
|
+
pass
|
|
925
|
+
|
|
926
|
+
# If the item is no longer in a scene, skip updating it to avoid
|
|
927
|
+
# touching partially-deleted objects during scene teardown.
|
|
928
|
+
try:
|
|
929
|
+
sc = item.scene() if hasattr(item, 'scene') else None
|
|
930
|
+
if sc is None:
|
|
931
|
+
continue
|
|
932
|
+
except Exception:
|
|
933
|
+
# Accessing scene() might fail for a damaged object; skip it
|
|
934
|
+
continue
|
|
935
|
+
|
|
936
|
+
# Desired new count (default to 0 if not computed)
|
|
937
|
+
new_count = h_count_map.get(atom_id, 0)
|
|
938
|
+
|
|
939
|
+
current = getattr(item, 'implicit_h_count', None)
|
|
940
|
+
current_prob = getattr(item, 'has_problem', False)
|
|
941
|
+
desired_prob = problem_map.get(atom_id, False)
|
|
942
|
+
|
|
943
|
+
# If neither the implicit-H count nor the problem flag
|
|
944
|
+
# changed, skip this item.
|
|
945
|
+
if current == new_count and current_prob == desired_prob:
|
|
946
|
+
continue
|
|
947
|
+
|
|
948
|
+
# Only prepare a geometry change if the implicit H count
|
|
949
|
+
# changes (this may affect the item's bounding rect).
|
|
950
|
+
need_geometry = (current != new_count)
|
|
951
|
+
try:
|
|
952
|
+
if need_geometry and hasattr(item, 'prepareGeometryChange'):
|
|
953
|
+
try:
|
|
954
|
+
item.prepareGeometryChange()
|
|
955
|
+
except Exception:
|
|
956
|
+
pass
|
|
957
|
+
|
|
958
|
+
# Apply implicit hydrogen count (guarded)
|
|
959
|
+
try:
|
|
960
|
+
item.implicit_h_count = new_count
|
|
961
|
+
except Exception:
|
|
962
|
+
# If setting the count fails, continue but still
|
|
963
|
+
# attempt to set the problem flag below.
|
|
964
|
+
pass
|
|
965
|
+
|
|
966
|
+
# Apply problem flag (visual red-outline)
|
|
967
|
+
try:
|
|
968
|
+
item.has_problem = bool(desired_prob)
|
|
969
|
+
except Exception:
|
|
970
|
+
pass
|
|
971
|
+
|
|
972
|
+
# Ensure the item is updated in the scene so paint() runs
|
|
973
|
+
# when either geometry or problem-flag changed.
|
|
974
|
+
items_to_update.append(item)
|
|
975
|
+
except Exception:
|
|
976
|
+
# Non-fatal: skip problematic items
|
|
977
|
+
continue
|
|
978
|
+
|
|
979
|
+
except Exception:
|
|
980
|
+
continue
|
|
981
|
+
|
|
982
|
+
# Trigger updates once for unique items; wrap in try/except to avoid crashes
|
|
983
|
+
# Trigger updates once for unique items; dedupe by object id so
|
|
984
|
+
# we don't attempt to hash QGraphicsItem wrappers which may
|
|
985
|
+
# behave oddly when partially deleted.
|
|
986
|
+
seen = set()
|
|
987
|
+
for it in items_to_update:
|
|
988
|
+
try:
|
|
989
|
+
if it is None:
|
|
990
|
+
continue
|
|
991
|
+
oid = id(it)
|
|
992
|
+
if oid in seen:
|
|
993
|
+
continue
|
|
994
|
+
seen.add(oid)
|
|
995
|
+
if hasattr(it, 'update'):
|
|
996
|
+
try:
|
|
997
|
+
it.update()
|
|
998
|
+
except Exception:
|
|
999
|
+
# ignore update errors for robustness
|
|
1000
|
+
pass
|
|
1001
|
+
except Exception:
|
|
1002
|
+
# Ignore any unexpected errors when touching the item
|
|
1003
|
+
continue
|
|
1004
|
+
|
|
1005
|
+
# Always schedule on main thread asynchronously
|
|
1006
|
+
try:
|
|
1007
|
+
QTimer.singleShot(0, _apply_ui_updates)
|
|
1008
|
+
except Exception:
|
|
1009
|
+
# Fallback: try to call directly (best-effort)
|
|
1010
|
+
try:
|
|
1011
|
+
_apply_ui_updates()
|
|
1012
|
+
except Exception:
|
|
1013
|
+
pass
|
|
1014
|
+
|
|
1015
|
+
except Exception:
|
|
1016
|
+
# Make sure update failures never crash the application
|
|
1017
|
+
pass
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def clean_up_2d_structure(self):
|
|
1023
|
+
self.statusBar().showMessage("Optimizing 2D structure...")
|
|
1024
|
+
|
|
1025
|
+
# 最初に既存の化学的問題フラグをクリア
|
|
1026
|
+
self.scene.clear_all_problem_flags()
|
|
1027
|
+
|
|
1028
|
+
# 2Dエディタに原子が存在しない場合
|
|
1029
|
+
if not self.data.atoms:
|
|
1030
|
+
self.statusBar().showMessage("Error: No atoms to optimize.")
|
|
1031
|
+
return
|
|
1032
|
+
|
|
1033
|
+
mol = self.data.to_rdkit_mol()
|
|
1034
|
+
if mol is None or mol.GetNumAtoms() == 0:
|
|
1035
|
+
# RDKit変換が失敗した場合は化学的問題をチェック
|
|
1036
|
+
self.check_chemistry_problems_fallback()
|
|
1037
|
+
return
|
|
1038
|
+
|
|
1039
|
+
try:
|
|
1040
|
+
# 安定版:原子IDとRDKit座標の確実なマッピング
|
|
1041
|
+
view_center = self.view_2d.mapToScene(self.view_2d.viewport().rect().center())
|
|
1042
|
+
new_positions_map = {}
|
|
1043
|
+
AllChem.Compute2DCoords(mol)
|
|
1044
|
+
conf = mol.GetConformer()
|
|
1045
|
+
for rdkit_atom in mol.GetAtoms():
|
|
1046
|
+
original_id = rdkit_atom.GetIntProp("_original_atom_id")
|
|
1047
|
+
new_positions_map[original_id] = conf.GetAtomPosition(rdkit_atom.GetIdx())
|
|
1048
|
+
|
|
1049
|
+
if not new_positions_map:
|
|
1050
|
+
self.statusBar().showMessage("Optimization failed to generate coordinates."); return
|
|
1051
|
+
|
|
1052
|
+
target_atom_items = [self.data.atoms[atom_id]['item'] for atom_id in new_positions_map.keys() if atom_id in self.data.atoms and 'item' in self.data.atoms[atom_id]]
|
|
1053
|
+
if not target_atom_items:
|
|
1054
|
+
self.statusBar().showMessage("Error: Atom items not found for optimized atoms."); return
|
|
1055
|
+
|
|
1056
|
+
# 元の図形の中心を維持
|
|
1057
|
+
#original_center_x = sum(item.pos().x() for item in target_atom_items) / len(target_atom_items)
|
|
1058
|
+
#original_center_y = sum(item.pos().y() for item in target_atom_items) / len(target_atom_items)
|
|
1059
|
+
|
|
1060
|
+
positions = list(new_positions_map.values())
|
|
1061
|
+
rdkit_cx = sum(p.x for p in positions) / len(positions)
|
|
1062
|
+
rdkit_cy = sum(p.y for p in positions) / len(positions)
|
|
1063
|
+
|
|
1064
|
+
SCALE = 50.0
|
|
1065
|
+
|
|
1066
|
+
# 新しい座標を適用
|
|
1067
|
+
for atom_id, rdkit_pos in new_positions_map.items():
|
|
1068
|
+
if atom_id in self.data.atoms:
|
|
1069
|
+
item = self.data.atoms[atom_id]['item']
|
|
1070
|
+
sx = ((rdkit_pos.x - rdkit_cx) * SCALE) + view_center.x()
|
|
1071
|
+
sy = (-(rdkit_pos.y - rdkit_cy) * SCALE) + view_center.y()
|
|
1072
|
+
new_scene_pos = QPointF(sx, sy)
|
|
1073
|
+
item.setPos(new_scene_pos)
|
|
1074
|
+
self.data.atoms[atom_id]['pos'] = new_scene_pos
|
|
1075
|
+
|
|
1076
|
+
# 最終的な座標に基づき、全ての結合表示を一度に更新
|
|
1077
|
+
# Guard against partially-deleted Qt wrappers: skip items that
|
|
1078
|
+
# SIP reports as deleted or which are no longer in a scene.
|
|
1079
|
+
for bond_data in self.data.bonds.values():
|
|
1080
|
+
item = bond_data.get('item') if bond_data else None
|
|
1081
|
+
if not item:
|
|
1082
|
+
continue
|
|
1083
|
+
try:
|
|
1084
|
+
# If SIP is available, skip wrappers whose C++ object is gone
|
|
1085
|
+
if sip_isdeleted_safe(item):
|
|
1086
|
+
continue
|
|
1087
|
+
except Exception:
|
|
1088
|
+
# If the sip check fails, continue with other lightweight guards
|
|
1089
|
+
pass
|
|
1090
|
+
try:
|
|
1091
|
+
sc = None
|
|
1092
|
+
try:
|
|
1093
|
+
sc = item.scene() if hasattr(item, 'scene') else None
|
|
1094
|
+
except Exception:
|
|
1095
|
+
sc = None
|
|
1096
|
+
if sc is None:
|
|
1097
|
+
continue
|
|
1098
|
+
try:
|
|
1099
|
+
item.update_position()
|
|
1100
|
+
except Exception:
|
|
1101
|
+
# Best-effort: skip any bond items that raise when updating
|
|
1102
|
+
continue
|
|
1103
|
+
except Exception:
|
|
1104
|
+
continue
|
|
1105
|
+
|
|
1106
|
+
# 重なり解消ロジックを実行
|
|
1107
|
+
self. resolve_overlapping_groups()
|
|
1108
|
+
|
|
1109
|
+
# 測定ラベルの位置を更新
|
|
1110
|
+
self.update_2d_measurement_labels()
|
|
1111
|
+
|
|
1112
|
+
# シーン全体の再描画を要求
|
|
1113
|
+
self.scene.update()
|
|
1114
|
+
|
|
1115
|
+
self.statusBar().showMessage("2D structure optimization successful.")
|
|
1116
|
+
self.push_undo_state()
|
|
1117
|
+
|
|
1118
|
+
except Exception as e:
|
|
1119
|
+
self.statusBar().showMessage(f"Error during 2D optimization: {e}")
|
|
1120
|
+
finally:
|
|
1121
|
+
self.view_2d.setFocus()
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def resolve_overlapping_groups(self):
|
|
1126
|
+
"""
|
|
1127
|
+
誤差範囲で完全に重なっている原子のグループを検出し、
|
|
1128
|
+
IDが大きい方のフラグメントを左下に平行移動して解消する。
|
|
1129
|
+
"""
|
|
1130
|
+
|
|
1131
|
+
# --- パラメータ設定 ---
|
|
1132
|
+
# 重なっているとみなす距離の閾値。構造に合わせて調整してください。
|
|
1133
|
+
OVERLAP_THRESHOLD = 0.5
|
|
1134
|
+
# 左下へ移動させる距離。
|
|
1135
|
+
MOVE_DISTANCE = 20
|
|
1136
|
+
|
|
1137
|
+
# self.data.atoms.values() から item を安全に取得
|
|
1138
|
+
all_atom_items = [
|
|
1139
|
+
data['item'] for data in self.data.atoms.values()
|
|
1140
|
+
if data and 'item' in data
|
|
1141
|
+
]
|
|
1142
|
+
|
|
1143
|
+
if len(all_atom_items) < 2:
|
|
1144
|
+
return
|
|
1145
|
+
|
|
1146
|
+
# --- ステップ1: 重なっている原子ペアを全てリストアップ ---
|
|
1147
|
+
overlapping_pairs = []
|
|
1148
|
+
for item1, item2 in itertools.combinations(all_atom_items, 2):
|
|
1149
|
+
# 結合で直接結ばれているペアは重なりと見なさない
|
|
1150
|
+
if self.scene.find_bond_between(item1, item2):
|
|
1151
|
+
continue
|
|
1152
|
+
|
|
1153
|
+
dist = QLineF(item1.pos(), item2.pos()).length()
|
|
1154
|
+
if dist < OVERLAP_THRESHOLD:
|
|
1155
|
+
overlapping_pairs.append((item1, item2))
|
|
1156
|
+
|
|
1157
|
+
if not overlapping_pairs:
|
|
1158
|
+
self.statusBar().showMessage("No overlapping atoms found.", 2000)
|
|
1159
|
+
return
|
|
1160
|
+
|
|
1161
|
+
# --- ステップ2: Union-Findアルゴリズムで重なりグループを構築 ---
|
|
1162
|
+
# 各原子がどのグループに属するかを管理する
|
|
1163
|
+
parent = {item.atom_id: item.atom_id for item in all_atom_items}
|
|
1164
|
+
|
|
1165
|
+
def find_set(atom_id):
|
|
1166
|
+
# atom_idが属するグループの代表(ルート)を見つける
|
|
1167
|
+
if parent[atom_id] == atom_id:
|
|
1168
|
+
return atom_id
|
|
1169
|
+
parent[atom_id] = find_set(parent[atom_id]) # 経路圧縮による最適化
|
|
1170
|
+
return parent[atom_id]
|
|
1171
|
+
|
|
1172
|
+
def unite_sets(id1, id2):
|
|
1173
|
+
# 2つの原子が属するグループを統合する
|
|
1174
|
+
root1 = find_set(id1)
|
|
1175
|
+
root2 = find_set(id2)
|
|
1176
|
+
if root1 != root2:
|
|
1177
|
+
parent[root2] = root1
|
|
1178
|
+
|
|
1179
|
+
for item1, item2 in overlapping_pairs:
|
|
1180
|
+
unite_sets(item1.atom_id, item2.atom_id)
|
|
1181
|
+
|
|
1182
|
+
# --- ステップ3: グループごとに移動計画を立てる ---
|
|
1183
|
+
# 同じ代表を持つ原子でグループを辞書にまとめる
|
|
1184
|
+
groups_by_root = {}
|
|
1185
|
+
for item in all_atom_items:
|
|
1186
|
+
root_id = find_set(item.atom_id)
|
|
1187
|
+
if root_id not in groups_by_root:
|
|
1188
|
+
groups_by_root[root_id] = []
|
|
1189
|
+
groups_by_root[root_id].append(item.atom_id)
|
|
1190
|
+
|
|
1191
|
+
move_operations = []
|
|
1192
|
+
processed_roots = set()
|
|
1193
|
+
|
|
1194
|
+
for root_id, group_atom_ids in groups_by_root.items():
|
|
1195
|
+
# 処理済みのグループや、メンバーが1つしかないグループはスキップ
|
|
1196
|
+
if root_id in processed_roots or len(group_atom_ids) < 2:
|
|
1197
|
+
continue
|
|
1198
|
+
processed_roots.add(root_id)
|
|
1199
|
+
|
|
1200
|
+
# 3a: グループを、結合に基づいたフラグメントに分割する (BFSを使用)
|
|
1201
|
+
fragments = []
|
|
1202
|
+
visited_in_group = set()
|
|
1203
|
+
group_atom_ids_set = set(group_atom_ids)
|
|
1204
|
+
|
|
1205
|
+
for atom_id in group_atom_ids:
|
|
1206
|
+
if atom_id not in visited_in_group:
|
|
1207
|
+
current_fragment = set()
|
|
1208
|
+
q = deque([atom_id])
|
|
1209
|
+
visited_in_group.add(atom_id)
|
|
1210
|
+
current_fragment.add(atom_id)
|
|
1211
|
+
|
|
1212
|
+
while q:
|
|
1213
|
+
current_id = q.popleft()
|
|
1214
|
+
# 隣接リスト self.adjacency_list があれば、ここでの探索が高速になります
|
|
1215
|
+
for neighbor_id in self.data.adjacency_list.get(current_id, []):
|
|
1216
|
+
if neighbor_id in group_atom_ids_set and neighbor_id not in visited_in_group:
|
|
1217
|
+
visited_in_group.add(neighbor_id)
|
|
1218
|
+
current_fragment.add(neighbor_id)
|
|
1219
|
+
q.append(neighbor_id)
|
|
1220
|
+
fragments.append(current_fragment)
|
|
1221
|
+
|
|
1222
|
+
if len(fragments) < 2:
|
|
1223
|
+
continue # 複数のフラグメントが重なっていない場合
|
|
1224
|
+
|
|
1225
|
+
# 3b: 移動するフラグメントを決定する
|
|
1226
|
+
# このグループの重なりの原因となった代表ペアを一つ探す
|
|
1227
|
+
rep_item1, rep_item2 = None, None
|
|
1228
|
+
for i1, i2 in overlapping_pairs:
|
|
1229
|
+
if find_set(i1.atom_id) == root_id:
|
|
1230
|
+
rep_item1, rep_item2 = i1, i2
|
|
1231
|
+
break
|
|
1232
|
+
|
|
1233
|
+
if not rep_item1: continue
|
|
1234
|
+
|
|
1235
|
+
# 代表ペアがそれぞれどのフラグメントに属するかを見つける
|
|
1236
|
+
frag1 = next((f for f in fragments if rep_item1.atom_id in f), None)
|
|
1237
|
+
frag2 = next((f for f in fragments if rep_item2.atom_id in f), None)
|
|
1238
|
+
|
|
1239
|
+
# 同一フラグメント内の重なりなどはスキップ
|
|
1240
|
+
if not frag1 or not frag2 or frag1 == frag2:
|
|
1241
|
+
continue
|
|
1242
|
+
|
|
1243
|
+
# 仕様: IDが大きい方の原子が含まれるフラグメントを動かす
|
|
1244
|
+
if rep_item1.atom_id > rep_item2.atom_id:
|
|
1245
|
+
ids_to_move = frag1
|
|
1246
|
+
else:
|
|
1247
|
+
ids_to_move = frag2
|
|
1248
|
+
|
|
1249
|
+
# 3c: 移動計画を作成
|
|
1250
|
+
translation_vector = QPointF(-MOVE_DISTANCE, MOVE_DISTANCE) # 左下方向へのベクトル
|
|
1251
|
+
move_operations.append((ids_to_move, translation_vector))
|
|
1252
|
+
|
|
1253
|
+
# --- ステップ4: 計画された移動を一度に実行 ---
|
|
1254
|
+
if not move_operations:
|
|
1255
|
+
self.statusBar().showMessage("No actionable overlaps found.", 2000)
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
for group_ids, vector in move_operations:
|
|
1259
|
+
for atom_id in group_ids:
|
|
1260
|
+
item = self.data.atoms[atom_id]['item']
|
|
1261
|
+
new_pos = item.pos() + vector
|
|
1262
|
+
item.setPos(new_pos)
|
|
1263
|
+
self.data.atoms[atom_id]['pos'] = new_pos
|
|
1264
|
+
|
|
1265
|
+
# --- ステップ5: 表示と状態を更新 ---
|
|
1266
|
+
for bond_data in self.data.bonds.values():
|
|
1267
|
+
item = bond_data.get('item') if bond_data else None
|
|
1268
|
+
if not item:
|
|
1269
|
+
continue
|
|
1270
|
+
try:
|
|
1271
|
+
if sip_isdeleted_safe(item):
|
|
1272
|
+
continue
|
|
1273
|
+
except Exception:
|
|
1274
|
+
pass
|
|
1275
|
+
try:
|
|
1276
|
+
sc = None
|
|
1277
|
+
try:
|
|
1278
|
+
sc = item.scene() if hasattr(item, 'scene') else None
|
|
1279
|
+
except Exception:
|
|
1280
|
+
sc = None
|
|
1281
|
+
if sc is None:
|
|
1282
|
+
continue
|
|
1283
|
+
try:
|
|
1284
|
+
item.update_position()
|
|
1285
|
+
except Exception:
|
|
1286
|
+
continue
|
|
1287
|
+
except Exception:
|
|
1288
|
+
continue
|
|
1289
|
+
|
|
1290
|
+
# 重なり解消後に測定ラベルの位置を更新
|
|
1291
|
+
self.update_2d_measurement_labels()
|
|
1292
|
+
|
|
1293
|
+
self.scene.update()
|
|
1294
|
+
self.push_undo_state()
|
|
1295
|
+
self.statusBar().showMessage("Resolved overlapping groups.", 2000)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def adjust_molecule_positions_to_avoid_collisions(self, mol, frags):
|
|
1301
|
+
"""
|
|
1302
|
+
複数分子の位置を調整して、衝突を回避する(バウンディングボックス最適化版)
|
|
1303
|
+
"""
|
|
1304
|
+
if len(frags) <= 1:
|
|
1305
|
+
return
|
|
1306
|
+
|
|
1307
|
+
conf = mol.GetConformer()
|
|
1308
|
+
pt = Chem.GetPeriodicTable()
|
|
1309
|
+
|
|
1310
|
+
# --- 1. 各フラグメントの情報(原子インデックス、VDW半径)を事前計算 ---
|
|
1311
|
+
frag_info = []
|
|
1312
|
+
for frag_indices in frags:
|
|
1313
|
+
positions = []
|
|
1314
|
+
vdw_radii = []
|
|
1315
|
+
for idx in frag_indices:
|
|
1316
|
+
pos = conf.GetAtomPosition(idx)
|
|
1317
|
+
positions.append(np.array([pos.x, pos.y, pos.z]))
|
|
1318
|
+
|
|
1319
|
+
atom = mol.GetAtomWithIdx(idx)
|
|
1320
|
+
# GetRvdw() はファンデルワールス半径を返す
|
|
1321
|
+
try:
|
|
1322
|
+
vdw_radii.append(pt.GetRvdw(atom.GetAtomicNum()))
|
|
1323
|
+
except RuntimeError:
|
|
1324
|
+
vdw_radii.append(1.5)
|
|
1325
|
+
|
|
1326
|
+
positions_np = np.array(positions)
|
|
1327
|
+
vdw_radii_np = np.array(vdw_radii)
|
|
1328
|
+
|
|
1329
|
+
# このフラグメントで最大のVDW半径を計算(ボックスのマージンとして使用)
|
|
1330
|
+
max_vdw = np.max(vdw_radii_np) if len(vdw_radii_np) > 0 else 0.0
|
|
1331
|
+
|
|
1332
|
+
frag_info.append({
|
|
1333
|
+
'indices': frag_indices,
|
|
1334
|
+
'centroid': np.mean(positions_np, axis=0),
|
|
1335
|
+
'positions_np': positions_np, # Numpy配列として保持
|
|
1336
|
+
'vdw_radii_np': vdw_radii_np, # Numpy配列として保持
|
|
1337
|
+
'max_vdw_radius': max_vdw,
|
|
1338
|
+
'bbox_min': np.zeros(3), # 後で計算
|
|
1339
|
+
'bbox_max': np.zeros(3) # 後で計算
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
# --- 2. 衝突判定のパラメータ ---
|
|
1343
|
+
collision_scale = 1.2 # VDW半径の120%
|
|
1344
|
+
max_iterations = 100
|
|
1345
|
+
moved = True
|
|
1346
|
+
iteration = 0
|
|
1347
|
+
|
|
1348
|
+
while moved and iteration < max_iterations:
|
|
1349
|
+
moved = False
|
|
1350
|
+
iteration += 1
|
|
1351
|
+
|
|
1352
|
+
# --- 3. フラグメントのバウンディングボックスを毎イテレーション更新 ---
|
|
1353
|
+
for i in range(len(frag_info)):
|
|
1354
|
+
# 現在の座標からボックスを再計算
|
|
1355
|
+
current_positions = []
|
|
1356
|
+
for idx in frag_info[i]['indices']:
|
|
1357
|
+
pos = conf.GetAtomPosition(idx)
|
|
1358
|
+
current_positions.append([pos.x, pos.y, pos.z])
|
|
1359
|
+
|
|
1360
|
+
positions_np = np.array(current_positions)
|
|
1361
|
+
frag_info[i]['positions_np'] = positions_np # 座標情報を更新
|
|
1362
|
+
|
|
1363
|
+
# VDW半径とスケールを考慮したマージンを計算
|
|
1364
|
+
# (最大VDW半径 * スケール) をマージンとして使う
|
|
1365
|
+
margin = frag_info[i]['max_vdw_radius'] * collision_scale
|
|
1366
|
+
|
|
1367
|
+
frag_info[i]['bbox_min'] = np.min(positions_np, axis=0) - margin
|
|
1368
|
+
frag_info[i]['bbox_max'] = np.max(positions_np, axis=0) + margin
|
|
1369
|
+
|
|
1370
|
+
# --- 4. 衝突判定ループ ---
|
|
1371
|
+
for i in range(len(frag_info)):
|
|
1372
|
+
for j in range(i + 1, len(frag_info)):
|
|
1373
|
+
frag_i = frag_info[i]
|
|
1374
|
+
frag_j = frag_info[j]
|
|
1375
|
+
|
|
1376
|
+
# === バウンディングボックス判定 ===
|
|
1377
|
+
# 2つのボックスが重なっているかチェック (AABB交差判定)
|
|
1378
|
+
# X, Y, Zの各軸で重なりをチェック
|
|
1379
|
+
overlap_x = (frag_i['bbox_min'][0] <= frag_j['bbox_max'][0] and frag_i['bbox_max'][0] >= frag_j['bbox_min'][0])
|
|
1380
|
+
overlap_y = (frag_i['bbox_min'][1] <= frag_j['bbox_max'][1] and frag_i['bbox_max'][1] >= frag_j['bbox_min'][1])
|
|
1381
|
+
overlap_z = (frag_i['bbox_min'][2] <= frag_j['bbox_max'][2] and frag_i['bbox_max'][2] >= frag_j['bbox_min'][2])
|
|
1382
|
+
|
|
1383
|
+
# ボックスがX, Y, Zのいずれかの軸で離れている場合、原子間の詳細なチェックをスキップ
|
|
1384
|
+
if not (overlap_x and overlap_y and overlap_z):
|
|
1385
|
+
continue
|
|
1386
|
+
# =================================
|
|
1387
|
+
|
|
1388
|
+
# ボックスが重なっている場合のみ、高コストな原子間の総当たりチェックを実行
|
|
1389
|
+
total_push_vector = np.zeros(3)
|
|
1390
|
+
collision_count = 0
|
|
1391
|
+
|
|
1392
|
+
# 事前計算したNumpy配列を使用
|
|
1393
|
+
positions_i = frag_i['positions_np']
|
|
1394
|
+
positions_j = frag_j['positions_np']
|
|
1395
|
+
vdw_i_all = frag_i['vdw_radii_np']
|
|
1396
|
+
vdw_j_all = frag_j['vdw_radii_np']
|
|
1397
|
+
|
|
1398
|
+
for k, idx_i in enumerate(frag_i['indices']):
|
|
1399
|
+
pos_i = positions_i[k]
|
|
1400
|
+
vdw_i = vdw_i_all[k]
|
|
1401
|
+
|
|
1402
|
+
for l, idx_j in enumerate(frag_j['indices']):
|
|
1403
|
+
pos_j = positions_j[l]
|
|
1404
|
+
vdw_j = vdw_j_all[l]
|
|
1405
|
+
|
|
1406
|
+
distance_vec = pos_i - pos_j
|
|
1407
|
+
distance_sq = np.dot(distance_vec, distance_vec) # 平方根を避けて高速化
|
|
1408
|
+
|
|
1409
|
+
min_distance = (vdw_i + vdw_j) * collision_scale
|
|
1410
|
+
min_distance_sq = min_distance * min_distance
|
|
1411
|
+
|
|
1412
|
+
if distance_sq < min_distance_sq and distance_sq > 0.0001:
|
|
1413
|
+
distance = np.sqrt(distance_sq)
|
|
1414
|
+
push_direction = distance_vec / distance
|
|
1415
|
+
push_magnitude = (min_distance - distance) / 2 # 押し出し量は半分ずつ
|
|
1416
|
+
total_push_vector += push_direction * push_magnitude
|
|
1417
|
+
collision_count += 1
|
|
1418
|
+
|
|
1419
|
+
if collision_count > 0:
|
|
1420
|
+
# 平均的な押し出しベクトルを適用
|
|
1421
|
+
avg_push_vector = total_push_vector / collision_count
|
|
1422
|
+
|
|
1423
|
+
# Conformerの座標を更新
|
|
1424
|
+
for idx in frag_i['indices']:
|
|
1425
|
+
pos = np.array(conf.GetAtomPosition(idx))
|
|
1426
|
+
new_pos = pos + avg_push_vector
|
|
1427
|
+
conf.SetAtomPosition(idx, new_pos.tolist())
|
|
1428
|
+
|
|
1429
|
+
for idx in frag_j['indices']:
|
|
1430
|
+
pos = np.array(conf.GetAtomPosition(idx))
|
|
1431
|
+
new_pos = pos - avg_push_vector
|
|
1432
|
+
conf.SetAtomPosition(idx, new_pos.tolist())
|
|
1433
|
+
|
|
1434
|
+
moved = True
|
|
1435
|
+
# (この移動により、このイテレーションで使う frag_info の座標キャッシュが古くなりますが、
|
|
1436
|
+
# 次のイテレーションの最初でボックスと共に再計算されるため問題ありません)
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _apply_chem_check_and_set_flags(self, mol, source_desc=None):
|
|
1441
|
+
"""Central helper to apply chemical sanitization (or skip it) and set
|
|
1442
|
+
chem_check_tried / chem_check_failed flags consistently.
|
|
1443
|
+
|
|
1444
|
+
When sanitization fails, a warning is shown and the Optimize 3D button
|
|
1445
|
+
is disabled. If the user setting 'skip_chemistry_checks' is True, no
|
|
1446
|
+
sanitization is attempted and both flags remain False.
|
|
1447
|
+
"""
|
|
1448
|
+
try:
|
|
1449
|
+
self.chem_check_tried = False
|
|
1450
|
+
self.chem_check_failed = False
|
|
1451
|
+
except Exception:
|
|
1452
|
+
# Ensure attributes exist even if called very early
|
|
1453
|
+
self.chem_check_tried = False
|
|
1454
|
+
self.chem_check_failed = False
|
|
1455
|
+
|
|
1456
|
+
if self.settings.get('skip_chemistry_checks', False):
|
|
1457
|
+
# User asked to skip chemistry checks entirely
|
|
1458
|
+
return
|
|
1459
|
+
|
|
1460
|
+
try:
|
|
1461
|
+
Chem.SanitizeMol(mol)
|
|
1462
|
+
self.chem_check_tried = True
|
|
1463
|
+
self.chem_check_failed = False
|
|
1464
|
+
except Exception:
|
|
1465
|
+
# Mark that we tried sanitization and it failed
|
|
1466
|
+
self.chem_check_tried = True
|
|
1467
|
+
self.chem_check_failed = True
|
|
1468
|
+
try:
|
|
1469
|
+
desc = f" ({source_desc})" if source_desc else ''
|
|
1470
|
+
self.statusBar().showMessage(f"Molecule sanitization failed{desc}; file may be malformed.")
|
|
1471
|
+
except Exception:
|
|
1472
|
+
pass
|
|
1473
|
+
# Disable 3D optimization UI to prevent running on invalid molecules
|
|
1474
|
+
if hasattr(self, 'optimize_3d_button'):
|
|
1475
|
+
try:
|
|
1476
|
+
self.optimize_3d_button.setEnabled(False)
|
|
1477
|
+
except Exception:
|
|
1478
|
+
pass
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _clear_xyz_flags(self, mol=None):
|
|
1483
|
+
"""Clear XYZ-derived markers from a molecule (or current_mol) and
|
|
1484
|
+
reset UI flags accordingly.
|
|
1485
|
+
|
|
1486
|
+
This is a best-effort cleanup to remove properties like
|
|
1487
|
+
_xyz_skip_checks and _xyz_atom_data that may have been attached when
|
|
1488
|
+
an XYZ file was previously loaded. After clearing molecule-level
|
|
1489
|
+
markers, the UI flag self.is_xyz_derived is set to False and the
|
|
1490
|
+
Optimize 3D button is re-evaluated (enabled unless chem_check_failed
|
|
1491
|
+
is True).
|
|
1492
|
+
"""
|
|
1493
|
+
target = mol if mol is not None else getattr(self, 'current_mol', None)
|
|
1494
|
+
try:
|
|
1495
|
+
if target is not None:
|
|
1496
|
+
# Remove RDKit property if present
|
|
1497
|
+
try:
|
|
1498
|
+
if hasattr(target, 'HasProp') and target.HasProp('_xyz_skip_checks'):
|
|
1499
|
+
try:
|
|
1500
|
+
target.ClearProp('_xyz_skip_checks')
|
|
1501
|
+
except Exception:
|
|
1502
|
+
try:
|
|
1503
|
+
target.SetIntProp('_xyz_skip_checks', 0)
|
|
1504
|
+
except Exception:
|
|
1505
|
+
pass
|
|
1506
|
+
except Exception:
|
|
1507
|
+
pass
|
|
1508
|
+
|
|
1509
|
+
# Remove attribute-style markers if present
|
|
1510
|
+
try:
|
|
1511
|
+
if hasattr(target, '_xyz_skip_checks'):
|
|
1512
|
+
try:
|
|
1513
|
+
delattr(target, '_xyz_skip_checks')
|
|
1514
|
+
except Exception:
|
|
1515
|
+
try:
|
|
1516
|
+
del target._xyz_skip_checks
|
|
1517
|
+
except Exception:
|
|
1518
|
+
try:
|
|
1519
|
+
target._xyz_skip_checks = False
|
|
1520
|
+
except Exception:
|
|
1521
|
+
pass
|
|
1522
|
+
except Exception:
|
|
1523
|
+
pass
|
|
1524
|
+
|
|
1525
|
+
try:
|
|
1526
|
+
if hasattr(target, '_xyz_atom_data'):
|
|
1527
|
+
try:
|
|
1528
|
+
delattr(target, '_xyz_atom_data')
|
|
1529
|
+
except Exception:
|
|
1530
|
+
try:
|
|
1531
|
+
del target._xyz_atom_data
|
|
1532
|
+
except Exception:
|
|
1533
|
+
try:
|
|
1534
|
+
target._xyz_atom_data = None
|
|
1535
|
+
except Exception:
|
|
1536
|
+
pass
|
|
1537
|
+
except Exception:
|
|
1538
|
+
pass
|
|
1539
|
+
|
|
1540
|
+
except Exception:
|
|
1541
|
+
# best-effort only
|
|
1542
|
+
pass
|
|
1543
|
+
|
|
1544
|
+
# Reset UI flags
|
|
1545
|
+
try:
|
|
1546
|
+
self.is_xyz_derived = False
|
|
1547
|
+
except Exception:
|
|
1548
|
+
pass
|
|
1549
|
+
|
|
1550
|
+
# Enable Optimize 3D unless sanitization failed
|
|
1551
|
+
try:
|
|
1552
|
+
if hasattr(self, 'optimize_3d_button'):
|
|
1553
|
+
if getattr(self, 'chem_check_failed', False):
|
|
1554
|
+
try:
|
|
1555
|
+
self.optimize_3d_button.setEnabled(False)
|
|
1556
|
+
except Exception:
|
|
1557
|
+
pass
|
|
1558
|
+
else:
|
|
1559
|
+
try:
|
|
1560
|
+
self.optimize_3d_button.setEnabled(True)
|
|
1561
|
+
except Exception:
|
|
1562
|
+
pass
|
|
1563
|
+
except Exception:
|
|
1564
|
+
pass
|
|
1565
|
+
|