MoleditPy 1.16.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. moleditpy/__init__.py +4 -0
  2. moleditpy/__main__.py +29 -0
  3. moleditpy/main.py +37 -0
  4. moleditpy/modules/__init__.py +36 -0
  5. moleditpy/modules/about_dialog.py +92 -0
  6. moleditpy/modules/align_plane_dialog.py +281 -0
  7. moleditpy/modules/alignment_dialog.py +261 -0
  8. moleditpy/modules/analysis_window.py +197 -0
  9. moleditpy/modules/angle_dialog.py +428 -0
  10. moleditpy/modules/assets/icon.icns +0 -0
  11. moleditpy/modules/assets/icon.ico +0 -0
  12. moleditpy/modules/assets/icon.png +0 -0
  13. moleditpy/modules/atom_item.py +336 -0
  14. moleditpy/modules/bond_item.py +303 -0
  15. moleditpy/modules/bond_length_dialog.py +368 -0
  16. moleditpy/modules/calculation_worker.py +754 -0
  17. moleditpy/modules/color_settings_dialog.py +309 -0
  18. moleditpy/modules/constants.py +76 -0
  19. moleditpy/modules/constrained_optimization_dialog.py +667 -0
  20. moleditpy/modules/custom_interactor_style.py +737 -0
  21. moleditpy/modules/custom_qt_interactor.py +49 -0
  22. moleditpy/modules/dialog3_d_picking_mixin.py +96 -0
  23. moleditpy/modules/dihedral_dialog.py +431 -0
  24. moleditpy/modules/main_window.py +830 -0
  25. moleditpy/modules/main_window_app_state.py +747 -0
  26. moleditpy/modules/main_window_compute.py +1203 -0
  27. moleditpy/modules/main_window_dialog_manager.py +454 -0
  28. moleditpy/modules/main_window_edit_3d.py +531 -0
  29. moleditpy/modules/main_window_edit_actions.py +1449 -0
  30. moleditpy/modules/main_window_export.py +744 -0
  31. moleditpy/modules/main_window_main_init.py +1668 -0
  32. moleditpy/modules/main_window_molecular_parsers.py +1037 -0
  33. moleditpy/modules/main_window_project_io.py +429 -0
  34. moleditpy/modules/main_window_string_importers.py +270 -0
  35. moleditpy/modules/main_window_ui_manager.py +567 -0
  36. moleditpy/modules/main_window_view_3d.py +1211 -0
  37. moleditpy/modules/main_window_view_loaders.py +350 -0
  38. moleditpy/modules/mirror_dialog.py +110 -0
  39. moleditpy/modules/molecular_data.py +290 -0
  40. moleditpy/modules/molecule_scene.py +1964 -0
  41. moleditpy/modules/move_group_dialog.py +586 -0
  42. moleditpy/modules/periodic_table_dialog.py +72 -0
  43. moleditpy/modules/planarize_dialog.py +209 -0
  44. moleditpy/modules/settings_dialog.py +1071 -0
  45. moleditpy/modules/template_preview_item.py +148 -0
  46. moleditpy/modules/template_preview_view.py +62 -0
  47. moleditpy/modules/translation_dialog.py +353 -0
  48. moleditpy/modules/user_template_dialog.py +621 -0
  49. moleditpy/modules/zoomable_view.py +98 -0
  50. moleditpy-1.16.3.dist-info/METADATA +274 -0
  51. moleditpy-1.16.3.dist-info/RECORD +54 -0
  52. moleditpy-1.16.3.dist-info/WHEEL +5 -0
  53. moleditpy-1.16.3.dist-info/entry_points.txt +2 -0
  54. moleditpy-1.16.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1964 @@
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
+
731
+ for rot in range(num_points):
732
+ match_double_count = 0
733
+ match_bonus = 0
734
+ mismatch_penalty = 0
735
+
736
+ # 【新規追加】接続部(Legs)の安全性チェック
737
+ # フューズ領域の両隣(テンプレート側)が「単結合(1)」であることを強く推奨する
738
+ # これにより、既存構造との接続点での原子価オーバー(手が5本になる)を防ぐ
739
+ safe_connection_score = 0
740
+
741
+ # フューズ領域の開始と終了を探す(インデックス集合から判定)
742
+ fused_indices = sorted(list(existing_orders.keys()))
743
+ # 連続領域と仮定して、端のインデックスを取得
744
+ # (0と5がつながっている環状のケースも考慮すべきだが、簡易的に最小/最大で判定し、
745
+ # もし飛び地なら不整合ペナルティで自然と落ちる)
746
+
747
+ # 簡易的な隣接チェック:
748
+ # フューズに使われる辺集合に含まれない「その隣」の辺を見る
749
+ for k in existing_orders:
750
+ # 左隣を見る
751
+ prev_idx = (k - 1 + rot) % num_points
752
+ # 右隣を見る
753
+ next_idx = (k + 1 + rot) % num_points
754
+
755
+ # もし隣がフューズ領域外(=接続部)なら、その次数をチェック
756
+ # 注意: existing_ordersのキーは「配置位置(k)」
757
+ # rotはテンプレートのズレ。
758
+ # テンプレート上の該当エッジの次数は orig_orders[(neighbor_k + rot)] ではなく
759
+ # orig_orders[neighbor_template_index]
760
+
761
+ # 正確なロジック:
762
+ # 今、配置位置 k にテンプレートの bond (k+rot) が来ている。
763
+ # 配置位置 k の「隣のボンド」ではなく、
764
+ # 「テンプレート上で」そのボンドの両隣にあるボンドが、今回のフューズに使われていないか確認する。
765
+ pass
766
+
767
+ # --- シンプルな実装: 全ての非フューズ辺(外周になる辺)をチェック ---
768
+ # 「フューズに使われていない辺」が単結合か二重結合かで加点
769
+ # ピレンの場合(3辺フューズ)、残り3辺が外周。
770
+ # ベンゼン(D-S-D-S-D-S)において、D-S-Dでフューズすると、残りはS-D-S。
771
+ # 接合部(Legs)にあたるのは、残りのS-D-Sの両端のS。これが重要。
772
+
773
+ # テンプレートの結合次数配列
774
+ current_template_orders = [orig_orders[(i + rot) % num_points] for i in range(num_points)]
775
+
776
+ # フューズ領域の両端を特定するために、
777
+ # 「フューズしているk」に対応するテンプレート側のインデックスを集める
778
+ used_template_indices = set((k + rot) % num_points for k in existing_orders)
779
+
780
+ # テンプレート上で「使われている領域」の両隣(接続部)が「1(単結合)」なら超高得点
781
+ for t_idx in used_template_indices:
782
+ # そのボンドのテンプレート上の左隣
783
+ adj_l = (t_idx - 1) % num_points
784
+ # そのボンドのテンプレート上の右隣
785
+ adj_r = (t_idx + 1) % num_points
786
+
787
+ # もし隣が「使われていない」なら、それは接続部である
788
+ if adj_l not in used_template_indices:
789
+ if orig_orders[adj_l] == 1: safe_connection_score += 5000
790
+
791
+ if adj_r not in used_template_indices:
792
+ if orig_orders[adj_r] == 1: safe_connection_score += 5000
793
+
794
+ # 既存のスコア計算
795
+ for k, exist_order in existing_orders.items():
796
+ template_ord = orig_orders[(k + rot) % num_points]
797
+ if template_ord == exist_order:
798
+ match_bonus += 100
799
+ if exist_order == 2: match_double_count += 1
800
+ else:
801
+ # 不一致でも、Legsが安全なら許容したいのでペナルティは控えめに、
802
+ # または safe_connection_score が圧倒的に勝つようにする
803
+ mismatch_penalty += 50
804
+
805
+ # 最終スコア: 接続部の安全性を最優先
806
+ current_score = safe_connection_score + (match_double_count * 1000) + match_bonus - mismatch_penalty
807
+
808
+ if current_score > max_score:
809
+ max_score = current_score
810
+ best_rot = rot
811
+
812
+ elif len(existing_orders) == 1:
813
+ # 1辺フューズ
814
+ k_fuse = next(iter(existing_orders.keys()))
815
+ exist_order = existing_orders[k_fuse]
816
+
817
+ for rot in range(num_points):
818
+ current_score = 0
819
+ rotated_template_order = orig_orders[(k_fuse + rot) % num_points]
820
+
821
+ # 1. 接合部の次数マッチング
822
+
823
+ # パターンA: 交互配置(既存と逆)
824
+ if (exist_order == 1 and rotated_template_order == 2) or \
825
+ (exist_order == 2 and rotated_template_order == 1):
826
+ current_score += 100
827
+
828
+ # 【追加変更点2】二重結合の重ね合わせ(共役維持)
829
+ # 既存が二重結合で、テンプレートも二重結合なら、ここで1つ消費される
830
+ elif (exist_order == 2 and rotated_template_order == 2):
831
+ current_score += 100
832
+
833
+ # 2. 両隣の辺の次数チェック(交互配置の維持を確認)
834
+ m_adj1 = (k_fuse - 1 + rot) % num_points
835
+ m_adj2 = (k_fuse + 1 + rot) % num_points
836
+ neighbor_order_1 = orig_orders[m_adj1]
837
+ neighbor_order_2 = orig_orders[m_adj2]
838
+
839
+ if exist_order == 1:
840
+ # 接合部が単なら、隣は二重であってほしい
841
+ if neighbor_order_1 == 2: current_score += 50
842
+ if neighbor_order_2 == 2: current_score += 50
843
+
844
+ elif exist_order == 2:
845
+ # 接合部が二重なら、隣は単であってほしい
846
+ if neighbor_order_1 == 1: current_score += 50
847
+ if neighbor_order_2 == 1: current_score += 50
848
+
849
+ # 3. タイブレーク(他の接触しない辺との整合性など)
850
+ for k, e_order in existing_orders.items():
851
+ if k != k_fuse:
852
+ r_t_order = orig_orders[(k + rot) % num_points]
853
+ if r_t_order == e_order: current_score += 10
854
+
855
+ if current_score > max_score:
856
+ max_score = current_score
857
+ best_rot = rot
858
+
859
+ # 最終的な回転を反映
860
+ new_tb = []
861
+ for m in range(num_points):
862
+ i_idx, j_idx, _ = bonds_info[m]
863
+ new_order = orig_orders[(m + best_rot) % num_points]
864
+ new_tb.append((i_idx, j_idx, new_order))
865
+ template_bonds_to_use = new_tb
866
+
867
+ # --- 5) ボンド作成/更新---
868
+ for id1_idx, id2_idx, order in template_bonds_to_use:
869
+ if id1_idx < len(atom_items) and id2_idx < len(atom_items):
870
+ a_item, b_item = atom_items[id1_idx], atom_items[id2_idx]
871
+ if not a_item or not b_item or a_item is b_item: continue
872
+
873
+ id1, id2 = a_item.atom_id, b_item.atom_id
874
+ if id1 > id2: id1, id2 = id2, id1
875
+
876
+ exist_b = self.find_bond_between(a_item, b_item)
877
+
878
+ if exist_b:
879
+ # デフォルトでは既存の結合を維持する
880
+ should_overwrite = False
881
+
882
+ # 条件1: ベンゼン環テンプレートであること
883
+ # 条件2: 接続先が単結合であること
884
+ if is_benzene_template and exist_b.order == 1:
885
+
886
+ # 条件3: 接続先の単結合が共役系の一部ではないこと
887
+ # (つまり、両端の原子が他に二重結合を持たないこと)
888
+ atom1 = exist_b.atom1
889
+ atom2 = exist_b.atom2
890
+
891
+ # atom1が他に二重結合を持つかチェック
892
+ atom1_has_other_double_bond = any(b.order == 2 for b in atom1.bonds if b is not exist_b)
893
+
894
+ # atom2が他に二重結合を持つかチェック
895
+ atom2_has_other_double_bond = any(b.order == 2 for b in atom2.bonds if b is not exist_b)
896
+
897
+ # 両方の原子が他に二重結合を持たない「孤立した単結合」の場合のみ上書きフラグを立てる
898
+ if not atom1_has_other_double_bond and not atom2_has_other_double_bond:
899
+ should_overwrite = True
900
+
901
+ if should_overwrite:
902
+ # 上書き条件が全て満たされた場合にのみ、結合次数を更新
903
+ exist_b.order = order
904
+ exist_b.stereo = 0
905
+ self.data.bonds[(id1, id2)]['order'] = order
906
+ self.data.bonds[(id1, id2)]['stereo'] = 0
907
+ exist_b.update()
908
+ else:
909
+ # 上書き条件を満たさない場合は、既存の結合を維持する
910
+ continue
911
+ else:
912
+ # 新規ボンド作成
913
+ self.create_bond(a_item, b_item, bond_order=order, bond_stereo=0)
914
+
915
+ # --- 6) 表示更新 ---
916
+ for at in atom_items:
917
+ try:
918
+ if at: at.update_style()
919
+ except Exception:
920
+ pass
921
+
922
+ return atom_items
923
+
924
+
925
+ def update_template_preview(self, pos):
926
+ mode_parts = self.mode.split('_')
927
+
928
+ # Check if this is a user template
929
+ if len(mode_parts) >= 3 and mode_parts[1] == 'user':
930
+ self.update_user_template_preview(pos)
931
+ return
932
+
933
+ is_aromatic = False
934
+ if mode_parts[1] == 'benzene':
935
+ n = 6
936
+ is_aromatic = True
937
+ else:
938
+ try: n = int(mode_parts[1])
939
+ except ValueError: return
940
+
941
+ items_under = self.items(pos) # top-most first
942
+ item = None
943
+ for it in items_under:
944
+ if isinstance(it, (AtomItem, BondItem)):
945
+ item = it
946
+ break
947
+
948
+ points, bonds_info = [], []
949
+ l = DEFAULT_BOND_LENGTH
950
+ self.template_context = {}
951
+
952
+
953
+ if isinstance(item, AtomItem):
954
+ p0 = item.pos()
955
+ continuous_angle = math.atan2(pos.y() - p0.y(), pos.x() - p0.x())
956
+ snap_angle_rad = math.radians(15)
957
+ snapped_angle = round(continuous_angle / snap_angle_rad) * snap_angle_rad
958
+ p1 = p0 + QPointF(l * math.cos(snapped_angle), l * math.sin(snapped_angle))
959
+ points = self._calculate_polygon_from_edge(p0, p1, n)
960
+ self.template_context['items'] = [item]
961
+
962
+ elif isinstance(item, BondItem):
963
+ # 結合にスナップ
964
+ p0, p1 = item.atom1.pos(), item.atom2.pos()
965
+ points = self._calculate_polygon_from_edge(p0, p1, n, cursor_pos=pos, use_existing_length=True)
966
+ self.template_context['items'] = [item.atom1, item.atom2]
967
+
968
+ else:
969
+ angle_step = 2 * math.pi / n
970
+ start_angle = -math.pi / 2 if n % 2 != 0 else -math.pi / 2 - angle_step / 2
971
+ points = [
972
+ pos + QPointF(l * math.cos(start_angle + i * angle_step), l * math.sin(start_angle + i * angle_step))
973
+ for i in range(n)
974
+ ]
975
+
976
+ if points:
977
+ if is_aromatic:
978
+ bonds_info = [(i, (i + 1) % n, 2 if i % 2 == 0 else 1) for i in range(n)]
979
+ else:
980
+ bonds_info = [(i, (i + 1) % n, 1) for i in range(n)]
981
+
982
+ self.template_context['points'] = points
983
+ self.template_context['bonds_info'] = bonds_info
984
+
985
+ self.template_preview.set_geometry(points, is_aromatic)
986
+
987
+ self.template_preview.show()
988
+ if self.views():
989
+ self.views()[0].viewport().update()
990
+ else:
991
+ self.template_preview.hide()
992
+ if self.views():
993
+ self.views()[0].viewport().update()
994
+
995
+ def _calculate_polygon_from_edge(self, p0, p1, n, cursor_pos=None, use_existing_length=False):
996
+ if n < 3: return []
997
+ v_edge = p1 - p0
998
+ edge_length = math.sqrt(v_edge.x()**2 + v_edge.y()**2)
999
+ if edge_length == 0: return []
1000
+
1001
+ target_length = edge_length if use_existing_length else DEFAULT_BOND_LENGTH
1002
+
1003
+ v_edge = (v_edge / edge_length) * target_length
1004
+
1005
+ if not use_existing_length:
1006
+ p1 = p0 + v_edge
1007
+
1008
+ points = [p0, p1]
1009
+
1010
+ interior_angle = (n - 2) * math.pi / n
1011
+ rotation_angle = math.pi - interior_angle
1012
+
1013
+ if cursor_pos:
1014
+ # Note: v_edgeは正規化済みだが、方向は同じなので判定には問題ない
1015
+ v_cursor = cursor_pos - p0
1016
+ cross_product_z = (p1 - p0).x() * v_cursor.y() - (p1 - p0).y() * v_cursor.x()
1017
+ if cross_product_z < 0:
1018
+ rotation_angle = -rotation_angle
1019
+
1020
+ cos_a, sin_a = math.cos(rotation_angle), math.sin(rotation_angle)
1021
+
1022
+ current_p, current_v = p1, v_edge
1023
+ for _ in range(n - 2):
1024
+ new_vx = current_v.x() * cos_a - current_v.y() * sin_a
1025
+ new_vy = current_v.x() * sin_a + current_v.y() * cos_a
1026
+ current_v = QPointF(new_vx, new_vy)
1027
+ current_p = current_p + current_v
1028
+ points.append(current_p)
1029
+ return points
1030
+
1031
+ def delete_items(self, items_to_delete):
1032
+ """指定されたアイテムセット(原子・結合)を安全な順序で削除する修正版"""
1033
+ # Hardened deletion: perform data-model removals first, then scene removals,
1034
+ # and always defensively check attributes to avoid accessing partially-deleted objects.
1035
+ if not items_to_delete:
1036
+ return False
1037
+
1038
+ # First sanitize the incoming collection: only keep live, expected QGraphics wrappers
1039
+ try:
1040
+ sanitized = set()
1041
+ for it in items_to_delete:
1042
+ try:
1043
+ if it is None:
1044
+ continue
1045
+ # Skip SIP-deleted wrappers early to avoid native crashes
1046
+ if sip_isdeleted_safe(it):
1047
+ continue
1048
+ # Only accept AtomItem/BondItem or other QGraphicsItem subclasses
1049
+ if isinstance(it, (AtomItem, BondItem, QGraphicsItem)):
1050
+ sanitized.add(it)
1051
+ except Exception:
1052
+ # If isinstance or sip check raises, skip this entry
1053
+ continue
1054
+ items_to_delete = sanitized
1055
+ except Exception:
1056
+ # If sanitization fails, fall back to original input and proceed defensively
1057
+ pass
1058
+
1059
+ try:
1060
+ atoms_to_delete = {item for item in items_to_delete if isinstance(item, AtomItem)}
1061
+ bonds_to_delete = {item for item in items_to_delete if isinstance(item, BondItem)}
1062
+
1063
+ # Include bonds attached to atoms being deleted
1064
+ for atom in list(atoms_to_delete):
1065
+ try:
1066
+ if hasattr(atom, 'bonds') and atom.bonds:
1067
+ for b in list(atom.bonds):
1068
+ bonds_to_delete.add(b)
1069
+ except Exception:
1070
+ # If accessing bonds raises (item partially deleted), skip
1071
+ continue
1072
+
1073
+ # Determine atoms that will remain but whose bond lists must be updated
1074
+ atoms_to_update = set()
1075
+ for bond in list(bonds_to_delete):
1076
+ try:
1077
+ a1 = getattr(bond, 'atom1', None)
1078
+ a2 = getattr(bond, 'atom2', None)
1079
+ if a1 and a1 not in atoms_to_delete:
1080
+ atoms_to_update.add(a1)
1081
+ if a2 and a2 not in atoms_to_delete:
1082
+ atoms_to_update.add(a2)
1083
+ except Exception:
1084
+ continue
1085
+
1086
+ # 1) Update surviving atoms' bond lists to remove references to bonds_to_delete
1087
+ # (Important: remove BondItem references so atoms properly reflect
1088
+ # that they have no remaining bonds and update visibility accordingly.)
1089
+ for atom in list(atoms_to_update):
1090
+ try:
1091
+ if sip_isdeleted_safe(atom):
1092
+ continue
1093
+ # Defensive: if the atom has a bonds list, filter out bonds being deleted
1094
+ if hasattr(atom, 'bonds') and atom.bonds:
1095
+ try:
1096
+ # Replace in-place to preserve any other references.
1097
+ # Avoid touching SIP-deleted bond wrappers: build a set
1098
+ # of live bonds-to-delete and also prune any SIP-deleted
1099
+ # entries that may exist in atom.bonds.
1100
+ live_btd = {b for b in bonds_to_delete if not sip_isdeleted_safe(b)}
1101
+
1102
+ # First, remove any SIP-deleted bond wrappers from atom.bonds
1103
+ atom.bonds[:] = [b for b in atom.bonds if not sip_isdeleted_safe(b)]
1104
+
1105
+ # Then remove bonds which are in the live_btd set
1106
+ if live_btd:
1107
+ atom.bonds[:] = [b for b in atom.bonds if b not in live_btd]
1108
+ except Exception:
1109
+ # Fall back to iterative removal if list comprehension fails
1110
+ try:
1111
+ live_btd = [b for b in list(bonds_to_delete) if not sip_isdeleted_safe(b)]
1112
+ for b in live_btd:
1113
+ if b in atom.bonds:
1114
+ atom.bonds.remove(b)
1115
+ except Exception:
1116
+ pass
1117
+
1118
+ # After pruning bond references, update visual style so carbons without
1119
+ # bonds become visible again.
1120
+ if hasattr(atom, 'update_style'):
1121
+ atom.update_style()
1122
+ except Exception:
1123
+ continue
1124
+
1125
+ # 2) Remove bonds/atoms from the data model first (so other code reading the model
1126
+ # doesn't encounter stale entries while we are removing graphics)
1127
+ for bond in list(bonds_to_delete):
1128
+ try:
1129
+ a1 = getattr(bond, 'atom1', None)
1130
+ a2 = getattr(bond, 'atom2', None)
1131
+ if a1 and a2 and hasattr(self, 'data'):
1132
+ try:
1133
+ self.data.remove_bond(a1.atom_id, a2.atom_id)
1134
+ except Exception:
1135
+ # try reverse order if remove_bond expects ordered tuple
1136
+ try:
1137
+ self.data.remove_bond(a2.atom_id, a1.atom_id)
1138
+ except Exception:
1139
+ pass
1140
+ except Exception:
1141
+ continue
1142
+
1143
+ for atom in list(atoms_to_delete):
1144
+ try:
1145
+ if hasattr(atom, 'atom_id') and hasattr(self, 'data'):
1146
+ try:
1147
+ self.data.remove_atom(atom.atom_id)
1148
+ except Exception:
1149
+ pass
1150
+ except Exception:
1151
+ continue
1152
+
1153
+ # Invalidate any pending implicit-hydrogen UI updates because the
1154
+ # underlying data model changed. This prevents a scheduled
1155
+ # update_implicit_hydrogens closure from touching atoms/bonds that
1156
+ # were just removed. Do a single increment rather than one per-atom.
1157
+ try:
1158
+ self._ih_update_counter += 1
1159
+ except Exception:
1160
+ try:
1161
+ self._ih_update_counter = 0
1162
+ except Exception:
1163
+ pass
1164
+
1165
+ # 3) Remove graphic items from the scene (bonds first)
1166
+ # To avoid calling into methods on wrappers that may refer to
1167
+ # already-deleted C++ objects (which can cause a native crash when
1168
+ # SIP is not available), take a snapshot of the current scene's
1169
+ # items and use membership tests instead of calling item.scene().
1170
+ try:
1171
+ current_scene_items = set(self.items())
1172
+ except Exception:
1173
+ # If for any reason items() fails, fall back to an empty set
1174
+ current_scene_items = set()
1175
+
1176
+ for bond in list(bonds_to_delete):
1177
+ try:
1178
+ # If the SIP wrapper is already deleted, skip it.
1179
+ if sip_isdeleted_safe(bond):
1180
+ continue
1181
+ # Only attempt to remove the bond if it is present in the
1182
+ # scene snapshot. This avoids calling bond.scene() which
1183
+ # may invoke C++ on a deleted object.
1184
+ if bond in current_scene_items:
1185
+ try:
1186
+ self.removeItem(bond)
1187
+ except Exception:
1188
+ pass
1189
+ except Exception:
1190
+ continue
1191
+
1192
+ for atom in list(atoms_to_delete):
1193
+ try:
1194
+ # Skip if wrapper is reported deleted by SIP
1195
+ if sip_isdeleted_safe(atom):
1196
+ continue
1197
+ if atom in current_scene_items:
1198
+ try:
1199
+ self.removeItem(atom)
1200
+ except Exception:
1201
+ pass
1202
+ except Exception:
1203
+ continue
1204
+
1205
+ # 4) Instead of aggressively nullling object attributes (which can
1206
+ # lead to C++/SIP finalization races and segfaults), keep a
1207
+ # strong reference to the deleted wrappers for the lifetime of
1208
+ # the scene. This prevents their underlying SIP wrappers from
1209
+ # being finalized while other code may still touch them.
1210
+ try:
1211
+ if not hasattr(self, '_deleted_items') or self._deleted_items is None:
1212
+ self._deleted_items = []
1213
+ except Exception:
1214
+ self._deleted_items = []
1215
+
1216
+ for bond in list(bonds_to_delete):
1217
+ try:
1218
+ # Hide the graphics item if possible and stash it
1219
+ if not sip_isdeleted_safe(bond):
1220
+ try:
1221
+ bond.hide()
1222
+ except Exception:
1223
+ pass
1224
+ try:
1225
+ self._deleted_items.append(bond)
1226
+ except Exception:
1227
+ # Swallow any error while stashing
1228
+ pass
1229
+ except Exception:
1230
+ continue
1231
+
1232
+ for atom in list(atoms_to_delete):
1233
+ try:
1234
+ if not sip_isdeleted_safe(atom):
1235
+ try:
1236
+ atom.hide()
1237
+ except Exception:
1238
+ pass
1239
+ try:
1240
+ self._deleted_items.append(atom)
1241
+ except Exception:
1242
+ pass
1243
+ except Exception:
1244
+ continue
1245
+
1246
+ # 5) Final visual updates for surviving atoms
1247
+ for atom in list(atoms_to_update):
1248
+ try:
1249
+ if hasattr(atom, 'update_style'):
1250
+ atom.update_style()
1251
+ except Exception:
1252
+ continue
1253
+
1254
+ return True
1255
+
1256
+ except Exception as e:
1257
+ # Keep the application alive on unexpected errors
1258
+ print(f"Error during delete_items operation: {e}")
1259
+
1260
+ traceback.print_exc()
1261
+ return False
1262
+ def purge_deleted_items(self):
1263
+ """Purge and release any held deleted-wrapper references.
1264
+
1265
+ This is intended to be invoked on application shutdown to allow
1266
+ the process to release references to SIP/C++ wrappers that were
1267
+ kept around to avoid finalization races during normal runtime.
1268
+ The method is defensive: it tolerates partially-deleted wrappers
1269
+ and any SIP unavailability.
1270
+ """
1271
+ try:
1272
+ if not hasattr(self, '_deleted_items') or not self._deleted_items:
1273
+ return
1274
+
1275
+ # Iterate a copy since we will clear the list.
1276
+ for obj in list(self._deleted_items):
1277
+ try:
1278
+ # If the wrapper is still alive, attempt to hide it so
1279
+ # the graphics subsystem isn't holding on to resources.
1280
+ if not sip_isdeleted_safe(obj):
1281
+ try:
1282
+ obj.hide()
1283
+ except Exception:
1284
+ pass
1285
+
1286
+ # Try to clear container attributes that may hold refs
1287
+ # to other scene objects (bonds, etc.) to help GC.
1288
+ try:
1289
+ if hasattr(obj, 'bonds') and getattr(obj, 'bonds') is not None:
1290
+ try:
1291
+ obj.bonds.clear()
1292
+ except Exception:
1293
+ # Try assignment fallback
1294
+ try:
1295
+ obj.bonds = []
1296
+ except Exception:
1297
+ pass
1298
+ except Exception:
1299
+ pass
1300
+
1301
+ except Exception:
1302
+ # Continue purging remaining items even if one fails.
1303
+ continue
1304
+
1305
+ # Finally, drop our references.
1306
+ try:
1307
+ self._deleted_items.clear()
1308
+ except Exception:
1309
+ try:
1310
+ self._deleted_items = []
1311
+ except Exception:
1312
+ pass
1313
+
1314
+ except Exception as e:
1315
+ # Never raise during shutdown
1316
+ try:
1317
+ print(f"Error purging deleted items: {e}")
1318
+ except Exception:
1319
+ pass
1320
+
1321
+ def add_user_template_fragment(self, context):
1322
+ """ユーザーテンプレートフラグメントを配置"""
1323
+ points = context.get('points', [])
1324
+ bonds_info = context.get('bonds_info', [])
1325
+ atoms_data = context.get('atoms_data', [])
1326
+ attachment_atom = context.get('attachment_atom')
1327
+
1328
+ if not points or not atoms_data:
1329
+ return
1330
+
1331
+ # Create atoms
1332
+ atom_id_map = {} # template id -> scene atom id
1333
+
1334
+ for i, (pos, atom_data) in enumerate(zip(points, atoms_data)):
1335
+ # Skip first atom if attaching to existing atom
1336
+ if i == 0 and attachment_atom:
1337
+ atom_id_map[atom_data['id']] = attachment_atom.atom_id
1338
+ continue
1339
+
1340
+ symbol = atom_data.get('symbol', 'C')
1341
+ charge = atom_data.get('charge', 0)
1342
+ radical = atom_data.get('radical', 0)
1343
+
1344
+ atom_id = self.data.add_atom(symbol, pos, charge, radical)
1345
+ atom_id_map[atom_data['id']] = atom_id
1346
+
1347
+ # Create visual atom item
1348
+ atom_item = AtomItem(atom_id, symbol, pos, charge, radical)
1349
+ self.data.atoms[atom_id]['item'] = atom_item
1350
+ self.addItem(atom_item)
1351
+
1352
+ # Create bonds (bonds_infoは必ずidベースで扱う)
1353
+ # まずindex→id変換テーブルを作る
1354
+ index_to_id = [atom_data.get('id', i) for i, atom_data in enumerate(atoms_data)]
1355
+ for bond_info in bonds_info:
1356
+ if isinstance(bond_info, (list, tuple)) and len(bond_info) >= 2:
1357
+ # bonds_infoの0,1番目がindexならidに変換
1358
+ atom1_idx = bond_info[0]
1359
+ atom2_idx = bond_info[1]
1360
+ order = bond_info[2] if len(bond_info) > 2 else 1
1361
+ stereo = bond_info[3] if len(bond_info) > 3 else 0
1362
+
1363
+ # index→id変換(すでにidならそのまま)
1364
+ if isinstance(atom1_idx, int) and atom1_idx < len(index_to_id):
1365
+ template_atom1_id = index_to_id[atom1_idx]
1366
+ else:
1367
+ template_atom1_id = atom1_idx
1368
+ if isinstance(atom2_idx, int) and atom2_idx < len(index_to_id):
1369
+ template_atom2_id = index_to_id[atom2_idx]
1370
+ else:
1371
+ template_atom2_id = atom2_idx
1372
+
1373
+ atom1_id = atom_id_map.get(template_atom1_id)
1374
+ atom2_id = atom_id_map.get(template_atom2_id)
1375
+
1376
+ if atom1_id is not None and atom2_id is not None:
1377
+ # Skip if bond already exists
1378
+ existing_bond = None
1379
+ if (atom1_id, atom2_id) in self.data.bonds:
1380
+ existing_bond = (atom1_id, atom2_id)
1381
+ elif (atom2_id, atom1_id) in self.data.bonds:
1382
+ existing_bond = (atom2_id, atom1_id)
1383
+
1384
+ if not existing_bond:
1385
+ bond_key, _ = self.data.add_bond(atom1_id, atom2_id, order, stereo)
1386
+ # Create visual bond item
1387
+ atom1_item = self.data.atoms[atom1_id]['item']
1388
+ atom2_item = self.data.atoms[atom2_id]['item']
1389
+ if atom1_item and atom2_item:
1390
+ bond_item = BondItem(atom1_item, atom2_item, order, stereo)
1391
+ self.data.bonds[bond_key]['item'] = bond_item
1392
+ self.addItem(bond_item)
1393
+ atom1_item.bonds.append(bond_item)
1394
+ atom2_item.bonds.append(bond_item)
1395
+
1396
+ # Update atom visuals
1397
+ for atom_id in atom_id_map.values():
1398
+ if atom_id in self.data.atoms and self.data.atoms[atom_id]['item']:
1399
+ self.data.atoms[atom_id]['item'].update_style()
1400
+
1401
+ def update_user_template_preview(self, pos):
1402
+ """ユーザーテンプレートのプレビューを更新"""
1403
+ # Robust user template preview: do not access self.data.atoms for preview-only atoms
1404
+ if not hasattr(self, 'user_template_data') or not self.user_template_data:
1405
+ return
1406
+
1407
+ template_data = self.user_template_data
1408
+ atoms = template_data.get('atoms', [])
1409
+ bonds = template_data.get('bonds', [])
1410
+
1411
+ if not atoms:
1412
+ return
1413
+
1414
+ # Find attachment point (first atom or clicked item)
1415
+ items_under = self.items(pos)
1416
+ attachment_atom = None
1417
+ for item in items_under:
1418
+ if isinstance(item, AtomItem):
1419
+ attachment_atom = item
1420
+ break
1421
+
1422
+ # Calculate template positions
1423
+ points = []
1424
+ # Find template bounds for centering
1425
+ if atoms:
1426
+ min_x = min(atom['x'] for atom in atoms)
1427
+ max_x = max(atom['x'] for atom in atoms)
1428
+ min_y = min(atom['y'] for atom in atoms)
1429
+ max_y = max(atom['y'] for atom in atoms)
1430
+ center_x = (min_x + max_x) / 2
1431
+ center_y = (min_y + max_y) / 2
1432
+ # Position template
1433
+ if attachment_atom:
1434
+ # Attach to existing atom
1435
+ attach_pos = attachment_atom.pos()
1436
+ offset_x = attach_pos.x() - atoms[0]['x']
1437
+ offset_y = attach_pos.y() - atoms[0]['y']
1438
+ else:
1439
+ # Center at cursor position
1440
+ offset_x = pos.x() - center_x
1441
+ offset_y = pos.y() - center_y
1442
+ # Calculate atom positions
1443
+ for atom in atoms:
1444
+ new_pos = QPointF(atom['x'] + offset_x, atom['y'] + offset_y)
1445
+ points.append(new_pos)
1446
+ # Create atom ID to index mapping (for preview only)
1447
+ atom_id_to_index = {}
1448
+ for i, atom in enumerate(atoms):
1449
+ atom_id = atom.get('id', i)
1450
+ atom_id_to_index[atom_id] = i
1451
+ # bonds_info をテンプレートの bonds から生成
1452
+ bonds_info = []
1453
+ for bond in bonds:
1454
+ atom1_idx = atom_id_to_index.get(bond['atom1'])
1455
+ atom2_idx = atom_id_to_index.get(bond['atom2'])
1456
+ if atom1_idx is not None and atom2_idx is not None:
1457
+ order = bond.get('order', 1)
1458
+ stereo = bond.get('stereo', 0)
1459
+ bonds_info.append((atom1_idx, atom2_idx, order, stereo))
1460
+ # プレビュー用: points, bonds_info から線を描画
1461
+ # 設置用 context を保存
1462
+ self.template_context = {
1463
+ 'points': points,
1464
+ 'bonds_info': bonds_info,
1465
+ 'atoms_data': atoms,
1466
+ 'attachment_atom': attachment_atom,
1467
+ }
1468
+ # 既存のプレビューアイテムを一旦クリア
1469
+ for item in list(self.items()):
1470
+ if isinstance(item, QGraphicsLineItem) and getattr(item, '_is_template_preview', False):
1471
+ self.removeItem(item)
1472
+
1473
+ # Draw preview lines only using calculated points (do not access self.data.atoms)
1474
+ for bond_info in bonds_info:
1475
+ if isinstance(bond_info, (list, tuple)) and len(bond_info) >= 2:
1476
+ i, j = bond_info[0], bond_info[1]
1477
+ order = bond_info[2] if len(bond_info) > 2 else 1
1478
+ # stereo = bond_info[3] if len(bond_info) > 3 else 0
1479
+ if i < len(points) and j < len(points):
1480
+ line = QGraphicsLineItem(QLineF(points[i], points[j]))
1481
+ pen = QPen(Qt.black, 2 if order == 2 else 1)
1482
+ line.setPen(pen)
1483
+ line._is_template_preview = True # フラグで区別
1484
+ self.addItem(line)
1485
+ # Never access self.data.atoms here for preview-only atoms
1486
+
1487
+ def leaveEvent(self, event):
1488
+ self.template_preview.hide(); super().leaveEvent(event)
1489
+
1490
+ def set_hovered_item(self, item):
1491
+ """BondItemから呼ばれ、ホバー中のアイテムを記録する"""
1492
+ self.hovered_item = item
1493
+
1494
+ def keyPressEvent(self, event):
1495
+ view = self.views()[0]
1496
+ cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
1497
+ item_at_cursor = self.itemAt(cursor_pos, view.transform())
1498
+ key = event.key()
1499
+ modifiers = event.modifiers()
1500
+
1501
+ if not self.window.is_2d_editable:
1502
+ return
1503
+
1504
+
1505
+ if key == Qt.Key.Key_4:
1506
+ # --- 動作1: カーソルが原子/結合上にある場合 (ワンショットでテンプレート配置) ---
1507
+ if isinstance(item_at_cursor, (AtomItem, BondItem)):
1508
+
1509
+ # ベンゼンテンプレートのパラメータを設定
1510
+ n, is_aromatic = 6, True
1511
+ points, bonds_info, existing_items = [], [], []
1512
+
1513
+ # update_template_preview と同様のロジックで配置情報を計算
1514
+ if isinstance(item_at_cursor, AtomItem):
1515
+ p0 = item_at_cursor.pos()
1516
+ l = DEFAULT_BOND_LENGTH
1517
+ direction = QLineF(p0, cursor_pos).unitVector()
1518
+ p1 = p0 + direction.p2() * l if direction.length() > 0 else p0 + QPointF(l, 0)
1519
+ points = self._calculate_polygon_from_edge(p0, p1, n)
1520
+ existing_items = [item_at_cursor]
1521
+
1522
+ elif isinstance(item_at_cursor, BondItem):
1523
+ p0, p1 = item_at_cursor.atom1.pos(), item_at_cursor.atom2.pos()
1524
+ points = self._calculate_polygon_from_edge(p0, p1, n, cursor_pos=cursor_pos, use_existing_length=True)
1525
+ existing_items = [item_at_cursor.atom1, item_at_cursor.atom2]
1526
+
1527
+ if points:
1528
+ bonds_info = [(i, (i + 1) % n, 2 if i % 2 == 0 else 1) for i in range(n)]
1529
+
1530
+ # 計算した情報を使って、その場にフラグメントを追加
1531
+ self.add_molecule_fragment(points, bonds_info, existing_items=existing_items)
1532
+ self.window.push_undo_state()
1533
+
1534
+ # --- 動作2: カーソルが空白領域にある場合 (モード切替) ---
1535
+ else:
1536
+ self.window.set_mode_and_update_toolbar('template_benzene')
1537
+
1538
+ event.accept()
1539
+ return
1540
+
1541
+ # --- 0a. ラジカルの変更 (.) ---
1542
+ if key == Qt.Key.Key_Period:
1543
+ target_atoms = []
1544
+ selected = self.selectedItems()
1545
+ if selected:
1546
+ target_atoms = [item for item in selected if isinstance(item, AtomItem)]
1547
+ elif isinstance(item_at_cursor, AtomItem):
1548
+ target_atoms = [item_at_cursor]
1549
+
1550
+ if target_atoms:
1551
+ for atom in target_atoms:
1552
+ # ラジカルの状態をトグル (0 -> 1 -> 2 -> 0)
1553
+ atom.prepareGeometryChange()
1554
+ atom.radical = (atom.radical + 1) % 3
1555
+ self.data.atoms[atom.atom_id]['radical'] = atom.radical
1556
+ atom.update_style()
1557
+ self.window.push_undo_state()
1558
+ event.accept()
1559
+ return
1560
+
1561
+ # --- 0b. 電荷の変更 (+/-キー) ---
1562
+ if key == Qt.Key.Key_Plus or key == Qt.Key.Key_Minus:
1563
+ target_atoms = []
1564
+ selected = self.selectedItems()
1565
+ if selected:
1566
+ target_atoms = [item for item in selected if isinstance(item, AtomItem)]
1567
+ elif isinstance(item_at_cursor, AtomItem):
1568
+ target_atoms = [item_at_cursor]
1569
+
1570
+ if target_atoms:
1571
+ delta = 1 if key == Qt.Key.Key_Plus else -1
1572
+ for atom in target_atoms:
1573
+ atom.prepareGeometryChange()
1574
+ atom.charge += delta
1575
+ self.data.atoms[atom.atom_id]['charge'] = atom.charge
1576
+ atom.update_style()
1577
+ self.window.push_undo_state()
1578
+ event.accept()
1579
+ return
1580
+
1581
+ # --- 1. Atomに対する操作 (元素記号の変更) ---
1582
+ if isinstance(item_at_cursor, AtomItem):
1583
+ new_symbol = None
1584
+ if modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_symbol_map:
1585
+ new_symbol = self.key_to_symbol_map[key]
1586
+ elif modifiers == Qt.KeyboardModifier.ShiftModifier and key in self.key_to_symbol_map_shift:
1587
+ new_symbol = self.key_to_symbol_map_shift[key]
1588
+
1589
+ if new_symbol and item_at_cursor.symbol != new_symbol:
1590
+ item_at_cursor.prepareGeometryChange()
1591
+
1592
+ item_at_cursor.symbol = new_symbol
1593
+ self.data.atoms[item_at_cursor.atom_id]['symbol'] = new_symbol
1594
+ item_at_cursor.update_style()
1595
+
1596
+
1597
+ atoms_to_update = {item_at_cursor}
1598
+ for bond in item_at_cursor.bonds:
1599
+ bond.update()
1600
+ other_atom = bond.atom1 if bond.atom2 is item_at_cursor else bond.atom2
1601
+ atoms_to_update.add(other_atom)
1602
+
1603
+ for atom in atoms_to_update:
1604
+ atom.update_style()
1605
+
1606
+ self.window.push_undo_state()
1607
+ event.accept()
1608
+ return
1609
+
1610
+ # --- 2. Bondに対する操作 (次数・立体化学の変更) ---
1611
+ target_bonds = []
1612
+ if isinstance(item_at_cursor, BondItem):
1613
+ target_bonds = [item_at_cursor]
1614
+ else:
1615
+ target_bonds = [it for it in self.selectedItems() if isinstance(it, BondItem)]
1616
+
1617
+ if target_bonds:
1618
+ any_bond_changed = False
1619
+ for bond in target_bonds:
1620
+ # 1. 結合の向きを考慮して、データ辞書内の現在のキーを正しく特定する
1621
+ id1, id2 = bond.atom1.atom_id, bond.atom2.atom_id
1622
+ current_key = None
1623
+ if (id1, id2) in self.data.bonds:
1624
+ current_key = (id1, id2)
1625
+ elif (id2, id1) in self.data.bonds:
1626
+ current_key = (id2, id1)
1627
+
1628
+ if not current_key: continue
1629
+
1630
+ # 2. 変更前の状態を保存
1631
+ old_order, old_stereo = bond.order, bond.stereo
1632
+
1633
+ # 3. キー入力に応じてBondItemのプロパティを変更
1634
+ if key == Qt.Key.Key_W:
1635
+ if bond.stereo == 1:
1636
+ bond_data = self.data.bonds.pop(current_key)
1637
+ new_key = (current_key[1], current_key[0])
1638
+ self.data.bonds[new_key] = bond_data
1639
+ bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1640
+ bond.update_position()
1641
+ was_reversed = True
1642
+ else:
1643
+ bond.order = 1; bond.stereo = 1
1644
+
1645
+ elif key == Qt.Key.Key_D:
1646
+ if bond.stereo == 2:
1647
+ bond_data = self.data.bonds.pop(current_key)
1648
+ new_key = (current_key[1], current_key[0])
1649
+ self.data.bonds[new_key] = bond_data
1650
+ bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1651
+ bond.update_position()
1652
+ was_reversed = True
1653
+ else:
1654
+ bond.order = 1; bond.stereo = 2
1655
+
1656
+ elif key == Qt.Key.Key_1 and (bond.order != 1 or bond.stereo != 0):
1657
+ bond.order = 1; bond.stereo = 0
1658
+ elif key == Qt.Key.Key_2 and (bond.order != 2 or bond.stereo != 0):
1659
+ bond.order = 2; bond.stereo = 0; needs_update = True
1660
+ elif key == Qt.Key.Key_3 and bond.order != 3:
1661
+ bond.order = 3; bond.stereo = 0; needs_update = True
1662
+
1663
+ # 4. 実際に変更があった場合のみデータモデルを更新
1664
+ if old_order != bond.order or old_stereo != bond.stereo:
1665
+ any_bond_changed = True
1666
+
1667
+ # 5. 古いキーでデータを辞書から一度削除
1668
+ bond_data = self.data.bonds.pop(current_key)
1669
+ bond_data['order'] = bond.order
1670
+ bond_data['stereo'] = bond.stereo
1671
+
1672
+ # 6. 変更後の種類に応じて新しいキーを決定し、再登録する
1673
+ new_key_id1, new_key_id2 = bond.atom1.atom_id, bond.atom2.atom_id
1674
+ if bond.stereo == 0:
1675
+ if new_key_id1 > new_key_id2:
1676
+ new_key_id1, new_key_id2 = new_key_id2, new_key_id1
1677
+
1678
+ new_key = (new_key_id1, new_key_id2)
1679
+ self.data.bonds[new_key] = bond_data
1680
+
1681
+ bond.update()
1682
+
1683
+ if any_bond_changed:
1684
+ self.window.push_undo_state()
1685
+
1686
+ if key in [Qt.Key.Key_1, Qt.Key.Key_2, Qt.Key.Key_3, Qt.Key.Key_W, Qt.Key.Key_D]:
1687
+ event.accept()
1688
+ return
1689
+
1690
+ if isinstance(self.hovered_item, BondItem) and self.hovered_item.order == 2:
1691
+ if event.key() == Qt.Key.Key_Z:
1692
+ self.update_bond_stereo(self.hovered_item, 3) # Z-isomer
1693
+ self.window.push_undo_state()
1694
+ event.accept()
1695
+ return
1696
+ elif event.key() == Qt.Key.Key_E:
1697
+ self.update_bond_stereo(self.hovered_item, 4) # E-isomer
1698
+ self.window.push_undo_state()
1699
+ event.accept()
1700
+ return
1701
+
1702
+ # --- 3. Atomに対する操作 (原子の追加 - マージされた機能) ---
1703
+ if key == Qt.Key.Key_1:
1704
+ start_atom = None
1705
+ if isinstance(item_at_cursor, AtomItem):
1706
+ start_atom = item_at_cursor
1707
+ else:
1708
+ selected_atoms = [item for item in self.selectedItems() if isinstance(item, AtomItem)]
1709
+ if len(selected_atoms) == 1:
1710
+ start_atom = selected_atoms[0]
1711
+
1712
+ if start_atom:
1713
+ start_pos = start_atom.pos()
1714
+ l = DEFAULT_BOND_LENGTH
1715
+ new_pos_offset = QPointF(0, -l) # デフォルトのオフセット (上)
1716
+
1717
+ # 接続している原子のリストを取得 (H原子以外)
1718
+ neighbor_positions = []
1719
+ for bond in start_atom.bonds:
1720
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1721
+ if other_atom.symbol != 'H': # 水素原子を無視 (四面体構造の考慮のため)
1722
+ neighbor_positions.append(other_atom.pos())
1723
+
1724
+ num_non_H_neighbors = len(neighbor_positions)
1725
+
1726
+ if num_non_H_neighbors == 0:
1727
+ # 結合ゼロ: デフォルト方向
1728
+ new_pos_offset = QPointF(0, -l)
1729
+
1730
+ elif num_non_H_neighbors == 1:
1731
+ # 結合1本: 既存結合と約120度(または60度)の角度
1732
+ bond = start_atom.bonds[0]
1733
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1734
+ existing_bond_vector = start_pos - other_atom.pos()
1735
+
1736
+ # 既存の結合から時計回り60度回転 (ベンゼン環のような構造にしやすい)
1737
+ angle_rad = math.radians(60)
1738
+ cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
1739
+ vx, vy = existing_bond_vector.x(), existing_bond_vector.y()
1740
+ new_vx, new_vy = vx * cos_a - vy * sin_a, vx * sin_a + vy * cos_a
1741
+ rotated_vector = QPointF(new_vx, new_vy)
1742
+ line = QLineF(QPointF(0, 0), rotated_vector)
1743
+ line.setLength(l)
1744
+ new_pos_offset = line.p2()
1745
+
1746
+ elif num_non_H_neighbors == 3:
1747
+
1748
+ bond_vectors_sum = QPointF(0, 0)
1749
+ for pos in neighbor_positions:
1750
+ # start_pos から neighbor_pos へのベクトル
1751
+ vec = pos - start_pos
1752
+ # 単位ベクトルに変換
1753
+ line_to_other = QLineF(QPointF(0,0), vec)
1754
+ if line_to_other.length() > 0:
1755
+ line_to_other.setLength(1.0)
1756
+ bond_vectors_sum += line_to_other.p2()
1757
+
1758
+ # SUM_TOLERANCE is now a module-level constant
1759
+ if bond_vectors_sum.manhattanLength() > SUM_TOLERANCE:
1760
+ new_direction_line = QLineF(QPointF(0,0), -bond_vectors_sum)
1761
+ new_direction_line.setLength(l)
1762
+ new_pos_offset = new_direction_line.p2()
1763
+ else:
1764
+ new_pos_offset = QPointF(l * 0.7071, -l * 0.7071)
1765
+
1766
+
1767
+ else: # 2本または4本以上の場合 (一般的な骨格の継続、または過結合)
1768
+ bond_vectors_sum = QPointF(0, 0)
1769
+ for bond in start_atom.bonds:
1770
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1771
+ line_to_other = QLineF(start_pos, other_atom.pos())
1772
+ if line_to_other.length() > 0:
1773
+ line_to_other.setLength(1.0)
1774
+ bond_vectors_sum += line_to_other.p2() - line_to_other.p1()
1775
+
1776
+ if bond_vectors_sum.manhattanLength() > 0.01:
1777
+ new_direction_line = QLineF(QPointF(0,0), -bond_vectors_sum)
1778
+ new_direction_line.setLength(l)
1779
+ new_pos_offset = new_direction_line.p2()
1780
+ else:
1781
+ # 総和がゼロの場合は、デフォルト(上)
1782
+ new_pos_offset = QPointF(0, -l)
1783
+
1784
+
1785
+ # SNAP_DISTANCE is a module-level constant
1786
+ target_pos = start_pos + new_pos_offset
1787
+
1788
+ # 近くに原子を探す
1789
+ near_atom = self.find_atom_near(target_pos, tol=SNAP_DISTANCE)
1790
+
1791
+ if near_atom and near_atom is not start_atom:
1792
+ # 近くに既存原子があれば結合
1793
+ self.create_bond(start_atom, near_atom)
1794
+ else:
1795
+ # 新規原子を作成し結合
1796
+ new_atom_id = self.create_atom('C', target_pos)
1797
+ new_atom_item = self.data.atoms[new_atom_id]['item']
1798
+ self.create_bond(start_atom, new_atom_item)
1799
+
1800
+ self.clearSelection()
1801
+ self.window.push_undo_state()
1802
+ event.accept()
1803
+ return
1804
+
1805
+ # --- 4. 全体に対する操作 (削除、モード切替など) ---
1806
+ if key == Qt.Key.Key_Delete or key == Qt.Key.Key_Backspace:
1807
+ if self.temp_line:
1808
+ try:
1809
+ if not sip_isdeleted_safe(self.temp_line):
1810
+ try:
1811
+ if getattr(self.temp_line, 'scene', None) and self.temp_line.scene():
1812
+ self.removeItem(self.temp_line)
1813
+ except Exception:
1814
+ pass
1815
+ except Exception:
1816
+ try:
1817
+ self.removeItem(self.temp_line)
1818
+ except Exception:
1819
+ pass
1820
+ self.temp_line = None; self.start_atom = None; self.start_pos = None
1821
+ self.initial_positions_in_event = {}
1822
+ event.accept()
1823
+ return
1824
+
1825
+ items_to_process = set(self.selectedItems())
1826
+ # カーソル下のアイテムも削除対象に加える
1827
+ if item_at_cursor and isinstance(item_at_cursor, (AtomItem, BondItem)):
1828
+ items_to_process.add(item_at_cursor)
1829
+
1830
+ if self.delete_items(items_to_process):
1831
+ self.window.push_undo_state()
1832
+ self.window.statusBar().showMessage("Deleted selected items.")
1833
+
1834
+ # もしデータモデル内の原子が全て無くなっていたら、シーンをクリアして初期状態に戻す
1835
+ if not self.data.atoms:
1836
+ # 1. シーン上の全グラフィックアイテムを削除する
1837
+ self.clear()
1838
+
1839
+ # 2. テンプレートプレビューなど、初期状態で必要なアイテムを再生成する
1840
+ self.reinitialize_items()
1841
+
1842
+ # 3. 結合描画中などの一時的な状態も完全にリセットする
1843
+ self.temp_line = None
1844
+ self.start_atom = None
1845
+ self.start_pos = None
1846
+ self.initial_positions_in_event = {}
1847
+
1848
+ # このイベントはここで処理完了とする
1849
+ event.accept()
1850
+ return
1851
+
1852
+ # 描画の強制更新
1853
+ if self.views():
1854
+ self.views()[0].viewport().update()
1855
+ QApplication.processEvents()
1856
+
1857
+ event.accept()
1858
+ return
1859
+
1860
+
1861
+ if key == Qt.Key.Key_Space:
1862
+ if self.mode != 'select':
1863
+ self.window.activate_select_mode()
1864
+ else:
1865
+ self.window.select_all()
1866
+ event.accept()
1867
+ return
1868
+
1869
+ # グローバルな描画モード切替
1870
+ mode_to_set = None
1871
+
1872
+ # 1. 原子描画モードへの切り替え
1873
+ symbol_for_mode_change = None
1874
+ if modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_symbol_map:
1875
+ symbol_for_mode_change = self.key_to_symbol_map[key]
1876
+ elif modifiers == Qt.KeyboardModifier.ShiftModifier and key in self.key_to_symbol_map_shift:
1877
+ symbol_for_mode_change = self.key_to_symbol_map_shift[key]
1878
+
1879
+ if symbol_for_mode_change:
1880
+ mode_to_set = f'atom_{symbol_for_mode_change}'
1881
+
1882
+ # 2. 結合描画モードへの切り替え
1883
+ elif modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_bond_mode_map:
1884
+ mode_to_set = self.key_to_bond_mode_map[key]
1885
+
1886
+ # モードが決定されていれば、モード変更を実行
1887
+ if mode_to_set:
1888
+ if hasattr(self.window, 'set_mode_and_update_toolbar'):
1889
+ self.window.set_mode_and_update_toolbar(mode_to_set)
1890
+ event.accept()
1891
+ return
1892
+
1893
+ # --- どの操作にも当てはまらない場合 ---
1894
+ super().keyPressEvent(event)
1895
+
1896
+ def find_atom_near(self, pos, tol=14.0):
1897
+ # Create a small search rectangle around the position
1898
+ search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
1899
+ nearby_items = self.items(search_rect)
1900
+
1901
+ for it in nearby_items:
1902
+ if isinstance(it, AtomItem):
1903
+ # Check the precise distance only for candidate items
1904
+ if QLineF(it.pos(), pos).length() <= tol:
1905
+ return it
1906
+ return None
1907
+
1908
+ def find_bond_between(self, atom1, atom2):
1909
+ for b in atom1.bonds:
1910
+ if (b.atom1 is atom1 and b.atom2 is atom2) or \
1911
+ (b.atom1 is atom2 and b.atom2 is atom1):
1912
+ return b
1913
+ return None
1914
+
1915
+ def update_bond_stereo(self, bond_item, new_stereo):
1916
+ """結合の立体化学を更新する共通メソッド"""
1917
+ try:
1918
+ if bond_item is None:
1919
+ print("Error: bond_item is None in update_bond_stereo")
1920
+ return
1921
+
1922
+ if bond_item.order != 2 or bond_item.stereo == new_stereo:
1923
+ return
1924
+
1925
+ if not hasattr(bond_item, 'atom1') or not hasattr(bond_item, 'atom2'):
1926
+ print("Error: bond_item missing atom references")
1927
+ return
1928
+
1929
+ if bond_item.atom1 is None or bond_item.atom2 is None:
1930
+ print("Error: bond_item has None atom references")
1931
+ return
1932
+
1933
+ if not hasattr(bond_item.atom1, 'atom_id') or not hasattr(bond_item.atom2, 'atom_id'):
1934
+ print("Error: bond atoms missing atom_id")
1935
+ return
1936
+
1937
+ id1, id2 = bond_item.atom1.atom_id, bond_item.atom2.atom_id
1938
+
1939
+ # E/Z結合は方向性を持つため、キーは(id1, id2)のまま探す
1940
+ key_to_update = (id1, id2)
1941
+ if key_to_update not in self.data.bonds:
1942
+ # Wedge/Dashなど、逆順で登録されている可能性も考慮
1943
+ key_to_update = (id2, id1)
1944
+ if key_to_update not in self.data.bonds:
1945
+ # Log error instead of printing to console
1946
+ if hasattr(self.window, 'statusBar'):
1947
+ self.window.statusBar().showMessage(f"Warning: Bond between atoms {id1} and {id2} not found in data model.", 3000)
1948
+ print(f"Error: Bond key not found: {id1}-{id2} or {id2}-{id1}")
1949
+ return
1950
+
1951
+ # Update data model
1952
+ self.data.bonds[key_to_update]['stereo'] = new_stereo
1953
+
1954
+ # Update visual representation
1955
+ bond_item.set_stereo(new_stereo)
1956
+
1957
+ self.data_changed_in_event = True
1958
+
1959
+ except Exception as e:
1960
+ print(f"Error in update_bond_stereo: {e}")
1961
+
1962
+ traceback.print_exc()
1963
+ if hasattr(self.window, 'statusBar'):
1964
+ self.window.statusBar().showMessage(f"Error updating bond stereochemistry: {e}", 5000)