MoleditPy-linux 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+