MoleditPy-linux 2.4.1__py3-none-any.whl

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