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