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