MoleditPy 1.15.1__py3-none-any.whl → 1.16.0__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 (55) hide show
  1. moleditpy/__init__.py +4 -0
  2. moleditpy/__main__.py +29 -19748
  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/atom_item.py +336 -0
  12. moleditpy/modules/bond_item.py +303 -0
  13. moleditpy/modules/bond_length_dialog.py +368 -0
  14. moleditpy/modules/calculation_worker.py +754 -0
  15. moleditpy/modules/color_settings_dialog.py +309 -0
  16. moleditpy/modules/constants.py +76 -0
  17. moleditpy/modules/constrained_optimization_dialog.py +667 -0
  18. moleditpy/modules/custom_interactor_style.py +737 -0
  19. moleditpy/modules/custom_qt_interactor.py +49 -0
  20. moleditpy/modules/dialog3_d_picking_mixin.py +96 -0
  21. moleditpy/modules/dihedral_dialog.py +431 -0
  22. moleditpy/modules/main_window.py +830 -0
  23. moleditpy/modules/main_window_app_state.py +747 -0
  24. moleditpy/modules/main_window_compute.py +1203 -0
  25. moleditpy/modules/main_window_dialog_manager.py +454 -0
  26. moleditpy/modules/main_window_edit_3d.py +531 -0
  27. moleditpy/modules/main_window_edit_actions.py +1449 -0
  28. moleditpy/modules/main_window_export.py +744 -0
  29. moleditpy/modules/main_window_main_init.py +1641 -0
  30. moleditpy/modules/main_window_molecular_parsers.py +956 -0
  31. moleditpy/modules/main_window_project_io.py +429 -0
  32. moleditpy/modules/main_window_string_importers.py +270 -0
  33. moleditpy/modules/main_window_ui_manager.py +567 -0
  34. moleditpy/modules/main_window_view_3d.py +1163 -0
  35. moleditpy/modules/main_window_view_loaders.py +350 -0
  36. moleditpy/modules/mirror_dialog.py +110 -0
  37. moleditpy/modules/molecular_data.py +290 -0
  38. moleditpy/modules/molecule_scene.py +1895 -0
  39. moleditpy/modules/move_group_dialog.py +586 -0
  40. moleditpy/modules/periodic_table_dialog.py +72 -0
  41. moleditpy/modules/planarize_dialog.py +209 -0
  42. moleditpy/modules/settings_dialog.py +1034 -0
  43. moleditpy/modules/template_preview_item.py +148 -0
  44. moleditpy/modules/template_preview_view.py +62 -0
  45. moleditpy/modules/translation_dialog.py +353 -0
  46. moleditpy/modules/user_template_dialog.py +621 -0
  47. moleditpy/modules/zoomable_view.py +98 -0
  48. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/METADATA +1 -1
  49. moleditpy-1.16.0.dist-info/RECORD +54 -0
  50. moleditpy-1.15.1.dist-info/RECORD +0 -9
  51. /moleditpy/{assets → modules/assets}/icon.ico +0 -0
  52. /moleditpy/{assets → modules/assets}/icon.png +0 -0
  53. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/WHEEL +0 -0
  54. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/entry_points.txt +0 -0
  55. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1895 @@
1
+ import traceback
2
+
3
+ from PyQt6.QtWidgets import (
4
+ QApplication, QGraphicsScene, QGraphicsItem,
5
+ QGraphicsLineItem
6
+ )
7
+
8
+ from PyQt6.QtGui import (
9
+ QPen, QCursor
10
+ )
11
+
12
+
13
+ from PyQt6.QtCore import (
14
+ Qt, QPointF, QRectF, QLineF
15
+ )
16
+ import math
17
+
18
+ try:
19
+ from .template_preview_item import TemplatePreviewItem
20
+ from .atom_item import AtomItem
21
+ from .bond_item import BondItem
22
+ except Exception:
23
+ from modules.template_preview_item import TemplatePreviewItem
24
+ from modules.atom_item import AtomItem
25
+ from modules.bond_item import BondItem
26
+
27
+ try:
28
+ from .constants import DEFAULT_BOND_LENGTH, SNAP_DISTANCE, SUM_TOLERANCE
29
+ except Exception:
30
+ from modules.constants import DEFAULT_BOND_LENGTH, SNAP_DISTANCE, SUM_TOLERANCE
31
+
32
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
33
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
34
+ # it once at module import time and expose a small, robust wrapper so callers
35
+ # can avoid re-importing sip repeatedly and so we centralize exception
36
+ # handling (this reduces crash risk during teardown and deletion operations).
37
+ try:
38
+ import sip as _sip # type: ignore
39
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
40
+ except Exception:
41
+ _sip = None
42
+ _sip_isdeleted = None
43
+
44
+ try:
45
+ from . import sip_isdeleted_safe
46
+ except Exception:
47
+ from modules import sip_isdeleted_safe
48
+
49
+
50
+ class MoleculeScene(QGraphicsScene):
51
+ def clear_template_preview(self):
52
+ """テンプレートプレビュー用のゴースト線を全て消す"""
53
+ for item in list(self.items()):
54
+ if isinstance(item, QGraphicsLineItem) and getattr(item, '_is_template_preview', False):
55
+ try:
56
+ # If SIP reports the wrapper as deleted, skip it. Otherwise
57
+ # ensure it is still in a scene before attempting removal.
58
+ if sip_isdeleted_safe(item):
59
+ continue
60
+ sc = None
61
+ try:
62
+ sc = item.scene() if hasattr(item, 'scene') else None
63
+ except Exception:
64
+ sc = None
65
+ if sc is None:
66
+ continue
67
+ try:
68
+ self.removeItem(item)
69
+ except Exception:
70
+ # Best-effort: ignore removal errors to avoid crashes during teardown
71
+ pass
72
+ except Exception:
73
+ # Non-fatal: continue with other items
74
+ continue
75
+ self.template_context = {}
76
+ if hasattr(self, 'template_preview'):
77
+ self.template_preview.hide()
78
+
79
+ def __init__(self, data, window):
80
+ super().__init__()
81
+ self.data, self.window = data, window
82
+ self.mode, self.current_atom_symbol = 'select', 'C'
83
+ self.bond_order, self.bond_stereo = 1, 0
84
+ self.start_atom, self.temp_line, self.start_pos = None, None, None; self.press_pos = None
85
+ self.mouse_moved_since_press = False
86
+ self.data_changed_in_event = False
87
+ self.hovered_item = None
88
+
89
+ self.key_to_symbol_map = {
90
+ Qt.Key.Key_C: 'C', Qt.Key.Key_N: 'N', Qt.Key.Key_O: 'O', Qt.Key.Key_S: 'S',
91
+ Qt.Key.Key_F: 'F', Qt.Key.Key_B: 'B', Qt.Key.Key_I: 'I', Qt.Key.Key_H: 'H',
92
+ Qt.Key.Key_P: 'P',
93
+ }
94
+ self.key_to_symbol_map_shift = { Qt.Key.Key_C: 'Cl', Qt.Key.Key_B: 'Br', Qt.Key.Key_S: 'Si',}
95
+
96
+ self.key_to_bond_mode_map = {
97
+ Qt.Key.Key_1: 'bond_1_0',
98
+ Qt.Key.Key_2: 'bond_2_0',
99
+ Qt.Key.Key_3: 'bond_3_0',
100
+ Qt.Key.Key_W: 'bond_1_1',
101
+ Qt.Key.Key_D: 'bond_1_2',
102
+ }
103
+ self.reinitialize_items()
104
+
105
+ def reinitialize_items(self):
106
+ self.template_preview = TemplatePreviewItem(); self.addItem(self.template_preview)
107
+ self.template_preview.hide(); self.template_preview_points = []; self.template_context = {}
108
+ # Hold strong references to deleted wrappers for the lifetime of the scene
109
+ # to avoid SIP/C++ finalization causing segfaults when Python still
110
+ # briefly touches those objects elsewhere in the app. Items collected
111
+ # here are hidden and never accessed again by normal code paths.
112
+ self._deleted_items = []
113
+ # Ensure we purge any held deleted-wrapper references when the
114
+ # application is shutting down. Connecting here is safe even if
115
+ # multiple scenes exist; the slot is defensive and idempotent.
116
+ try:
117
+ app = QApplication.instance()
118
+ if app is not None:
119
+ try:
120
+ app.aboutToQuit.connect(self.purge_deleted_items)
121
+ except Exception:
122
+ # If connecting fails for any reason, continue without
123
+ # the connection — at worst holders will be freed by
124
+ # process teardown.
125
+ pass
126
+ except Exception:
127
+ pass
128
+
129
+ def clear_all_problem_flags(self):
130
+ """全ての AtomItem の has_problem フラグをリセットし、再描画する"""
131
+ needs_update = False
132
+ for atom_data in self.data.atoms.values():
133
+ item = atom_data.get('item')
134
+ # hasattr は安全性のためのチェック
135
+ if item and hasattr(item, 'has_problem') and item.has_problem:
136
+ item.has_problem = False
137
+ item.update()
138
+ needs_update = True
139
+ return needs_update
140
+
141
+ def mousePressEvent(self, event):
142
+ self.press_pos = event.scenePos()
143
+ self.mouse_moved_since_press = False
144
+ self.data_changed_in_event = False
145
+
146
+ # 削除されたオブジェクトを安全にチェックして初期位置を記録
147
+ self.initial_positions_in_event = {}
148
+ for item in self.items():
149
+ if isinstance(item, AtomItem):
150
+ try:
151
+ self.initial_positions_in_event[item] = item.pos()
152
+ except RuntimeError:
153
+ # オブジェクトが削除されている場合はスキップ
154
+ continue
155
+
156
+ if not self.window.is_2d_editable:
157
+ return
158
+
159
+ if event.button() == Qt.MouseButton.RightButton:
160
+ item = self.itemAt(event.scenePos(), self.views()[0].transform())
161
+ if not isinstance(item, (AtomItem, BondItem)):
162
+ return # 対象外のものをクリックした場合は何もしない
163
+ data_changed = False
164
+ # If the user has a rectangular multi-selection and the clicked item
165
+ # is part of that selection, delete all selected items (atoms/bonds).
166
+ try:
167
+ selected_items = [it for it in self.selectedItems() if isinstance(it, (AtomItem, BondItem))]
168
+ except Exception:
169
+ selected_items = []
170
+
171
+ if len(selected_items) > 1 and item in selected_items and not self.mode.startswith(('template', 'charge', 'radical')):
172
+ # Delete the entire rectangular selection
173
+ data_changed = self.delete_items(set(selected_items))
174
+ if data_changed:
175
+ self.window.push_undo_state()
176
+ self.press_pos = None
177
+ event.accept()
178
+ return
179
+ # --- E/Zモード専用処理 ---
180
+ if self.mode == 'bond_2_5':
181
+ if isinstance(item, BondItem):
182
+ try:
183
+ # E/Zラベルを消す(ノーマルに戻す)
184
+ if item.stereo in [3, 4]:
185
+ item.set_stereo(0)
186
+ # データモデルも更新
187
+ for (id1, id2), bdata in self.data.bonds.items():
188
+ if bdata.get('item') is item:
189
+ bdata['stereo'] = 0
190
+ break
191
+ self.window.push_undo_state()
192
+ data_changed = False # ここでundo済みなので以降で積まない
193
+ except Exception as e:
194
+ print(f"Error clearing E/Z label: {e}")
195
+
196
+ traceback.print_exc()
197
+ if hasattr(self.window, 'statusBar'):
198
+ self.window.statusBar().showMessage(f"Error clearing E/Z label: {e}", 5000)
199
+ # AtomItemは何もしない
200
+ # --- 通常の処理 ---
201
+ elif isinstance(item, AtomItem):
202
+ # ラジカルモードの場合、ラジカルを0にする
203
+ if self.mode == 'radical' and item.radical != 0:
204
+ item.prepareGeometryChange()
205
+ item.radical = 0
206
+ self.data.atoms[item.atom_id]['radical'] = 0
207
+ item.update_style()
208
+ data_changed = True
209
+ # 電荷モードの場合、電荷を0にする
210
+ elif self.mode in ['charge_plus', 'charge_minus'] and item.charge != 0:
211
+ item.prepareGeometryChange()
212
+ item.charge = 0
213
+ self.data.atoms[item.atom_id]['charge'] = 0
214
+ item.update_style()
215
+ data_changed = True
216
+ # 上記以外のモード(テンプレート、電荷、ラジカルを除く)では原子を削除
217
+ elif not self.mode.startswith(('template', 'charge', 'radical')):
218
+ data_changed = self.delete_items({item})
219
+ elif isinstance(item, BondItem):
220
+ # テンプレート、電荷、ラジカルモード以外で結合を削除
221
+ if not self.mode.startswith(('template', 'charge', 'radical')):
222
+ data_changed = self.delete_items({item})
223
+
224
+ if data_changed:
225
+ self.window.push_undo_state()
226
+ self.press_pos = None
227
+ event.accept()
228
+ return # 右クリック処理を完了し、左クリックの処理へ進ませない
229
+
230
+ if self.mode.startswith('template'):
231
+ self.clearSelection() # テンプレートモードでは選択処理を一切行わず、クリック位置の記録のみ行う
232
+ return
233
+
234
+ # Z,Eモードの時は選択処理を行わないようにする
235
+ if self.mode in ['bond_2_5']:
236
+ self.clearSelection()
237
+ event.accept()
238
+ return
239
+
240
+ if getattr(self, "mode", "") != "select":
241
+ self.clearSelection()
242
+ event.accept()
243
+
244
+ item = self.itemAt(self.press_pos, self.views()[0].transform())
245
+
246
+ if isinstance(item, AtomItem):
247
+ self.start_atom = item
248
+ if self.mode != 'select':
249
+ self.clearSelection()
250
+ self.temp_line = QGraphicsLineItem(QLineF(self.start_atom.pos(), self.press_pos))
251
+ self.temp_line.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.DotLine))
252
+ self.addItem(self.temp_line)
253
+ else:
254
+ super().mousePressEvent(event)
255
+ elif item is None and (self.mode.startswith('atom') or self.mode.startswith('bond')):
256
+ self.start_pos = self.press_pos
257
+ self.temp_line = QGraphicsLineItem(QLineF(self.start_pos, self.press_pos)); self.temp_line.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.DotLine)); self.addItem(self.temp_line)
258
+ else:
259
+ super().mousePressEvent(event)
260
+
261
+ def mouseMoveEvent(self, event):
262
+ if not self.window.is_2d_editable:
263
+ return
264
+
265
+ if self.mode.startswith('template'):
266
+ self.update_template_preview(event.scenePos())
267
+
268
+ if not self.mouse_moved_since_press and self.press_pos:
269
+ if (event.scenePos() - self.press_pos).manhattanLength() > QApplication.startDragDistance():
270
+ self.mouse_moved_since_press = True
271
+
272
+ if self.temp_line and not self.mode.startswith('template'):
273
+ start_point = self.start_atom.pos() if self.start_atom else self.start_pos
274
+ if not start_point:
275
+ super().mouseMoveEvent(event)
276
+ return
277
+
278
+ current_pos = event.scenePos()
279
+ end_point = current_pos
280
+
281
+ target_atom = None
282
+ for item in self.items(current_pos):
283
+ if isinstance(item, AtomItem):
284
+ target_atom = item
285
+ break
286
+
287
+ is_valid_snap_target = (
288
+ target_atom is not None and
289
+ (self.start_atom is None or target_atom is not self.start_atom)
290
+ )
291
+
292
+ if is_valid_snap_target:
293
+ end_point = target_atom.pos()
294
+
295
+ self.temp_line.setLine(QLineF(start_point, end_point))
296
+ else:
297
+ # テンプレートモードであっても、ホバーイベントはここで伝播する
298
+ super().mouseMoveEvent(event)
299
+
300
+ def mouseReleaseEvent(self, event):
301
+ if not self.window.is_2d_editable:
302
+ return
303
+
304
+ end_pos = event.scenePos()
305
+ is_click = self.press_pos and (end_pos - self.press_pos).manhattanLength() < QApplication.startDragDistance()
306
+
307
+ if self.temp_line:
308
+ try:
309
+ if not sip_isdeleted_safe(self.temp_line):
310
+ try:
311
+ if getattr(self.temp_line, 'scene', None) and self.temp_line.scene():
312
+ self.removeItem(self.temp_line)
313
+ except Exception:
314
+ pass
315
+ except Exception:
316
+ try:
317
+ self.removeItem(self.temp_line)
318
+ except Exception:
319
+ pass
320
+ finally:
321
+ self.temp_line = None
322
+
323
+ if self.mode.startswith('template') and is_click:
324
+ if self.template_context and self.template_context.get('points'):
325
+ context = self.template_context
326
+ # Check if this is a user template
327
+ if self.mode.startswith('template_user'):
328
+ self.add_user_template_fragment(context)
329
+ else:
330
+ self.add_molecule_fragment(context['points'], context['bonds_info'], existing_items=context.get('items', []))
331
+ self.data_changed_in_event = True
332
+ # イベント処理をここで完了させ、下のアイテムが選択されるのを防ぐ
333
+ self.start_atom=None; self.start_pos = None; self.press_pos = None
334
+ if self.data_changed_in_event: self.window.push_undo_state()
335
+ return
336
+
337
+ released_item = self.itemAt(end_pos, self.views()[0].transform())
338
+
339
+ # 1. 特殊モード(ラジカル/電荷)の処理
340
+ if (self.mode == 'radical') and is_click and isinstance(released_item, AtomItem):
341
+ atom = released_item
342
+ atom.prepareGeometryChange()
343
+ # ラジカルの状態をトグル (0 -> 1 -> 2 -> 0)
344
+ atom.radical = (atom.radical + 1) % 3
345
+ self.data.atoms[atom.atom_id]['radical'] = atom.radical
346
+ atom.update_style()
347
+ self.data_changed_in_event = True
348
+ self.start_atom=None; self.start_pos = None; self.press_pos = None
349
+ if self.data_changed_in_event: self.window.push_undo_state()
350
+ return
351
+ elif (self.mode == 'charge_plus' or self.mode == 'charge_minus') and is_click and isinstance(released_item, AtomItem):
352
+ atom = released_item
353
+ atom.prepareGeometryChange()
354
+ delta = 1 if self.mode == 'charge_plus' else -1
355
+ atom.charge += delta
356
+ self.data.atoms[atom.atom_id]['charge'] = atom.charge
357
+ atom.update_style()
358
+ self.data_changed_in_event = True
359
+ self.start_atom=None; self.start_pos = None; self.press_pos = None
360
+ if self.data_changed_in_event: self.window.push_undo_state()
361
+ return
362
+
363
+ elif self.mode.startswith('bond') and is_click and isinstance(released_item, BondItem):
364
+ b = released_item
365
+ if self.mode == 'bond_2_5':
366
+ try:
367
+ if b.order == 2:
368
+ current_stereo = b.stereo
369
+ if current_stereo not in [3, 4]:
370
+ new_stereo = 3 # None -> Z
371
+ elif current_stereo == 3:
372
+ new_stereo = 4 # Z -> E
373
+ else: # current_stereo == 4
374
+ new_stereo = 0 # E -> None
375
+ self.update_bond_stereo(b, new_stereo)
376
+ self.window.push_undo_state() # ここでUndo stackに積む
377
+ except Exception as e:
378
+ print(f"Error in E/Z stereo toggle: {e}")
379
+
380
+ traceback.print_exc()
381
+ if hasattr(self.window, 'statusBar'):
382
+ self.window.statusBar().showMessage(f"Error changing E/Z stereochemistry: {e}", 5000)
383
+ return # この後の処理は行わない
384
+ elif self.bond_stereo != 0 and b.order == self.bond_order and b.stereo == self.bond_stereo:
385
+ # 方向性を反転させる
386
+ old_id1, old_id2 = b.atom1.atom_id, b.atom2.atom_id
387
+ # 1. 古い方向の結合をデータから削除
388
+ self.data.remove_bond(old_id1, old_id2)
389
+ # 2. 逆方向で結合をデータに再追加
390
+ new_key, _ = self.data.add_bond(old_id2, old_id1, self.bond_order, self.bond_stereo)
391
+ # 3. BondItemの原子参照を入れ替え、新しいデータと関連付ける
392
+ b.atom1, b.atom2 = b.atom2, b.atom1
393
+ self.data.bonds[new_key]['item'] = b
394
+ # 4. 見た目を更新
395
+ b.update_position()
396
+ else:
397
+ # 既存の結合を一度削除
398
+ self.data.remove_bond(b.atom1.atom_id, b.atom2.atom_id)
399
+ # BondItemが記憶している方向(b.atom1 -> b.atom2)で、新しい結合様式を再作成
400
+ # これにより、修正済みのadd_bondが呼ばれ、正しい方向で保存される
401
+ new_key, _ = self.data.add_bond(b.atom1.atom_id, b.atom2.atom_id, self.bond_order, self.bond_stereo)
402
+ # BondItemの見た目とデータ参照を更新
403
+ b.prepareGeometryChange()
404
+ b.order = self.bond_order
405
+ b.stereo = self.bond_stereo
406
+ self.data.bonds[new_key]['item'] = b
407
+ b.update()
408
+ self.clearSelection()
409
+ self.data_changed_in_event = True
410
+ # 3. 新規原子・結合の作成処理 (atom_* モード および すべての bond_* モードで許可)
411
+ elif self.start_atom and (self.mode.startswith('atom') or self.mode.startswith('bond')):
412
+ line = QLineF(self.start_atom.pos(), end_pos); end_item = self.itemAt(end_pos, self.views()[0].transform())
413
+ # 使用する結合様式を決定
414
+ # atomモードの場合は bond_order/stereo を None にして create_bond にデフォルト値(1, 0)を適用
415
+ # bond_* モードの場合は現在の設定 (self.bond_order/stereo) を使用
416
+ order_to_use = self.bond_order if self.mode.startswith('bond') else None
417
+ stereo_to_use = self.bond_stereo if self.mode.startswith('bond') else None
418
+ if is_click:
419
+ # 短いクリック: 既存原子のシンボル更新 (atomモードのみ)
420
+ if self.mode.startswith('atom') and self.start_atom.symbol != self.current_atom_symbol:
421
+ self.start_atom.symbol=self.current_atom_symbol; self.data.atoms[self.start_atom.atom_id]['symbol']=self.current_atom_symbol; self.start_atom.update_style()
422
+ self.data_changed_in_event = True
423
+ else:
424
+ # ドラッグ: 新規結合または既存原子への結合
425
+ if isinstance(end_item, AtomItem) and self.start_atom!=end_item:
426
+ self.create_bond(self.start_atom, end_item, bond_order=order_to_use, bond_stereo=stereo_to_use)
427
+ else:
428
+ new_id = self.create_atom(self.current_atom_symbol, end_pos); new_item = self.data.atoms[new_id]['item']
429
+ self.create_bond(self.start_atom, new_item, bond_order=order_to_use, bond_stereo=stereo_to_use)
430
+ self.data_changed_in_event = True
431
+ # 4. 空白領域からの新規作成処理 (atom_* モード および すべての bond_* モードで許可)
432
+ elif self.start_pos and (self.mode.startswith('atom') or self.mode.startswith('bond')):
433
+ line = QLineF(self.start_pos, end_pos)
434
+ # 使用する結合様式を決定
435
+ order_to_use = self.bond_order if self.mode.startswith('bond') else None
436
+ stereo_to_use = self.bond_stereo if self.mode.startswith('bond') else None
437
+ if line.length() < 10:
438
+ self.create_atom(self.current_atom_symbol, end_pos); self.data_changed_in_event = True
439
+ else:
440
+ end_item = self.itemAt(end_pos, self.views()[0].transform())
441
+ if isinstance(end_item, AtomItem):
442
+ start_id = self.create_atom(self.current_atom_symbol, self.start_pos)
443
+ start_item = self.data.atoms[start_id]['item']
444
+ self.create_bond(start_item, end_item, bond_order=order_to_use, bond_stereo=stereo_to_use)
445
+ else:
446
+ start_id = self.create_atom(self.current_atom_symbol, self.start_pos)
447
+ end_id = self.create_atom(self.current_atom_symbol, end_pos)
448
+ self.create_bond(
449
+ self.data.atoms[start_id]['item'],
450
+ self.data.atoms[end_id]['item'],
451
+ bond_order=order_to_use,
452
+ bond_stereo=stereo_to_use
453
+ )
454
+ self.data_changed_in_event = True
455
+ # 5. それ以外の処理 (Selectモードなど)
456
+ else: super().mouseReleaseEvent(event)
457
+
458
+ # 削除されたオブジェクトを安全にチェック
459
+ moved_atoms = []
460
+ for item, old_pos in self.initial_positions_in_event.items():
461
+ try:
462
+ # オブジェクトが有効で、シーンに存在し、位置が変更されているかチェック
463
+ if item.scene() and item.pos() != old_pos:
464
+ moved_atoms.append(item)
465
+ except RuntimeError:
466
+ # オブジェクトが削除されている場合はスキップ
467
+ continue
468
+ if moved_atoms:
469
+ self.data_changed_in_event = True
470
+ bonds_to_update = set()
471
+ for atom in moved_atoms:
472
+ try:
473
+ self.data.atoms[atom.atom_id]['pos'] = atom.pos()
474
+ bonds_to_update.update(atom.bonds)
475
+ except RuntimeError:
476
+ # オブジェクトが削除されている場合はスキップ
477
+ continue
478
+ for bond in bonds_to_update: bond.update_position()
479
+ # 原子移動後に測定ラベルの位置を更新
480
+ self.window.update_2d_measurement_labels()
481
+ if self.views(): self.views()[0].viewport().update()
482
+ self.start_atom=None; self.start_pos = None; self.press_pos = None; self.temp_line = None
483
+ self.template_context = {}
484
+ # Clear user template data when switching modes
485
+ if hasattr(self, 'user_template_data'):
486
+ self.user_template_data = None
487
+ if self.data_changed_in_event: self.window.push_undo_state()
488
+
489
+ def mouseDoubleClickEvent(self, event):
490
+ """ダブルクリックイベントを処理する"""
491
+ item = self.itemAt(event.scenePos(), self.views()[0].transform())
492
+
493
+ if self.mode in ['charge_plus', 'charge_minus', 'radical'] and isinstance(item, AtomItem):
494
+ if self.mode == 'radical':
495
+ item.prepareGeometryChange()
496
+ item.radical = (item.radical + 1) % 3
497
+ self.data.atoms[item.atom_id]['radical'] = item.radical
498
+ item.update_style()
499
+ else:
500
+ item.prepareGeometryChange()
501
+ delta = 1 if self.mode == 'charge_plus' else -1
502
+ item.charge += delta
503
+ self.data.atoms[item.atom_id]['charge'] = item.charge
504
+ item.update_style()
505
+
506
+ self.window.push_undo_state()
507
+
508
+ event.accept()
509
+ return
510
+
511
+ # Select-mode: double-click should select the clicked atom/bond and
512
+ # only the atoms/bonds connected to it (the connected component).
513
+ if self.mode == 'select' and isinstance(item, (AtomItem, BondItem)):
514
+ try:
515
+ start_atoms = set()
516
+ if isinstance(item, AtomItem):
517
+ start_atoms.add(item)
518
+ else:
519
+ # BondItem: start from both ends if available
520
+ a1 = getattr(item, 'atom1', None)
521
+ a2 = getattr(item, 'atom2', None)
522
+ if a1 is not None:
523
+ start_atoms.add(a1)
524
+ if a2 is not None:
525
+ start_atoms.add(a2)
526
+
527
+ # BFS/DFS over atoms via bond references (defensive checks)
528
+ atoms_to_visit = list(start_atoms)
529
+ connected_atoms = set()
530
+ connected_bonds = set()
531
+
532
+ while atoms_to_visit:
533
+ a = atoms_to_visit.pop()
534
+ if a is None:
535
+ continue
536
+ if a in connected_atoms:
537
+ continue
538
+ connected_atoms.add(a)
539
+ # iterate bonds attached to atom
540
+ for b in getattr(a, 'bonds', []) or []:
541
+ if b is None:
542
+ continue
543
+ connected_bonds.add(b)
544
+ # find the other atom at the bond
545
+ other = None
546
+ try:
547
+ if getattr(b, 'atom1', None) is a:
548
+ other = getattr(b, 'atom2', None)
549
+ else:
550
+ other = getattr(b, 'atom1', None)
551
+ except Exception:
552
+ other = None
553
+ if other is not None and other not in connected_atoms:
554
+ atoms_to_visit.append(other)
555
+
556
+ # Apply selection: clear previous and select only these
557
+ try:
558
+ self.clearSelection()
559
+ except Exception:
560
+ pass
561
+
562
+ for a in connected_atoms:
563
+ try:
564
+ a.setSelected(True)
565
+ except Exception:
566
+ try:
567
+ # fallback: set selected attribute if exists
568
+ setattr(a, 'selected', True)
569
+ except Exception:
570
+ pass
571
+ for b in connected_bonds:
572
+ try:
573
+ b.setSelected(True)
574
+ except Exception:
575
+ try:
576
+ setattr(b, 'selected', True)
577
+ except Exception:
578
+ pass
579
+
580
+ event.accept()
581
+ return
582
+ except Exception:
583
+ # On any unexpected error, fall back to default handling
584
+ pass
585
+
586
+ elif self.mode in ['bond_2_5']:
587
+ event.accept()
588
+ return
589
+
590
+ super().mouseDoubleClickEvent(event)
591
+
592
+ def create_atom(self, symbol, pos, charge=0, radical=0):
593
+ atom_id = self.data.add_atom(symbol, pos, charge=charge, radical=radical)
594
+ atom_item = AtomItem(atom_id, symbol, pos, charge=charge, radical=radical)
595
+ self.data.atoms[atom_id]['item'] = atom_item; self.addItem(atom_item); return atom_id
596
+
597
+
598
+ def create_bond(self, start_atom, end_atom, bond_order=None, bond_stereo=None):
599
+ try:
600
+ if start_atom is None or end_atom is None:
601
+ print("Error: Cannot create bond with None atoms")
602
+ return
603
+
604
+ exist_b = self.find_bond_between(start_atom, end_atom)
605
+ if exist_b:
606
+ return
607
+
608
+ # 引数で次数が指定されていればそれを使用し、なければ現在のモードの値を使用する
609
+ order_to_use = self.bond_order if bond_order is None else bond_order
610
+ stereo_to_use = self.bond_stereo if bond_stereo is None else bond_stereo
611
+
612
+ key, status = self.data.add_bond(start_atom.atom_id, end_atom.atom_id, order_to_use, stereo_to_use)
613
+ if status == 'created':
614
+ bond_item = BondItem(start_atom, end_atom, order_to_use, stereo_to_use)
615
+ self.data.bonds[key]['item'] = bond_item
616
+ if hasattr(start_atom, 'bonds'):
617
+ start_atom.bonds.append(bond_item)
618
+ if hasattr(end_atom, 'bonds'):
619
+ end_atom.bonds.append(bond_item)
620
+ self.addItem(bond_item)
621
+
622
+ if hasattr(start_atom, 'update_style'):
623
+ start_atom.update_style()
624
+ if hasattr(end_atom, 'update_style'):
625
+ end_atom.update_style()
626
+
627
+ except Exception as e:
628
+ print(f"Error creating bond: {e}")
629
+
630
+ traceback.print_exc()
631
+
632
+ def add_molecule_fragment(self, points, bonds_info, existing_items=None, symbol='C'):
633
+ """
634
+ add_molecule_fragment の最終確定版。
635
+ - 既存の結合次数を変更しないポリシーを徹底(最重要)。
636
+ - ベンゼン環テンプレートは、フューズされる既存結合の次数に基づき、
637
+ 「新規に作られる二重結合が2本になるように」回転を決定するロジックを適用(条件分岐あり)。
638
+ """
639
+
640
+ num_points = len(points)
641
+ atom_items = [None] * num_points
642
+
643
+ is_benzene_template = (num_points == 6 and any(o == 2 for _, _, o in bonds_info))
644
+
645
+
646
+ def coords(p):
647
+ if hasattr(p, 'x') and hasattr(p, 'y'):
648
+ return (p.x(), p.y())
649
+ try:
650
+ return (p[0], p[1])
651
+ except Exception:
652
+ raise ValueError("point has no x/y")
653
+
654
+ def dist_pts(a, b):
655
+ ax, ay = coords(a); bx, by = coords(b)
656
+ return math.hypot(ax - bx, ay - by)
657
+
658
+ # --- 1) 既にクリックされた existing_items をテンプレート頂点にマップ ---
659
+ existing_items = existing_items or []
660
+ used_indices = set()
661
+ ref_lengths = [dist_pts(points[i], points[j]) for i, j, _ in bonds_info if i < num_points and j < num_points]
662
+ avg_len = (sum(ref_lengths) / len(ref_lengths)) if ref_lengths else 20.0
663
+ map_threshold = max(0.5 * avg_len, 8.0)
664
+
665
+ for ex_item in existing_items:
666
+ try:
667
+ ex_pos = ex_item.pos()
668
+ best_idx, best_d = -1, float('inf')
669
+ for i, p in enumerate(points):
670
+ if i in used_indices: continue
671
+ d = dist_pts(p, ex_pos)
672
+ if best_d is None or d < best_d:
673
+ best_d, best_idx = d, i
674
+ if best_idx != -1 and best_d <= max(map_threshold, 1.5 * avg_len):
675
+ atom_items[best_idx] = ex_item
676
+ used_indices.add(best_idx)
677
+ except Exception:
678
+ pass
679
+
680
+ # --- 2) シーン内既存原子を self.data.atoms から列挙してマップ ---
681
+ mapped_atoms = {it for it in atom_items if it is not None}
682
+ for i, p in enumerate(points):
683
+ if atom_items[i] is not None: continue
684
+
685
+ nearby = None
686
+ best_d = float('inf')
687
+
688
+ for atom_data in self.data.atoms.values():
689
+ a_item = atom_data.get('item')
690
+ if not a_item or a_item in mapped_atoms: continue
691
+ try:
692
+ d = dist_pts(p, a_item.pos())
693
+ except Exception:
694
+ continue
695
+ if d < best_d:
696
+ best_d, nearby = d, a_item
697
+
698
+ if nearby and best_d <= map_threshold:
699
+ atom_items[i] = nearby
700
+ mapped_atoms.add(nearby)
701
+
702
+ # --- 3) 足りない頂点は新規作成 ---
703
+ for i, p in enumerate(points):
704
+ if atom_items[i] is None:
705
+ atom_id = self.create_atom(symbol, p)
706
+ atom_items[i] = self.data.atoms[atom_id]['item']
707
+
708
+ # --- 4) テンプレートのボンド配列を決定(ベンゼン回転合わせの処理) ---
709
+ template_bonds_to_use = list(bonds_info)
710
+ is_6ring = (num_points == 6 and len(bonds_info) == 6)
711
+ template_has_double = any(o == 2 for (_, _, o) in bonds_info)
712
+
713
+ if is_6ring and template_has_double:
714
+ existing_orders = {} # key: bonds_infoのインデックス, value: 既存の結合次数
715
+ for k, (i_idx, j_idx, _) in enumerate(bonds_info):
716
+ if i_idx < len(atom_items) and j_idx < len(atom_items):
717
+ a, b = atom_items[i_idx], atom_items[j_idx]
718
+ if a is None or b is None: continue
719
+ eb = self.find_bond_between(a, b)
720
+ if eb:
721
+ existing_orders[k] = getattr(eb, 'order', 1)
722
+
723
+ if existing_orders:
724
+ orig_orders = [o for (_, _, o) in bonds_info]
725
+ best_rot = 0
726
+ max_score = -999 # スコアは「適合度」を意味する
727
+
728
+ # --- フューズされた辺の数による条件分岐 ---
729
+ if len(existing_orders) >= 2:
730
+ # 2辺以上フューズ: 単純に既存の辺の次数とテンプレートの辺の次数が一致するものを最優先する
731
+ # (この場合、新しい環を交互配置にするのは難しく、単に既存の構造を壊さないことを優先)
732
+ for rot in range(num_points):
733
+ current_score = sum(100 for k, exist_order in existing_orders.items()
734
+ if orig_orders[(k + rot) % num_points] == exist_order)
735
+ if current_score > max_score:
736
+ max_score = current_score
737
+ best_rot = rot
738
+
739
+ elif len(existing_orders) == 1:
740
+ # 1辺フューズ: 既存の辺を維持しつつ、その両隣で「反転一致」を達成し、新しい環を交互配置にする
741
+
742
+ # フューズされた辺のインデックスと次数を取得
743
+ k_fuse = next(iter(existing_orders.keys()))
744
+ exist_order = existing_orders[k_fuse]
745
+
746
+ # 目標: フューズされた辺の両隣(k-1とk+1)に来るテンプレートの次数が、既存の辺の次数と逆であること
747
+ # k_adj_1 -> (k_fuse - 1) % 6
748
+ # k_adj_2 -> (k_fuse + 1) % 6
749
+
750
+ for rot in range(num_points):
751
+ current_score = 0
752
+ rotated_template_order = orig_orders[(k_fuse + rot) % num_points]
753
+
754
+ # 1. まず、フューズされた辺自体が次数を反転させられる位置にあるかチェック(必須ではないが、回転を絞る)
755
+ if (exist_order == 1 and rotated_template_order == 2) or \
756
+ (exist_order == 2 and rotated_template_order == 1):
757
+ current_score += 100 # 大幅ボーナス: 理想的な回転
758
+
759
+ # 2. 次に、両隣の辺の次数をチェック(交互配置維持の主目的)
760
+ # 既存辺の両隣は、新規に作成されるため、テンプレートの次数でボンドが作成されます。
761
+ # ここで、テンプレートの次数が既存辺の次数と逆になる回転を選ぶ必要があります。
762
+
763
+ # テンプレートの辺は、回転後のk_fuseの両隣(m_adj1, m_adj2)
764
+ m_adj1 = (k_fuse - 1 + rot) % num_points
765
+ m_adj2 = (k_fuse + 1 + rot) % num_points
766
+
767
+ neighbor_order_1 = orig_orders[m_adj1]
768
+ neighbor_order_2 = orig_orders[m_adj2]
769
+
770
+ # 既存が単結合(1)の場合、両隣は二重結合(2)であってほしい
771
+ if exist_order == 1:
772
+ if neighbor_order_1 == 2: current_score += 50
773
+ if neighbor_order_2 == 2: current_score += 50
774
+
775
+ # 既存が二重結合(2)の場合、両隣は単結合(1)であってほしい
776
+ elif exist_order == 2:
777
+ if neighbor_order_1 == 1: current_score += 50
778
+ if neighbor_order_2 == 1: current_score += 50
779
+
780
+ # 3. タイブレーク: その他の既存結合(フューズ辺ではない)との次数一致度も加味
781
+ for k, e_order in existing_orders.items():
782
+ if k != k_fuse:
783
+ r_t_order = orig_orders[(k + rot) % num_points]
784
+ if r_t_order == e_order: current_score += 10 # 既存構造維持のボーナス
785
+
786
+ if current_score > max_score:
787
+ max_score = current_score
788
+ best_rot = rot
789
+
790
+ # 最終的な回転を反映
791
+ new_tb = []
792
+ for m in range(num_points):
793
+ i_idx, j_idx, _ = bonds_info[m]
794
+ new_order = orig_orders[(m + best_rot) % num_points]
795
+ new_tb.append((i_idx, j_idx, new_order))
796
+ template_bonds_to_use = new_tb
797
+
798
+ # --- 5) ボンド作成/更新---
799
+ for id1_idx, id2_idx, order in template_bonds_to_use:
800
+ if id1_idx < len(atom_items) and id2_idx < len(atom_items):
801
+ a_item, b_item = atom_items[id1_idx], atom_items[id2_idx]
802
+ if not a_item or not b_item or a_item is b_item: continue
803
+
804
+ id1, id2 = a_item.atom_id, b_item.atom_id
805
+ if id1 > id2: id1, id2 = id2, id1
806
+
807
+ exist_b = self.find_bond_between(a_item, b_item)
808
+
809
+ if exist_b:
810
+ # デフォルトでは既存の結合を維持する
811
+ should_overwrite = False
812
+
813
+ # 条件1: ベンゼン環テンプレートであること
814
+ # 条件2: 接続先が単結合であること
815
+ if is_benzene_template and exist_b.order == 1:
816
+
817
+ # 条件3: 接続先の単結合が共役系の一部ではないこと
818
+ # (つまり、両端の原子が他に二重結合を持たないこと)
819
+ atom1 = exist_b.atom1
820
+ atom2 = exist_b.atom2
821
+
822
+ # atom1が他に二重結合を持つかチェック
823
+ atom1_has_other_double_bond = any(b.order == 2 for b in atom1.bonds if b is not exist_b)
824
+
825
+ # atom2が他に二重結合を持つかチェック
826
+ atom2_has_other_double_bond = any(b.order == 2 for b in atom2.bonds if b is not exist_b)
827
+
828
+ # 両方の原子が他に二重結合を持たない「孤立した単結合」の場合のみ上書きフラグを立てる
829
+ if not atom1_has_other_double_bond and not atom2_has_other_double_bond:
830
+ should_overwrite = True
831
+
832
+ if should_overwrite:
833
+ # 上書き条件が全て満たされた場合にのみ、結合次数を更新
834
+ exist_b.order = order
835
+ exist_b.stereo = 0
836
+ self.data.bonds[(id1, id2)]['order'] = order
837
+ self.data.bonds[(id1, id2)]['stereo'] = 0
838
+ exist_b.update()
839
+ else:
840
+ # 上書き条件を満たさない場合は、既存の結合を維持する
841
+ continue
842
+ else:
843
+ # 新規ボンド作成
844
+ self.create_bond(a_item, b_item, bond_order=order, bond_stereo=0)
845
+
846
+ # --- 6) 表示更新 ---
847
+ for at in atom_items:
848
+ try:
849
+ if at: at.update_style()
850
+ except Exception:
851
+ pass
852
+
853
+ return atom_items
854
+
855
+
856
+ def update_template_preview(self, pos):
857
+ mode_parts = self.mode.split('_')
858
+
859
+ # Check if this is a user template
860
+ if len(mode_parts) >= 3 and mode_parts[1] == 'user':
861
+ self.update_user_template_preview(pos)
862
+ return
863
+
864
+ is_aromatic = False
865
+ if mode_parts[1] == 'benzene':
866
+ n = 6
867
+ is_aromatic = True
868
+ else:
869
+ try: n = int(mode_parts[1])
870
+ except ValueError: return
871
+
872
+ items_under = self.items(pos) # top-most first
873
+ item = None
874
+ for it in items_under:
875
+ if isinstance(it, (AtomItem, BondItem)):
876
+ item = it
877
+ break
878
+
879
+ points, bonds_info = [], []
880
+ l = DEFAULT_BOND_LENGTH
881
+ self.template_context = {}
882
+
883
+
884
+ if isinstance(item, AtomItem):
885
+ p0 = item.pos()
886
+ continuous_angle = math.atan2(pos.y() - p0.y(), pos.x() - p0.x())
887
+ snap_angle_rad = math.radians(15)
888
+ snapped_angle = round(continuous_angle / snap_angle_rad) * snap_angle_rad
889
+ p1 = p0 + QPointF(l * math.cos(snapped_angle), l * math.sin(snapped_angle))
890
+ points = self._calculate_polygon_from_edge(p0, p1, n)
891
+ self.template_context['items'] = [item]
892
+
893
+ elif isinstance(item, BondItem):
894
+ # 結合にスナップ
895
+ p0, p1 = item.atom1.pos(), item.atom2.pos()
896
+ points = self._calculate_polygon_from_edge(p0, p1, n, cursor_pos=pos, use_existing_length=True)
897
+ self.template_context['items'] = [item.atom1, item.atom2]
898
+
899
+ else:
900
+ angle_step = 2 * math.pi / n
901
+ start_angle = -math.pi / 2 if n % 2 != 0 else -math.pi / 2 - angle_step / 2
902
+ points = [
903
+ pos + QPointF(l * math.cos(start_angle + i * angle_step), l * math.sin(start_angle + i * angle_step))
904
+ for i in range(n)
905
+ ]
906
+
907
+ if points:
908
+ if is_aromatic:
909
+ bonds_info = [(i, (i + 1) % n, 2 if i % 2 == 0 else 1) for i in range(n)]
910
+ else:
911
+ bonds_info = [(i, (i + 1) % n, 1) for i in range(n)]
912
+
913
+ self.template_context['points'] = points
914
+ self.template_context['bonds_info'] = bonds_info
915
+
916
+ self.template_preview.set_geometry(points, is_aromatic)
917
+
918
+ self.template_preview.show()
919
+ if self.views():
920
+ self.views()[0].viewport().update()
921
+ else:
922
+ self.template_preview.hide()
923
+ if self.views():
924
+ self.views()[0].viewport().update()
925
+
926
+ def _calculate_polygon_from_edge(self, p0, p1, n, cursor_pos=None, use_existing_length=False):
927
+ if n < 3: return []
928
+ v_edge = p1 - p0
929
+ edge_length = math.sqrt(v_edge.x()**2 + v_edge.y()**2)
930
+ if edge_length == 0: return []
931
+
932
+ target_length = edge_length if use_existing_length else DEFAULT_BOND_LENGTH
933
+
934
+ v_edge = (v_edge / edge_length) * target_length
935
+
936
+ if not use_existing_length:
937
+ p1 = p0 + v_edge
938
+
939
+ points = [p0, p1]
940
+
941
+ interior_angle = (n - 2) * math.pi / n
942
+ rotation_angle = math.pi - interior_angle
943
+
944
+ if cursor_pos:
945
+ # Note: v_edgeは正規化済みだが、方向は同じなので判定には問題ない
946
+ v_cursor = cursor_pos - p0
947
+ cross_product_z = (p1 - p0).x() * v_cursor.y() - (p1 - p0).y() * v_cursor.x()
948
+ if cross_product_z < 0:
949
+ rotation_angle = -rotation_angle
950
+
951
+ cos_a, sin_a = math.cos(rotation_angle), math.sin(rotation_angle)
952
+
953
+ current_p, current_v = p1, v_edge
954
+ for _ in range(n - 2):
955
+ new_vx = current_v.x() * cos_a - current_v.y() * sin_a
956
+ new_vy = current_v.x() * sin_a + current_v.y() * cos_a
957
+ current_v = QPointF(new_vx, new_vy)
958
+ current_p = current_p + current_v
959
+ points.append(current_p)
960
+ return points
961
+
962
+ def delete_items(self, items_to_delete):
963
+ """指定されたアイテムセット(原子・結合)を安全な順序で削除する修正版"""
964
+ # Hardened deletion: perform data-model removals first, then scene removals,
965
+ # and always defensively check attributes to avoid accessing partially-deleted objects.
966
+ if not items_to_delete:
967
+ return False
968
+
969
+ # First sanitize the incoming collection: only keep live, expected QGraphics wrappers
970
+ try:
971
+ sanitized = set()
972
+ for it in items_to_delete:
973
+ try:
974
+ if it is None:
975
+ continue
976
+ # Skip SIP-deleted wrappers early to avoid native crashes
977
+ if sip_isdeleted_safe(it):
978
+ continue
979
+ # Only accept AtomItem/BondItem or other QGraphicsItem subclasses
980
+ if isinstance(it, (AtomItem, BondItem, QGraphicsItem)):
981
+ sanitized.add(it)
982
+ except Exception:
983
+ # If isinstance or sip check raises, skip this entry
984
+ continue
985
+ items_to_delete = sanitized
986
+ except Exception:
987
+ # If sanitization fails, fall back to original input and proceed defensively
988
+ pass
989
+
990
+ try:
991
+ atoms_to_delete = {item for item in items_to_delete if isinstance(item, AtomItem)}
992
+ bonds_to_delete = {item for item in items_to_delete if isinstance(item, BondItem)}
993
+
994
+ # Include bonds attached to atoms being deleted
995
+ for atom in list(atoms_to_delete):
996
+ try:
997
+ if hasattr(atom, 'bonds') and atom.bonds:
998
+ for b in list(atom.bonds):
999
+ bonds_to_delete.add(b)
1000
+ except Exception:
1001
+ # If accessing bonds raises (item partially deleted), skip
1002
+ continue
1003
+
1004
+ # Determine atoms that will remain but whose bond lists must be updated
1005
+ atoms_to_update = set()
1006
+ for bond in list(bonds_to_delete):
1007
+ try:
1008
+ a1 = getattr(bond, 'atom1', None)
1009
+ a2 = getattr(bond, 'atom2', None)
1010
+ if a1 and a1 not in atoms_to_delete:
1011
+ atoms_to_update.add(a1)
1012
+ if a2 and a2 not in atoms_to_delete:
1013
+ atoms_to_update.add(a2)
1014
+ except Exception:
1015
+ continue
1016
+
1017
+ # 1) Update surviving atoms' bond lists to remove references to bonds_to_delete
1018
+ # (Important: remove BondItem references so atoms properly reflect
1019
+ # that they have no remaining bonds and update visibility accordingly.)
1020
+ for atom in list(atoms_to_update):
1021
+ try:
1022
+ if sip_isdeleted_safe(atom):
1023
+ continue
1024
+ # Defensive: if the atom has a bonds list, filter out bonds being deleted
1025
+ if hasattr(atom, 'bonds') and atom.bonds:
1026
+ try:
1027
+ # Replace in-place to preserve any other references.
1028
+ # Avoid touching SIP-deleted bond wrappers: build a set
1029
+ # of live bonds-to-delete and also prune any SIP-deleted
1030
+ # entries that may exist in atom.bonds.
1031
+ live_btd = {b for b in bonds_to_delete if not sip_isdeleted_safe(b)}
1032
+
1033
+ # First, remove any SIP-deleted bond wrappers from atom.bonds
1034
+ atom.bonds[:] = [b for b in atom.bonds if not sip_isdeleted_safe(b)]
1035
+
1036
+ # Then remove bonds which are in the live_btd set
1037
+ if live_btd:
1038
+ atom.bonds[:] = [b for b in atom.bonds if b not in live_btd]
1039
+ except Exception:
1040
+ # Fall back to iterative removal if list comprehension fails
1041
+ try:
1042
+ live_btd = [b for b in list(bonds_to_delete) if not sip_isdeleted_safe(b)]
1043
+ for b in live_btd:
1044
+ if b in atom.bonds:
1045
+ atom.bonds.remove(b)
1046
+ except Exception:
1047
+ pass
1048
+
1049
+ # After pruning bond references, update visual style so carbons without
1050
+ # bonds become visible again.
1051
+ if hasattr(atom, 'update_style'):
1052
+ atom.update_style()
1053
+ except Exception:
1054
+ continue
1055
+
1056
+ # 2) Remove bonds/atoms from the data model first (so other code reading the model
1057
+ # doesn't encounter stale entries while we are removing graphics)
1058
+ for bond in list(bonds_to_delete):
1059
+ try:
1060
+ a1 = getattr(bond, 'atom1', None)
1061
+ a2 = getattr(bond, 'atom2', None)
1062
+ if a1 and a2 and hasattr(self, 'data'):
1063
+ try:
1064
+ self.data.remove_bond(a1.atom_id, a2.atom_id)
1065
+ except Exception:
1066
+ # try reverse order if remove_bond expects ordered tuple
1067
+ try:
1068
+ self.data.remove_bond(a2.atom_id, a1.atom_id)
1069
+ except Exception:
1070
+ pass
1071
+ except Exception:
1072
+ continue
1073
+
1074
+ for atom in list(atoms_to_delete):
1075
+ try:
1076
+ if hasattr(atom, 'atom_id') and hasattr(self, 'data'):
1077
+ try:
1078
+ self.data.remove_atom(atom.atom_id)
1079
+ except Exception:
1080
+ pass
1081
+ except Exception:
1082
+ continue
1083
+
1084
+ # Invalidate any pending implicit-hydrogen UI updates because the
1085
+ # underlying data model changed. This prevents a scheduled
1086
+ # update_implicit_hydrogens closure from touching atoms/bonds that
1087
+ # were just removed. Do a single increment rather than one per-atom.
1088
+ try:
1089
+ self._ih_update_counter += 1
1090
+ except Exception:
1091
+ try:
1092
+ self._ih_update_counter = 0
1093
+ except Exception:
1094
+ pass
1095
+
1096
+ # 3) Remove graphic items from the scene (bonds first)
1097
+ # To avoid calling into methods on wrappers that may refer to
1098
+ # already-deleted C++ objects (which can cause a native crash when
1099
+ # SIP is not available), take a snapshot of the current scene's
1100
+ # items and use membership tests instead of calling item.scene().
1101
+ try:
1102
+ current_scene_items = set(self.items())
1103
+ except Exception:
1104
+ # If for any reason items() fails, fall back to an empty set
1105
+ current_scene_items = set()
1106
+
1107
+ for bond in list(bonds_to_delete):
1108
+ try:
1109
+ # If the SIP wrapper is already deleted, skip it.
1110
+ if sip_isdeleted_safe(bond):
1111
+ continue
1112
+ # Only attempt to remove the bond if it is present in the
1113
+ # scene snapshot. This avoids calling bond.scene() which
1114
+ # may invoke C++ on a deleted object.
1115
+ if bond in current_scene_items:
1116
+ try:
1117
+ self.removeItem(bond)
1118
+ except Exception:
1119
+ pass
1120
+ except Exception:
1121
+ continue
1122
+
1123
+ for atom in list(atoms_to_delete):
1124
+ try:
1125
+ # Skip if wrapper is reported deleted by SIP
1126
+ if sip_isdeleted_safe(atom):
1127
+ continue
1128
+ if atom in current_scene_items:
1129
+ try:
1130
+ self.removeItem(atom)
1131
+ except Exception:
1132
+ pass
1133
+ except Exception:
1134
+ continue
1135
+
1136
+ # 4) Instead of aggressively nullling object attributes (which can
1137
+ # lead to C++/SIP finalization races and segfaults), keep a
1138
+ # strong reference to the deleted wrappers for the lifetime of
1139
+ # the scene. This prevents their underlying SIP wrappers from
1140
+ # being finalized while other code may still touch them.
1141
+ try:
1142
+ if not hasattr(self, '_deleted_items') or self._deleted_items is None:
1143
+ self._deleted_items = []
1144
+ except Exception:
1145
+ self._deleted_items = []
1146
+
1147
+ for bond in list(bonds_to_delete):
1148
+ try:
1149
+ # Hide the graphics item if possible and stash it
1150
+ if not sip_isdeleted_safe(bond):
1151
+ try:
1152
+ bond.hide()
1153
+ except Exception:
1154
+ pass
1155
+ try:
1156
+ self._deleted_items.append(bond)
1157
+ except Exception:
1158
+ # Swallow any error while stashing
1159
+ pass
1160
+ except Exception:
1161
+ continue
1162
+
1163
+ for atom in list(atoms_to_delete):
1164
+ try:
1165
+ if not sip_isdeleted_safe(atom):
1166
+ try:
1167
+ atom.hide()
1168
+ except Exception:
1169
+ pass
1170
+ try:
1171
+ self._deleted_items.append(atom)
1172
+ except Exception:
1173
+ pass
1174
+ except Exception:
1175
+ continue
1176
+
1177
+ # 5) Final visual updates for surviving atoms
1178
+ for atom in list(atoms_to_update):
1179
+ try:
1180
+ if hasattr(atom, 'update_style'):
1181
+ atom.update_style()
1182
+ except Exception:
1183
+ continue
1184
+
1185
+ return True
1186
+
1187
+ except Exception as e:
1188
+ # Keep the application alive on unexpected errors
1189
+ print(f"Error during delete_items operation: {e}")
1190
+
1191
+ traceback.print_exc()
1192
+ return False
1193
+ def purge_deleted_items(self):
1194
+ """Purge and release any held deleted-wrapper references.
1195
+
1196
+ This is intended to be invoked on application shutdown to allow
1197
+ the process to release references to SIP/C++ wrappers that were
1198
+ kept around to avoid finalization races during normal runtime.
1199
+ The method is defensive: it tolerates partially-deleted wrappers
1200
+ and any SIP unavailability.
1201
+ """
1202
+ try:
1203
+ if not hasattr(self, '_deleted_items') or not self._deleted_items:
1204
+ return
1205
+
1206
+ # Iterate a copy since we will clear the list.
1207
+ for obj in list(self._deleted_items):
1208
+ try:
1209
+ # If the wrapper is still alive, attempt to hide it so
1210
+ # the graphics subsystem isn't holding on to resources.
1211
+ if not sip_isdeleted_safe(obj):
1212
+ try:
1213
+ obj.hide()
1214
+ except Exception:
1215
+ pass
1216
+
1217
+ # Try to clear container attributes that may hold refs
1218
+ # to other scene objects (bonds, etc.) to help GC.
1219
+ try:
1220
+ if hasattr(obj, 'bonds') and getattr(obj, 'bonds') is not None:
1221
+ try:
1222
+ obj.bonds.clear()
1223
+ except Exception:
1224
+ # Try assignment fallback
1225
+ try:
1226
+ obj.bonds = []
1227
+ except Exception:
1228
+ pass
1229
+ except Exception:
1230
+ pass
1231
+
1232
+ except Exception:
1233
+ # Continue purging remaining items even if one fails.
1234
+ continue
1235
+
1236
+ # Finally, drop our references.
1237
+ try:
1238
+ self._deleted_items.clear()
1239
+ except Exception:
1240
+ try:
1241
+ self._deleted_items = []
1242
+ except Exception:
1243
+ pass
1244
+
1245
+ except Exception as e:
1246
+ # Never raise during shutdown
1247
+ try:
1248
+ print(f"Error purging deleted items: {e}")
1249
+ except Exception:
1250
+ pass
1251
+
1252
+ def add_user_template_fragment(self, context):
1253
+ """ユーザーテンプレートフラグメントを配置"""
1254
+ points = context.get('points', [])
1255
+ bonds_info = context.get('bonds_info', [])
1256
+ atoms_data = context.get('atoms_data', [])
1257
+ attachment_atom = context.get('attachment_atom')
1258
+
1259
+ if not points or not atoms_data:
1260
+ return
1261
+
1262
+ # Create atoms
1263
+ atom_id_map = {} # template id -> scene atom id
1264
+
1265
+ for i, (pos, atom_data) in enumerate(zip(points, atoms_data)):
1266
+ # Skip first atom if attaching to existing atom
1267
+ if i == 0 and attachment_atom:
1268
+ atom_id_map[atom_data['id']] = attachment_atom.atom_id
1269
+ continue
1270
+
1271
+ symbol = atom_data.get('symbol', 'C')
1272
+ charge = atom_data.get('charge', 0)
1273
+ radical = atom_data.get('radical', 0)
1274
+
1275
+ atom_id = self.data.add_atom(symbol, pos, charge, radical)
1276
+ atom_id_map[atom_data['id']] = atom_id
1277
+
1278
+ # Create visual atom item
1279
+ atom_item = AtomItem(atom_id, symbol, pos, charge, radical)
1280
+ self.data.atoms[atom_id]['item'] = atom_item
1281
+ self.addItem(atom_item)
1282
+
1283
+ # Create bonds (bonds_infoは必ずidベースで扱う)
1284
+ # まずindex→id変換テーブルを作る
1285
+ index_to_id = [atom_data.get('id', i) for i, atom_data in enumerate(atoms_data)]
1286
+ for bond_info in bonds_info:
1287
+ if isinstance(bond_info, (list, tuple)) and len(bond_info) >= 2:
1288
+ # bonds_infoの0,1番目がindexならidに変換
1289
+ atom1_idx = bond_info[0]
1290
+ atom2_idx = bond_info[1]
1291
+ order = bond_info[2] if len(bond_info) > 2 else 1
1292
+ stereo = bond_info[3] if len(bond_info) > 3 else 0
1293
+
1294
+ # index→id変換(すでにidならそのまま)
1295
+ if isinstance(atom1_idx, int) and atom1_idx < len(index_to_id):
1296
+ template_atom1_id = index_to_id[atom1_idx]
1297
+ else:
1298
+ template_atom1_id = atom1_idx
1299
+ if isinstance(atom2_idx, int) and atom2_idx < len(index_to_id):
1300
+ template_atom2_id = index_to_id[atom2_idx]
1301
+ else:
1302
+ template_atom2_id = atom2_idx
1303
+
1304
+ atom1_id = atom_id_map.get(template_atom1_id)
1305
+ atom2_id = atom_id_map.get(template_atom2_id)
1306
+
1307
+ if atom1_id is not None and atom2_id is not None:
1308
+ # Skip if bond already exists
1309
+ existing_bond = None
1310
+ if (atom1_id, atom2_id) in self.data.bonds:
1311
+ existing_bond = (atom1_id, atom2_id)
1312
+ elif (atom2_id, atom1_id) in self.data.bonds:
1313
+ existing_bond = (atom2_id, atom1_id)
1314
+
1315
+ if not existing_bond:
1316
+ bond_key, _ = self.data.add_bond(atom1_id, atom2_id, order, stereo)
1317
+ # Create visual bond item
1318
+ atom1_item = self.data.atoms[atom1_id]['item']
1319
+ atom2_item = self.data.atoms[atom2_id]['item']
1320
+ if atom1_item and atom2_item:
1321
+ bond_item = BondItem(atom1_item, atom2_item, order, stereo)
1322
+ self.data.bonds[bond_key]['item'] = bond_item
1323
+ self.addItem(bond_item)
1324
+ atom1_item.bonds.append(bond_item)
1325
+ atom2_item.bonds.append(bond_item)
1326
+
1327
+ # Update atom visuals
1328
+ for atom_id in atom_id_map.values():
1329
+ if atom_id in self.data.atoms and self.data.atoms[atom_id]['item']:
1330
+ self.data.atoms[atom_id]['item'].update_style()
1331
+
1332
+ def update_user_template_preview(self, pos):
1333
+ """ユーザーテンプレートのプレビューを更新"""
1334
+ # Robust user template preview: do not access self.data.atoms for preview-only atoms
1335
+ if not hasattr(self, 'user_template_data') or not self.user_template_data:
1336
+ return
1337
+
1338
+ template_data = self.user_template_data
1339
+ atoms = template_data.get('atoms', [])
1340
+ bonds = template_data.get('bonds', [])
1341
+
1342
+ if not atoms:
1343
+ return
1344
+
1345
+ # Find attachment point (first atom or clicked item)
1346
+ items_under = self.items(pos)
1347
+ attachment_atom = None
1348
+ for item in items_under:
1349
+ if isinstance(item, AtomItem):
1350
+ attachment_atom = item
1351
+ break
1352
+
1353
+ # Calculate template positions
1354
+ points = []
1355
+ # Find template bounds for centering
1356
+ if atoms:
1357
+ min_x = min(atom['x'] for atom in atoms)
1358
+ max_x = max(atom['x'] for atom in atoms)
1359
+ min_y = min(atom['y'] for atom in atoms)
1360
+ max_y = max(atom['y'] for atom in atoms)
1361
+ center_x = (min_x + max_x) / 2
1362
+ center_y = (min_y + max_y) / 2
1363
+ # Position template
1364
+ if attachment_atom:
1365
+ # Attach to existing atom
1366
+ attach_pos = attachment_atom.pos()
1367
+ offset_x = attach_pos.x() - atoms[0]['x']
1368
+ offset_y = attach_pos.y() - atoms[0]['y']
1369
+ else:
1370
+ # Center at cursor position
1371
+ offset_x = pos.x() - center_x
1372
+ offset_y = pos.y() - center_y
1373
+ # Calculate atom positions
1374
+ for atom in atoms:
1375
+ new_pos = QPointF(atom['x'] + offset_x, atom['y'] + offset_y)
1376
+ points.append(new_pos)
1377
+ # Create atom ID to index mapping (for preview only)
1378
+ atom_id_to_index = {}
1379
+ for i, atom in enumerate(atoms):
1380
+ atom_id = atom.get('id', i)
1381
+ atom_id_to_index[atom_id] = i
1382
+ # bonds_info をテンプレートの bonds から生成
1383
+ bonds_info = []
1384
+ for bond in bonds:
1385
+ atom1_idx = atom_id_to_index.get(bond['atom1'])
1386
+ atom2_idx = atom_id_to_index.get(bond['atom2'])
1387
+ if atom1_idx is not None and atom2_idx is not None:
1388
+ order = bond.get('order', 1)
1389
+ stereo = bond.get('stereo', 0)
1390
+ bonds_info.append((atom1_idx, atom2_idx, order, stereo))
1391
+ # プレビュー用: points, bonds_info から線を描画
1392
+ # 設置用 context を保存
1393
+ self.template_context = {
1394
+ 'points': points,
1395
+ 'bonds_info': bonds_info,
1396
+ 'atoms_data': atoms,
1397
+ 'attachment_atom': attachment_atom,
1398
+ }
1399
+ # 既存のプレビューアイテムを一旦クリア
1400
+ for item in list(self.items()):
1401
+ if isinstance(item, QGraphicsLineItem) and getattr(item, '_is_template_preview', False):
1402
+ self.removeItem(item)
1403
+
1404
+ # Draw preview lines only using calculated points (do not access self.data.atoms)
1405
+ for bond_info in bonds_info:
1406
+ if isinstance(bond_info, (list, tuple)) and len(bond_info) >= 2:
1407
+ i, j = bond_info[0], bond_info[1]
1408
+ order = bond_info[2] if len(bond_info) > 2 else 1
1409
+ # stereo = bond_info[3] if len(bond_info) > 3 else 0
1410
+ if i < len(points) and j < len(points):
1411
+ line = QGraphicsLineItem(QLineF(points[i], points[j]))
1412
+ pen = QPen(Qt.black, 2 if order == 2 else 1)
1413
+ line.setPen(pen)
1414
+ line._is_template_preview = True # フラグで区別
1415
+ self.addItem(line)
1416
+ # Never access self.data.atoms here for preview-only atoms
1417
+
1418
+ def leaveEvent(self, event):
1419
+ self.template_preview.hide(); super().leaveEvent(event)
1420
+
1421
+ def set_hovered_item(self, item):
1422
+ """BondItemから呼ばれ、ホバー中のアイテムを記録する"""
1423
+ self.hovered_item = item
1424
+
1425
+ def keyPressEvent(self, event):
1426
+ view = self.views()[0]
1427
+ cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
1428
+ item_at_cursor = self.itemAt(cursor_pos, view.transform())
1429
+ key = event.key()
1430
+ modifiers = event.modifiers()
1431
+
1432
+ if not self.window.is_2d_editable:
1433
+ return
1434
+
1435
+
1436
+ if key == Qt.Key.Key_4:
1437
+ # --- 動作1: カーソルが原子/結合上にある場合 (ワンショットでテンプレート配置) ---
1438
+ if isinstance(item_at_cursor, (AtomItem, BondItem)):
1439
+
1440
+ # ベンゼンテンプレートのパラメータを設定
1441
+ n, is_aromatic = 6, True
1442
+ points, bonds_info, existing_items = [], [], []
1443
+
1444
+ # update_template_preview と同様のロジックで配置情報を計算
1445
+ if isinstance(item_at_cursor, AtomItem):
1446
+ p0 = item_at_cursor.pos()
1447
+ l = DEFAULT_BOND_LENGTH
1448
+ direction = QLineF(p0, cursor_pos).unitVector()
1449
+ p1 = p0 + direction.p2() * l if direction.length() > 0 else p0 + QPointF(l, 0)
1450
+ points = self._calculate_polygon_from_edge(p0, p1, n)
1451
+ existing_items = [item_at_cursor]
1452
+
1453
+ elif isinstance(item_at_cursor, BondItem):
1454
+ p0, p1 = item_at_cursor.atom1.pos(), item_at_cursor.atom2.pos()
1455
+ points = self._calculate_polygon_from_edge(p0, p1, n, cursor_pos=cursor_pos, use_existing_length=True)
1456
+ existing_items = [item_at_cursor.atom1, item_at_cursor.atom2]
1457
+
1458
+ if points:
1459
+ bonds_info = [(i, (i + 1) % n, 2 if i % 2 == 0 else 1) for i in range(n)]
1460
+
1461
+ # 計算した情報を使って、その場にフラグメントを追加
1462
+ self.add_molecule_fragment(points, bonds_info, existing_items=existing_items)
1463
+ self.window.push_undo_state()
1464
+
1465
+ # --- 動作2: カーソルが空白領域にある場合 (モード切替) ---
1466
+ else:
1467
+ self.window.set_mode_and_update_toolbar('template_benzene')
1468
+
1469
+ event.accept()
1470
+ return
1471
+
1472
+ # --- 0a. ラジカルの変更 (.) ---
1473
+ if key == Qt.Key.Key_Period:
1474
+ target_atoms = []
1475
+ selected = self.selectedItems()
1476
+ if selected:
1477
+ target_atoms = [item for item in selected if isinstance(item, AtomItem)]
1478
+ elif isinstance(item_at_cursor, AtomItem):
1479
+ target_atoms = [item_at_cursor]
1480
+
1481
+ if target_atoms:
1482
+ for atom in target_atoms:
1483
+ # ラジカルの状態をトグル (0 -> 1 -> 2 -> 0)
1484
+ atom.prepareGeometryChange()
1485
+ atom.radical = (atom.radical + 1) % 3
1486
+ self.data.atoms[atom.atom_id]['radical'] = atom.radical
1487
+ atom.update_style()
1488
+ self.window.push_undo_state()
1489
+ event.accept()
1490
+ return
1491
+
1492
+ # --- 0b. 電荷の変更 (+/-キー) ---
1493
+ if key == Qt.Key.Key_Plus or key == Qt.Key.Key_Minus:
1494
+ target_atoms = []
1495
+ selected = self.selectedItems()
1496
+ if selected:
1497
+ target_atoms = [item for item in selected if isinstance(item, AtomItem)]
1498
+ elif isinstance(item_at_cursor, AtomItem):
1499
+ target_atoms = [item_at_cursor]
1500
+
1501
+ if target_atoms:
1502
+ delta = 1 if key == Qt.Key.Key_Plus else -1
1503
+ for atom in target_atoms:
1504
+ atom.prepareGeometryChange()
1505
+ atom.charge += delta
1506
+ self.data.atoms[atom.atom_id]['charge'] = atom.charge
1507
+ atom.update_style()
1508
+ self.window.push_undo_state()
1509
+ event.accept()
1510
+ return
1511
+
1512
+ # --- 1. Atomに対する操作 (元素記号の変更) ---
1513
+ if isinstance(item_at_cursor, AtomItem):
1514
+ new_symbol = None
1515
+ if modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_symbol_map:
1516
+ new_symbol = self.key_to_symbol_map[key]
1517
+ elif modifiers == Qt.KeyboardModifier.ShiftModifier and key in self.key_to_symbol_map_shift:
1518
+ new_symbol = self.key_to_symbol_map_shift[key]
1519
+
1520
+ if new_symbol and item_at_cursor.symbol != new_symbol:
1521
+ item_at_cursor.prepareGeometryChange()
1522
+
1523
+ item_at_cursor.symbol = new_symbol
1524
+ self.data.atoms[item_at_cursor.atom_id]['symbol'] = new_symbol
1525
+ item_at_cursor.update_style()
1526
+
1527
+
1528
+ atoms_to_update = {item_at_cursor}
1529
+ for bond in item_at_cursor.bonds:
1530
+ bond.update()
1531
+ other_atom = bond.atom1 if bond.atom2 is item_at_cursor else bond.atom2
1532
+ atoms_to_update.add(other_atom)
1533
+
1534
+ for atom in atoms_to_update:
1535
+ atom.update_style()
1536
+
1537
+ self.window.push_undo_state()
1538
+ event.accept()
1539
+ return
1540
+
1541
+ # --- 2. Bondに対する操作 (次数・立体化学の変更) ---
1542
+ target_bonds = []
1543
+ if isinstance(item_at_cursor, BondItem):
1544
+ target_bonds = [item_at_cursor]
1545
+ else:
1546
+ target_bonds = [it for it in self.selectedItems() if isinstance(it, BondItem)]
1547
+
1548
+ if target_bonds:
1549
+ any_bond_changed = False
1550
+ for bond in target_bonds:
1551
+ # 1. 結合の向きを考慮して、データ辞書内の現在のキーを正しく特定する
1552
+ id1, id2 = bond.atom1.atom_id, bond.atom2.atom_id
1553
+ current_key = None
1554
+ if (id1, id2) in self.data.bonds:
1555
+ current_key = (id1, id2)
1556
+ elif (id2, id1) in self.data.bonds:
1557
+ current_key = (id2, id1)
1558
+
1559
+ if not current_key: continue
1560
+
1561
+ # 2. 変更前の状態を保存
1562
+ old_order, old_stereo = bond.order, bond.stereo
1563
+
1564
+ # 3. キー入力に応じてBondItemのプロパティを変更
1565
+ if key == Qt.Key.Key_W:
1566
+ if bond.stereo == 1:
1567
+ bond_data = self.data.bonds.pop(current_key)
1568
+ new_key = (current_key[1], current_key[0])
1569
+ self.data.bonds[new_key] = bond_data
1570
+ bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1571
+ bond.update_position()
1572
+ was_reversed = True
1573
+ else:
1574
+ bond.order = 1; bond.stereo = 1
1575
+
1576
+ elif key == Qt.Key.Key_D:
1577
+ if bond.stereo == 2:
1578
+ bond_data = self.data.bonds.pop(current_key)
1579
+ new_key = (current_key[1], current_key[0])
1580
+ self.data.bonds[new_key] = bond_data
1581
+ bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1582
+ bond.update_position()
1583
+ was_reversed = True
1584
+ else:
1585
+ bond.order = 1; bond.stereo = 2
1586
+
1587
+ elif key == Qt.Key.Key_1 and (bond.order != 1 or bond.stereo != 0):
1588
+ bond.order = 1; bond.stereo = 0
1589
+ elif key == Qt.Key.Key_2 and (bond.order != 2 or bond.stereo != 0):
1590
+ bond.order = 2; bond.stereo = 0; needs_update = True
1591
+ elif key == Qt.Key.Key_3 and bond.order != 3:
1592
+ bond.order = 3; bond.stereo = 0; needs_update = True
1593
+
1594
+ # 4. 実際に変更があった場合のみデータモデルを更新
1595
+ if old_order != bond.order or old_stereo != bond.stereo:
1596
+ any_bond_changed = True
1597
+
1598
+ # 5. 古いキーでデータを辞書から一度削除
1599
+ bond_data = self.data.bonds.pop(current_key)
1600
+ bond_data['order'] = bond.order
1601
+ bond_data['stereo'] = bond.stereo
1602
+
1603
+ # 6. 変更後の種類に応じて新しいキーを決定し、再登録する
1604
+ new_key_id1, new_key_id2 = bond.atom1.atom_id, bond.atom2.atom_id
1605
+ if bond.stereo == 0:
1606
+ if new_key_id1 > new_key_id2:
1607
+ new_key_id1, new_key_id2 = new_key_id2, new_key_id1
1608
+
1609
+ new_key = (new_key_id1, new_key_id2)
1610
+ self.data.bonds[new_key] = bond_data
1611
+
1612
+ bond.update()
1613
+
1614
+ if any_bond_changed:
1615
+ self.window.push_undo_state()
1616
+
1617
+ if key in [Qt.Key.Key_1, Qt.Key.Key_2, Qt.Key.Key_3, Qt.Key.Key_W, Qt.Key.Key_D]:
1618
+ event.accept()
1619
+ return
1620
+
1621
+ if isinstance(self.hovered_item, BondItem) and self.hovered_item.order == 2:
1622
+ if event.key() == Qt.Key.Key_Z:
1623
+ self.update_bond_stereo(self.hovered_item, 3) # Z-isomer
1624
+ self.window.push_undo_state()
1625
+ event.accept()
1626
+ return
1627
+ elif event.key() == Qt.Key.Key_E:
1628
+ self.update_bond_stereo(self.hovered_item, 4) # E-isomer
1629
+ self.window.push_undo_state()
1630
+ event.accept()
1631
+ return
1632
+
1633
+ # --- 3. Atomに対する操作 (原子の追加 - マージされた機能) ---
1634
+ if key == Qt.Key.Key_1:
1635
+ start_atom = None
1636
+ if isinstance(item_at_cursor, AtomItem):
1637
+ start_atom = item_at_cursor
1638
+ else:
1639
+ selected_atoms = [item for item in self.selectedItems() if isinstance(item, AtomItem)]
1640
+ if len(selected_atoms) == 1:
1641
+ start_atom = selected_atoms[0]
1642
+
1643
+ if start_atom:
1644
+ start_pos = start_atom.pos()
1645
+ l = DEFAULT_BOND_LENGTH
1646
+ new_pos_offset = QPointF(0, -l) # デフォルトのオフセット (上)
1647
+
1648
+ # 接続している原子のリストを取得 (H原子以外)
1649
+ neighbor_positions = []
1650
+ for bond in start_atom.bonds:
1651
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1652
+ if other_atom.symbol != 'H': # 水素原子を無視 (四面体構造の考慮のため)
1653
+ neighbor_positions.append(other_atom.pos())
1654
+
1655
+ num_non_H_neighbors = len(neighbor_positions)
1656
+
1657
+ if num_non_H_neighbors == 0:
1658
+ # 結合ゼロ: デフォルト方向
1659
+ new_pos_offset = QPointF(0, -l)
1660
+
1661
+ elif num_non_H_neighbors == 1:
1662
+ # 結合1本: 既存結合と約120度(または60度)の角度
1663
+ bond = start_atom.bonds[0]
1664
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1665
+ existing_bond_vector = start_pos - other_atom.pos()
1666
+
1667
+ # 既存の結合から時計回り60度回転 (ベンゼン環のような構造にしやすい)
1668
+ angle_rad = math.radians(60)
1669
+ cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
1670
+ vx, vy = existing_bond_vector.x(), existing_bond_vector.y()
1671
+ new_vx, new_vy = vx * cos_a - vy * sin_a, vx * sin_a + vy * cos_a
1672
+ rotated_vector = QPointF(new_vx, new_vy)
1673
+ line = QLineF(QPointF(0, 0), rotated_vector)
1674
+ line.setLength(l)
1675
+ new_pos_offset = line.p2()
1676
+
1677
+ elif num_non_H_neighbors == 3:
1678
+
1679
+ bond_vectors_sum = QPointF(0, 0)
1680
+ for pos in neighbor_positions:
1681
+ # start_pos から neighbor_pos へのベクトル
1682
+ vec = pos - start_pos
1683
+ # 単位ベクトルに変換
1684
+ line_to_other = QLineF(QPointF(0,0), vec)
1685
+ if line_to_other.length() > 0:
1686
+ line_to_other.setLength(1.0)
1687
+ bond_vectors_sum += line_to_other.p2()
1688
+
1689
+ # SUM_TOLERANCE is now a module-level constant
1690
+ if bond_vectors_sum.manhattanLength() > SUM_TOLERANCE:
1691
+ new_direction_line = QLineF(QPointF(0,0), -bond_vectors_sum)
1692
+ new_direction_line.setLength(l)
1693
+ new_pos_offset = new_direction_line.p2()
1694
+ else:
1695
+ new_pos_offset = QPointF(l * 0.7071, -l * 0.7071)
1696
+
1697
+
1698
+ else: # 2本または4本以上の場合 (一般的な骨格の継続、または過結合)
1699
+ bond_vectors_sum = QPointF(0, 0)
1700
+ for bond in start_atom.bonds:
1701
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1702
+ line_to_other = QLineF(start_pos, other_atom.pos())
1703
+ if line_to_other.length() > 0:
1704
+ line_to_other.setLength(1.0)
1705
+ bond_vectors_sum += line_to_other.p2() - line_to_other.p1()
1706
+
1707
+ if bond_vectors_sum.manhattanLength() > 0.01:
1708
+ new_direction_line = QLineF(QPointF(0,0), -bond_vectors_sum)
1709
+ new_direction_line.setLength(l)
1710
+ new_pos_offset = new_direction_line.p2()
1711
+ else:
1712
+ # 総和がゼロの場合は、デフォルト(上)
1713
+ new_pos_offset = QPointF(0, -l)
1714
+
1715
+
1716
+ # SNAP_DISTANCE is a module-level constant
1717
+ target_pos = start_pos + new_pos_offset
1718
+
1719
+ # 近くに原子を探す
1720
+ near_atom = self.find_atom_near(target_pos, tol=SNAP_DISTANCE)
1721
+
1722
+ if near_atom and near_atom is not start_atom:
1723
+ # 近くに既存原子があれば結合
1724
+ self.create_bond(start_atom, near_atom)
1725
+ else:
1726
+ # 新規原子を作成し結合
1727
+ new_atom_id = self.create_atom('C', target_pos)
1728
+ new_atom_item = self.data.atoms[new_atom_id]['item']
1729
+ self.create_bond(start_atom, new_atom_item)
1730
+
1731
+ self.clearSelection()
1732
+ self.window.push_undo_state()
1733
+ event.accept()
1734
+ return
1735
+
1736
+ # --- 4. 全体に対する操作 (削除、モード切替など) ---
1737
+ if key == Qt.Key.Key_Delete or key == Qt.Key.Key_Backspace:
1738
+ if self.temp_line:
1739
+ try:
1740
+ if not sip_isdeleted_safe(self.temp_line):
1741
+ try:
1742
+ if getattr(self.temp_line, 'scene', None) and self.temp_line.scene():
1743
+ self.removeItem(self.temp_line)
1744
+ except Exception:
1745
+ pass
1746
+ except Exception:
1747
+ try:
1748
+ self.removeItem(self.temp_line)
1749
+ except Exception:
1750
+ pass
1751
+ self.temp_line = None; self.start_atom = None; self.start_pos = None
1752
+ self.initial_positions_in_event = {}
1753
+ event.accept()
1754
+ return
1755
+
1756
+ items_to_process = set(self.selectedItems())
1757
+ # カーソル下のアイテムも削除対象に加える
1758
+ if item_at_cursor and isinstance(item_at_cursor, (AtomItem, BondItem)):
1759
+ items_to_process.add(item_at_cursor)
1760
+
1761
+ if self.delete_items(items_to_process):
1762
+ self.window.push_undo_state()
1763
+ self.window.statusBar().showMessage("Deleted selected items.")
1764
+
1765
+ # もしデータモデル内の原子が全て無くなっていたら、シーンをクリアして初期状態に戻す
1766
+ if not self.data.atoms:
1767
+ # 1. シーン上の全グラフィックアイテムを削除する
1768
+ self.clear()
1769
+
1770
+ # 2. テンプレートプレビューなど、初期状態で必要なアイテムを再生成する
1771
+ self.reinitialize_items()
1772
+
1773
+ # 3. 結合描画中などの一時的な状態も完全にリセットする
1774
+ self.temp_line = None
1775
+ self.start_atom = None
1776
+ self.start_pos = None
1777
+ self.initial_positions_in_event = {}
1778
+
1779
+ # このイベントはここで処理完了とする
1780
+ event.accept()
1781
+ return
1782
+
1783
+ # 描画の強制更新
1784
+ if self.views():
1785
+ self.views()[0].viewport().update()
1786
+ QApplication.processEvents()
1787
+
1788
+ event.accept()
1789
+ return
1790
+
1791
+
1792
+ if key == Qt.Key.Key_Space:
1793
+ if self.mode != 'select':
1794
+ self.window.activate_select_mode()
1795
+ else:
1796
+ self.window.select_all()
1797
+ event.accept()
1798
+ return
1799
+
1800
+ # グローバルな描画モード切替
1801
+ mode_to_set = None
1802
+
1803
+ # 1. 原子描画モードへの切り替え
1804
+ symbol_for_mode_change = None
1805
+ if modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_symbol_map:
1806
+ symbol_for_mode_change = self.key_to_symbol_map[key]
1807
+ elif modifiers == Qt.KeyboardModifier.ShiftModifier and key in self.key_to_symbol_map_shift:
1808
+ symbol_for_mode_change = self.key_to_symbol_map_shift[key]
1809
+
1810
+ if symbol_for_mode_change:
1811
+ mode_to_set = f'atom_{symbol_for_mode_change}'
1812
+
1813
+ # 2. 結合描画モードへの切り替え
1814
+ elif modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_bond_mode_map:
1815
+ mode_to_set = self.key_to_bond_mode_map[key]
1816
+
1817
+ # モードが決定されていれば、モード変更を実行
1818
+ if mode_to_set:
1819
+ if hasattr(self.window, 'set_mode_and_update_toolbar'):
1820
+ self.window.set_mode_and_update_toolbar(mode_to_set)
1821
+ event.accept()
1822
+ return
1823
+
1824
+ # --- どの操作にも当てはまらない場合 ---
1825
+ super().keyPressEvent(event)
1826
+
1827
+ def find_atom_near(self, pos, tol=14.0):
1828
+ # Create a small search rectangle around the position
1829
+ search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
1830
+ nearby_items = self.items(search_rect)
1831
+
1832
+ for it in nearby_items:
1833
+ if isinstance(it, AtomItem):
1834
+ # Check the precise distance only for candidate items
1835
+ if QLineF(it.pos(), pos).length() <= tol:
1836
+ return it
1837
+ return None
1838
+
1839
+ def find_bond_between(self, atom1, atom2):
1840
+ for b in atom1.bonds:
1841
+ if (b.atom1 is atom1 and b.atom2 is atom2) or \
1842
+ (b.atom1 is atom2 and b.atom2 is atom1):
1843
+ return b
1844
+ return None
1845
+
1846
+ def update_bond_stereo(self, bond_item, new_stereo):
1847
+ """結合の立体化学を更新する共通メソッド"""
1848
+ try:
1849
+ if bond_item is None:
1850
+ print("Error: bond_item is None in update_bond_stereo")
1851
+ return
1852
+
1853
+ if bond_item.order != 2 or bond_item.stereo == new_stereo:
1854
+ return
1855
+
1856
+ if not hasattr(bond_item, 'atom1') or not hasattr(bond_item, 'atom2'):
1857
+ print("Error: bond_item missing atom references")
1858
+ return
1859
+
1860
+ if bond_item.atom1 is None or bond_item.atom2 is None:
1861
+ print("Error: bond_item has None atom references")
1862
+ return
1863
+
1864
+ if not hasattr(bond_item.atom1, 'atom_id') or not hasattr(bond_item.atom2, 'atom_id'):
1865
+ print("Error: bond atoms missing atom_id")
1866
+ return
1867
+
1868
+ id1, id2 = bond_item.atom1.atom_id, bond_item.atom2.atom_id
1869
+
1870
+ # E/Z結合は方向性を持つため、キーは(id1, id2)のまま探す
1871
+ key_to_update = (id1, id2)
1872
+ if key_to_update not in self.data.bonds:
1873
+ # Wedge/Dashなど、逆順で登録されている可能性も考慮
1874
+ key_to_update = (id2, id1)
1875
+ if key_to_update not in self.data.bonds:
1876
+ # Log error instead of printing to console
1877
+ if hasattr(self.window, 'statusBar'):
1878
+ self.window.statusBar().showMessage(f"Warning: Bond between atoms {id1} and {id2} not found in data model.", 3000)
1879
+ print(f"Error: Bond key not found: {id1}-{id2} or {id2}-{id1}")
1880
+ return
1881
+
1882
+ # Update data model
1883
+ self.data.bonds[key_to_update]['stereo'] = new_stereo
1884
+
1885
+ # Update visual representation
1886
+ bond_item.set_stereo(new_stereo)
1887
+
1888
+ self.data_changed_in_event = True
1889
+
1890
+ except Exception as e:
1891
+ print(f"Error in update_bond_stereo: {e}")
1892
+
1893
+ traceback.print_exc()
1894
+ if hasattr(self.window, 'statusBar'):
1895
+ self.window.statusBar().showMessage(f"Error updating bond stereochemistry: {e}", 5000)