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,102 @@
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
+ from pyvistaqt import QtInteractor
14
+ import time
15
+
16
+
17
+ class CustomQtInteractor(QtInteractor):
18
+ def __init__(self, parent=None, main_window=None, **kwargs):
19
+ super().__init__(parent, **kwargs)
20
+ self.main_window = main_window
21
+ # Track recent clicks so we can detect and swallow triple-clicks
22
+ # Triple-clicks are not a distinct Qt event on all platforms, so we
23
+ # implement a small timing-based counter here and accept the event
24
+ # when 3 rapid clicks are detected to prevent them from reaching
25
+ # the VTK interactor and causing unexpected behaviour in the 3D view.
26
+ self._last_click_time = 0.0
27
+ self._click_count = 0
28
+ self._ignore_next_release = False
29
+
30
+ def wheelEvent(self, event):
31
+ """
32
+ マウスホイールイベントをオーバーライドする。
33
+ """
34
+ # 最初に親クラスのイベントを呼び、通常のズーム処理を実行させる
35
+ super().wheelEvent(event)
36
+
37
+ # ズーム処理の完了後、2Dビューにフォーカスを強制的に戻す
38
+ if self.main_window and hasattr(self.main_window, 'view_2d'):
39
+ self.main_window.view_2d.setFocus()
40
+
41
+ def mouseReleaseEvent(self, event):
42
+ """
43
+ Qtのマウスリリースイベントをオーバーライドし、
44
+ 3Dビューでの全ての操作完了後に2Dビューへフォーカスを戻す。
45
+ また、Ghost Release(対応するPressがないRelease)をフィルタリングする。
46
+ """
47
+ if self._ignore_next_release:
48
+ self._ignore_next_release = False
49
+ event.accept()
50
+ return
51
+
52
+ super().mouseReleaseEvent(event) # 親クラスのイベントを先に処理
53
+ if self.main_window and hasattr(self.main_window, 'view_2d'):
54
+ self.main_window.view_2d.setFocus()
55
+
56
+ def mousePressEvent(self, event):
57
+ """
58
+ Custom mouse press handling to track accumulated clicks and filter out
59
+ triple-clicks.
60
+ """
61
+ current_time = time.time()
62
+ # Reset count if too much time has passed (0.5s is standard double-click time)
63
+ if current_time - self._last_click_time > 0.5:
64
+ self._click_count = 0
65
+
66
+ self._click_count += 1
67
+ self._last_click_time = current_time
68
+
69
+ # If this is the 3rd click (or more), swallow it to prevent
70
+ # the internal state desync that happens with rapid clicking sequences.
71
+ if self._click_count >= 3:
72
+ self._ignore_next_release = True
73
+ event.accept()
74
+ return
75
+
76
+ super().mousePressEvent(event)
77
+
78
+ def mouseDoubleClickEvent(self, event):
79
+ """Ignore mouse double-clicks on the 3D widget to avoid accidental actions.
80
+
81
+ Swallow the double-click event so it doesn't trigger selection, editing,
82
+ or camera jumps. We intentionally do not call the superclass handler.
83
+ Crucially, we also flag the NEXT release event to be swallowed, preventing
84
+ a "Ghost Release" (Release without Press) from reaching VTK.
85
+ """
86
+ current_time = time.time()
87
+ self._last_click_time = current_time
88
+ # Set to 2 to ensure the next click counts as 3rd
89
+ if current_time - self._last_click_time < 0.5:
90
+ self._click_count = 2
91
+ else:
92
+ self._click_count = 2 # Force sync
93
+
94
+ # We must ignore the release event that follows this double-click event
95
+ self._ignore_next_release = True
96
+
97
+ try:
98
+ # Accept the event to mark it handled and prevent further processing.
99
+ event.accept()
100
+ except Exception:
101
+ # If event doesn't support accept for some reason, just return.
102
+ return
@@ -0,0 +1,141 @@
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
+ from PyQt6.QtCore import Qt, QEvent
14
+ import numpy as np
15
+ try:
16
+ from .constants import pt
17
+ except Exception:
18
+ from modules.constants import pt
19
+
20
+
21
+ class Dialog3DPickingMixin:
22
+ """3D原子選択のための共通機能を提供するMixin"""
23
+
24
+ def __init__(self):
25
+ """Mixinの初期化"""
26
+ self.picking_enabled = False
27
+
28
+ def eventFilter(self, obj, event):
29
+ """3Dビューでのマウスクリックをキャプチャする(元の3D editロジックを正確に再現)"""
30
+ if (obj == self.main_window.plotter.interactor and
31
+ event.type() == QEvent.Type.MouseButtonPress and
32
+ event.button() == Qt.MouseButton.LeftButton):
33
+
34
+ # Start tracking for smart selection (click vs drag)
35
+ self._mouse_press_pos = event.pos()
36
+ self._mouse_moved = False
37
+
38
+
39
+ try:
40
+ # VTKイベント座標を取得(元のロジックと同じ)
41
+ interactor = self.main_window.plotter.interactor
42
+ click_pos = interactor.GetEventPosition()
43
+ picker = self.main_window.plotter.picker
44
+ picker.Pick(click_pos[0], click_pos[1], 0, self.main_window.plotter.renderer)
45
+
46
+ if picker.GetActor() is self.main_window.atom_actor:
47
+ picked_position = np.array(picker.GetPickPosition())
48
+ distances = np.linalg.norm(self.main_window.atom_positions_3d - picked_position, axis=1)
49
+ closest_atom_idx = np.argmin(distances)
50
+
51
+ # 範囲チェックを追加
52
+ if 0 <= closest_atom_idx < self.mol.GetNumAtoms():
53
+ # クリック閾値チェック(元のロジックと同じ)
54
+ atom = self.mol.GetAtomWithIdx(int(closest_atom_idx))
55
+ if atom:
56
+ try:
57
+ atomic_num = atom.GetAtomicNum()
58
+ vdw_radius = pt.GetRvdw(atomic_num)
59
+ if vdw_radius < 0.1: vdw_radius = 1.5
60
+ except Exception:
61
+ vdw_radius = 1.5
62
+ click_threshold = vdw_radius * 1.5
63
+
64
+ if distances[closest_atom_idx] < click_threshold:
65
+ # We handled the pick (atom clicked) -> consume the event so
66
+ # other UI elements (including the VTK interactor observers)
67
+ # don't also process it. Set a flag on the main window so
68
+ # the VTK-based handlers can ignore the same logical click
69
+ # when it arrives via the VTK event pipeline.
70
+ try:
71
+ self.main_window._picking_consumed = True
72
+ except Exception:
73
+ pass
74
+ self.on_atom_picked(int(closest_atom_idx))
75
+
76
+ # We picked an atom, so stop tracking for background click
77
+ self._mouse_press_pos = None
78
+ return True
79
+
80
+ # 原子以外をクリックした場合
81
+ # 即時には解除せず、回転操作(ドラッグ)を許可する。
82
+ # 実際の解除は MouseButtonRelease イベントで行う。
83
+ return False
84
+
85
+ except Exception as e:
86
+ print(f"Error in eventFilter: {e}")
87
+ # On exception, don't swallow the event either — let the normal
88
+ # event pipeline continue so the UI remains responsive.
89
+ return False
90
+
91
+ # Add movement tracking for smart selection
92
+ elif (obj == self.main_window.plotter.interactor and
93
+ event.type() == QEvent.Type.MouseMove):
94
+ if hasattr(self, '_mouse_press_pos') and self._mouse_press_pos is not None:
95
+ # Check if moved significantly
96
+ diff = event.pos() - self._mouse_press_pos
97
+ if diff.manhattanLength() > 3:
98
+ self._mouse_moved = True
99
+
100
+ # Add release handling for smart selection
101
+ elif (obj == self.main_window.plotter.interactor and
102
+ event.type() == QEvent.Type.MouseButtonRelease and
103
+ event.button() == Qt.MouseButton.LeftButton):
104
+
105
+ if hasattr(self, '_mouse_press_pos') and self._mouse_press_pos is not None:
106
+ if not getattr(self, '_mouse_moved', False):
107
+ # Pure click (no drag) on background -> Clear selection
108
+ if hasattr(self, 'clear_selection'):
109
+ self.clear_selection()
110
+
111
+ # Reset state
112
+ self._mouse_press_pos = None
113
+ self._mouse_moved = False
114
+
115
+
116
+ return super().eventFilter(obj, event)
117
+
118
+ def enable_picking(self):
119
+ """3Dビューでの原子選択を有効にする"""
120
+ self.main_window.plotter.interactor.installEventFilter(self)
121
+ self.picking_enabled = True
122
+ # Ensure the main window flag exists
123
+ try:
124
+ self.main_window._picking_consumed = False
125
+ except Exception:
126
+ pass
127
+
128
+ def disable_picking(self):
129
+ """3Dビューでの原子選択を無効にする"""
130
+ if hasattr(self, 'picking_enabled') and self.picking_enabled:
131
+ self.main_window.plotter.interactor.removeEventFilter(self)
132
+ self.picking_enabled = False
133
+ try:
134
+ # Clear any leftover flag when picking is disabled
135
+ if hasattr(self.main_window, '_picking_consumed'):
136
+ self.main_window._picking_consumed = False
137
+ except Exception:
138
+ pass
139
+
140
+ def try_alternative_picking(self, x, y):
141
+ """代替のピッキング方法(使用しない)"""
@@ -0,0 +1,443 @@
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
+ from PyQt6.QtWidgets import (
14
+ QDialog, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QLineEdit, QWidget, QRadioButton
15
+ )
16
+
17
+ try:
18
+ from .dialog3_d_picking_mixin import Dialog3DPickingMixin
19
+ except Exception:
20
+ from modules.dialog3_d_picking_mixin import Dialog3DPickingMixin
21
+
22
+ from PyQt6.QtCore import Qt
23
+ import numpy as np
24
+ from PyQt6.QtWidgets import QMessageBox
25
+
26
+
27
+ class DihedralDialog(Dialog3DPickingMixin, QDialog):
28
+ def __init__(self, mol, main_window, preselected_atoms=None, parent=None):
29
+ QDialog.__init__(self, parent)
30
+ Dialog3DPickingMixin.__init__(self)
31
+ self.mol = mol
32
+ self.main_window = main_window
33
+ self.atom1_idx = None
34
+ self.atom2_idx = None # central bond start
35
+ self.atom3_idx = None # central bond end
36
+ self.atom4_idx = None
37
+
38
+ # 事前選択された原子を設定
39
+ if preselected_atoms and len(preselected_atoms) >= 4:
40
+ self.atom1_idx = preselected_atoms[0]
41
+ self.atom2_idx = preselected_atoms[1] # central bond start
42
+ self.atom3_idx = preselected_atoms[2] # central bond end
43
+ self.atom4_idx = preselected_atoms[3]
44
+
45
+ self.init_ui()
46
+
47
+ def init_ui(self):
48
+ self.setWindowTitle("Adjust Dihedral Angle")
49
+ self.setModal(False) # モードレスにしてクリックを阻害しない
50
+ # 常に前面表示
51
+ layout = QVBoxLayout(self)
52
+
53
+ # Instructions
54
+ instruction_label = QLabel("Click four atoms in order to define a dihedral angle. The rotation will be around the bond between the 2nd and 3rd atoms.")
55
+ instruction_label.setWordWrap(True)
56
+ layout.addWidget(instruction_label)
57
+
58
+ # Selected atoms display
59
+ self.selection_label = QLabel("No atoms selected")
60
+ layout.addWidget(self.selection_label)
61
+
62
+ # Current dihedral angle display
63
+ self.dihedral_label = QLabel("")
64
+ layout.addWidget(self.dihedral_label)
65
+
66
+ # New dihedral angle input
67
+ dihedral_layout = QHBoxLayout()
68
+ dihedral_layout.addWidget(QLabel("New dihedral angle (degrees):"))
69
+ self.dihedral_input = QLineEdit()
70
+ self.dihedral_input.setPlaceholderText("180.0")
71
+ dihedral_layout.addWidget(self.dihedral_input)
72
+ layout.addLayout(dihedral_layout)
73
+
74
+ # Movement options
75
+ group_box = QWidget()
76
+ group_layout = QVBoxLayout(group_box)
77
+ group_layout.addWidget(QLabel("Move:"))
78
+
79
+ self.move_group_radio = QRadioButton("Atom 1,2,3: Fixed, Atom 4 group: Rotate")
80
+ self.move_group_radio.setChecked(True)
81
+ group_layout.addWidget(self.move_group_radio)
82
+
83
+ self.move_atom_radio = QRadioButton("Atom 1,2,3: Fixed, Atom 4: Rotate atom only")
84
+ group_layout.addWidget(self.move_atom_radio)
85
+
86
+ self.both_groups_radio = QRadioButton("Central bond fixed: Both groups rotate equally")
87
+ group_layout.addWidget(self.both_groups_radio)
88
+
89
+ layout.addWidget(group_box)
90
+
91
+ # Buttons
92
+ button_layout = QHBoxLayout()
93
+ self.clear_button = QPushButton("Clear Selection")
94
+ self.clear_button.clicked.connect(self.clear_selection)
95
+ button_layout.addWidget(self.clear_button)
96
+
97
+ button_layout.addStretch()
98
+
99
+ self.apply_button = QPushButton("Apply")
100
+ self.apply_button.clicked.connect(self.apply_changes)
101
+ self.apply_button.setEnabled(False)
102
+ button_layout.addWidget(self.apply_button)
103
+
104
+ close_button = QPushButton("Close")
105
+ close_button.clicked.connect(self.reject)
106
+ button_layout.addWidget(close_button)
107
+
108
+ layout.addLayout(button_layout)
109
+
110
+ # Connect to main window's picker for DihedralDialog
111
+ self.picker_connection = None
112
+ self.enable_picking()
113
+
114
+ # 事前選択された原子がある場合は初期表示を更新
115
+ if self.atom1_idx is not None:
116
+ self.show_atom_labels()
117
+ self.update_display()
118
+
119
+ def on_atom_picked(self, atom_idx):
120
+ """原子がピックされたときの処理"""
121
+ if self.atom1_idx is None:
122
+ self.atom1_idx = atom_idx
123
+ elif self.atom2_idx is None:
124
+ self.atom2_idx = atom_idx
125
+ elif self.atom3_idx is None:
126
+ self.atom3_idx = atom_idx
127
+ elif self.atom4_idx is None:
128
+ self.atom4_idx = atom_idx
129
+ else:
130
+ # Reset and start over
131
+ self.atom1_idx = atom_idx
132
+ self.atom2_idx = None
133
+ self.atom3_idx = None
134
+ self.atom4_idx = None
135
+
136
+ # 原子ラベルを表示
137
+ self.show_atom_labels()
138
+ self.update_display()
139
+
140
+ def keyPressEvent(self, event):
141
+ """キーボードイベントを処理"""
142
+ if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
143
+ if self.apply_button.isEnabled():
144
+ self.apply_changes()
145
+ event.accept()
146
+ else:
147
+ super().keyPressEvent(event)
148
+
149
+ def closeEvent(self, event):
150
+ """ダイアログが閉じられる時の処理"""
151
+ self.clear_atom_labels()
152
+ self.disable_picking()
153
+ super().closeEvent(event)
154
+
155
+ def reject(self):
156
+ """キャンセル時の処理"""
157
+ self.clear_atom_labels()
158
+ self.disable_picking()
159
+ super().reject()
160
+
161
+ def accept(self):
162
+ """OK時の処理"""
163
+ self.clear_atom_labels()
164
+ self.disable_picking()
165
+ super().accept()
166
+
167
+ def clear_selection(self):
168
+ """選択をクリア"""
169
+ self.atom1_idx = None
170
+ self.atom2_idx = None # central bond start
171
+ self.atom3_idx = None # central bond end
172
+ self.atom4_idx = None
173
+ self.clear_atom_labels()
174
+ self.update_display()
175
+
176
+ def show_atom_labels(self):
177
+ """選択された原子にラベルを表示"""
178
+ # 既存のラベルをクリア
179
+ self.clear_atom_labels()
180
+
181
+ # 新しいラベルを表示
182
+ if not hasattr(self, 'selection_labels'):
183
+ self.selection_labels = []
184
+
185
+ selected_atoms = [self.atom1_idx, self.atom2_idx, self.atom3_idx, self.atom4_idx]
186
+ labels = ["1st", "2nd (bond start)", "3rd (bond end)", "4th"]
187
+ colors = ["yellow", "yellow", "yellow", "yellow"] # 全て黄色に統一
188
+
189
+ for i, atom_idx in enumerate(selected_atoms):
190
+ if atom_idx is not None:
191
+ pos = self.main_window.atom_positions_3d[atom_idx]
192
+ label_text = f"{labels[i]}"
193
+
194
+ # ラベルを追加
195
+ label_actor = self.main_window.plotter.add_point_labels(
196
+ [pos], [label_text],
197
+ point_size=20,
198
+ font_size=12,
199
+ text_color=colors[i],
200
+ always_visible=True
201
+ )
202
+ self.selection_labels.append(label_actor)
203
+
204
+ def clear_atom_labels(self):
205
+ """原子ラベルをクリア"""
206
+ if hasattr(self, 'selection_labels'):
207
+ for label_actor in self.selection_labels:
208
+ try:
209
+ self.main_window.plotter.remove_actor(label_actor)
210
+ except Exception:
211
+ pass
212
+ self.selection_labels = []
213
+
214
+ def update_display(self):
215
+ """表示を更新"""
216
+ selected_count = sum(x is not None for x in [self.atom1_idx, self.atom2_idx, self.atom3_idx, self.atom4_idx])
217
+
218
+ if selected_count == 0:
219
+ self.selection_label.setText("No atoms selected")
220
+ self.dihedral_label.setText("")
221
+ self.apply_button.setEnabled(False)
222
+ # Clear dihedral input when no selection
223
+ try:
224
+ self.dihedral_input.clear()
225
+ except Exception:
226
+ pass
227
+ elif selected_count < 4:
228
+ selected_atoms = [self.atom1_idx, self.atom2_idx, self.atom3_idx, self.atom4_idx]
229
+
230
+ display_parts = []
231
+ for atom_idx in selected_atoms:
232
+ if atom_idx is not None:
233
+ symbol = self.mol.GetAtomWithIdx(atom_idx).GetSymbol()
234
+ display_parts.append(f"{symbol}({atom_idx})")
235
+ else:
236
+ display_parts.append("?")
237
+
238
+ self.selection_label.setText(" - ".join(display_parts))
239
+ self.dihedral_label.setText("")
240
+ self.apply_button.setEnabled(False)
241
+ # Clear dihedral input while selection is incomplete
242
+ try:
243
+ self.dihedral_input.clear()
244
+ except Exception:
245
+ pass
246
+ else:
247
+ selected_atoms = [self.atom1_idx, self.atom2_idx, self.atom3_idx, self.atom4_idx]
248
+
249
+ display_parts = []
250
+ for atom_idx in selected_atoms:
251
+ symbol = self.mol.GetAtomWithIdx(atom_idx).GetSymbol()
252
+ display_parts.append(f"{symbol}({atom_idx})")
253
+
254
+ self.selection_label.setText(" - ".join(display_parts))
255
+
256
+ # Calculate current dihedral angle
257
+ current_dihedral = self.calculate_dihedral()
258
+ self.dihedral_label.setText(f"Current dihedral: {current_dihedral:.2f}°")
259
+ self.apply_button.setEnabled(True)
260
+ # Update dihedral input box with current dihedral
261
+ try:
262
+ self.dihedral_input.setText(f"{current_dihedral:.2f}")
263
+ except Exception:
264
+ pass
265
+
266
+ def calculate_dihedral(self):
267
+ """現在の二面角を計算(正しい公式を使用)"""
268
+ conf = self.mol.GetConformer()
269
+ pos1 = np.array(conf.GetAtomPosition(self.atom1_idx))
270
+ pos2 = np.array(conf.GetAtomPosition(self.atom2_idx))
271
+ pos3 = np.array(conf.GetAtomPosition(self.atom3_idx))
272
+ pos4 = np.array(conf.GetAtomPosition(self.atom4_idx))
273
+
274
+ # Vectors between consecutive atoms
275
+ v1 = pos2 - pos1 # 1->2
276
+ v2 = pos3 - pos2 # 2->3 (central bond)
277
+ v3 = pos4 - pos3 # 3->4
278
+
279
+ # Normalize the central bond vector
280
+ v2_norm = v2 / np.linalg.norm(v2)
281
+
282
+ # Calculate plane normal vectors
283
+ n1 = np.cross(v1, v2) # Normal to plane 1-2-3
284
+ n2 = np.cross(v2, v3) # Normal to plane 2-3-4
285
+
286
+ # Normalize the normal vectors
287
+ n1_norm = np.linalg.norm(n1)
288
+ n2_norm = np.linalg.norm(n2)
289
+
290
+ if n1_norm == 0 or n2_norm == 0:
291
+ return 0.0 # Atoms are collinear
292
+
293
+ n1 = n1 / n1_norm
294
+ n2 = n2 / n2_norm
295
+
296
+ # Calculate the cosine of the dihedral angle
297
+ cos_angle = np.dot(n1, n2)
298
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
299
+
300
+ # Calculate the sine for proper sign determination
301
+ sin_angle = np.dot(np.cross(n1, n2), v2_norm)
302
+
303
+ # Calculate the dihedral angle with correct sign
304
+ angle_rad = np.arctan2(sin_angle, cos_angle)
305
+ return np.degrees(angle_rad)
306
+
307
+ def apply_changes(self):
308
+ """変更を適用"""
309
+ if any(idx is None for idx in [self.atom1_idx, self.atom2_idx, self.atom3_idx, self.atom4_idx]):
310
+ return
311
+
312
+ try:
313
+ new_dihedral = float(self.dihedral_input.text())
314
+ if new_dihedral < -180 or new_dihedral > 180:
315
+ QMessageBox.warning(self, "Invalid Input", "Dihedral angle must be between -180 and 180 degrees.")
316
+ return
317
+ except ValueError:
318
+ QMessageBox.warning(self, "Invalid Input", "Please enter a valid number.")
319
+ return
320
+
321
+ # Apply the dihedral angle change
322
+ self.adjust_dihedral(new_dihedral)
323
+
324
+ # キラルラベルを更新
325
+ self.main_window.update_chiral_labels()
326
+
327
+ # Undo状態を保存
328
+ self.main_window.push_undo_state()
329
+
330
+ def adjust_dihedral(self, new_dihedral_deg):
331
+ """二面角を調整(改善されたアルゴリズム)"""
332
+ conf = self.mol.GetConformer()
333
+ pos1 = np.array(conf.GetAtomPosition(self.atom1_idx))
334
+ pos2 = np.array(conf.GetAtomPosition(self.atom2_idx))
335
+ pos3 = np.array(conf.GetAtomPosition(self.atom3_idx))
336
+ pos4 = np.array(conf.GetAtomPosition(self.atom4_idx))
337
+
338
+ # Current dihedral angle
339
+ current_dihedral = self.calculate_dihedral()
340
+
341
+ # Calculate rotation angle needed
342
+ rotation_angle_deg = new_dihedral_deg - current_dihedral
343
+
344
+ # Handle angle wrapping for shortest rotation
345
+ if rotation_angle_deg > 180:
346
+ rotation_angle_deg -= 360
347
+ elif rotation_angle_deg < -180:
348
+ rotation_angle_deg += 360
349
+
350
+ rotation_angle_rad = np.radians(rotation_angle_deg)
351
+
352
+ # Skip if no rotation needed
353
+ if abs(rotation_angle_rad) < 1e-6:
354
+ return
355
+
356
+ # Rotation axis is the bond between atom2 and atom3
357
+ rotation_axis = pos3 - pos2
358
+ axis_length = np.linalg.norm(rotation_axis)
359
+
360
+ if axis_length == 0:
361
+ return # Atoms are at the same position
362
+
363
+ rotation_axis = rotation_axis / axis_length
364
+
365
+ # Rodrigues' rotation formula implementation
366
+ def rotate_point_around_axis(point, axis_point, axis_direction, angle):
367
+ """Rotate a point around an axis using Rodrigues' formula"""
368
+ # Translate point so axis passes through origin
369
+ translated_point = point - axis_point
370
+
371
+ # Apply Rodrigues' rotation formula
372
+ cos_a = np.cos(angle)
373
+ sin_a = np.sin(angle)
374
+
375
+ rotated = (translated_point * cos_a +
376
+ np.cross(axis_direction, translated_point) * sin_a +
377
+ axis_direction * np.dot(axis_direction, translated_point) * (1 - cos_a))
378
+
379
+ # Translate back to original coordinate system
380
+ return rotated + axis_point
381
+
382
+ if self.both_groups_radio.isChecked():
383
+ # Both groups rotate equally around the central bond (half angle each in opposite directions)
384
+ half_rotation = rotation_angle_rad / 2
385
+
386
+ # Get both connected groups
387
+ group1_atoms = self.get_connected_group(self.atom2_idx, exclude=self.atom3_idx)
388
+ group4_atoms = self.get_connected_group(self.atom3_idx, exclude=self.atom2_idx)
389
+
390
+ # Rotate group1 (atom1 side) by -half_rotation
391
+ for atom_idx in group1_atoms:
392
+ current_pos = np.array(conf.GetAtomPosition(atom_idx))
393
+ new_pos = rotate_point_around_axis(current_pos, pos2, rotation_axis, -half_rotation)
394
+ conf.SetAtomPosition(atom_idx, new_pos.tolist())
395
+ self.main_window.atom_positions_3d[atom_idx] = new_pos
396
+
397
+ # Rotate group4 (atom4 side) by +half_rotation
398
+ for atom_idx in group4_atoms:
399
+ current_pos = np.array(conf.GetAtomPosition(atom_idx))
400
+ new_pos = rotate_point_around_axis(current_pos, pos2, rotation_axis, half_rotation)
401
+ conf.SetAtomPosition(atom_idx, new_pos.tolist())
402
+ self.main_window.atom_positions_3d[atom_idx] = new_pos
403
+
404
+ elif self.move_group_radio.isChecked():
405
+ # Move the connected group containing atom4
406
+ # Find all atoms connected to atom3 (excluding atom2 side)
407
+ atoms_to_rotate = self.get_connected_group(self.atom3_idx, exclude=self.atom2_idx)
408
+
409
+ # Rotate all atoms in the group
410
+ for atom_idx in atoms_to_rotate:
411
+ current_pos = np.array(conf.GetAtomPosition(atom_idx))
412
+ new_pos = rotate_point_around_axis(current_pos, pos2, rotation_axis, rotation_angle_rad)
413
+ conf.SetAtomPosition(atom_idx, new_pos.tolist())
414
+ self.main_window.atom_positions_3d[atom_idx] = new_pos
415
+ else:
416
+ # Move only atom4
417
+ new_pos4 = rotate_point_around_axis(pos4, pos2, rotation_axis, rotation_angle_rad)
418
+ conf.SetAtomPosition(self.atom4_idx, new_pos4.tolist())
419
+ self.main_window.atom_positions_3d[self.atom4_idx] = new_pos4
420
+
421
+ # Update the 3D view
422
+ self.main_window.draw_molecule_3d(self.mol)
423
+
424
+ def get_connected_group(self, start_atom, exclude=None):
425
+ """指定された原子から連結されているグループを取得"""
426
+ visited = set()
427
+ to_visit = [start_atom]
428
+
429
+ while to_visit:
430
+ current = to_visit.pop()
431
+ if current in visited or current == exclude:
432
+ continue
433
+
434
+ visited.add(current)
435
+
436
+ # Get neighboring atoms
437
+ atom = self.mol.GetAtomWithIdx(current)
438
+ for bond in atom.GetBonds():
439
+ other_idx = bond.GetOtherAtomIdx(current)
440
+ if other_idx not in visited and other_idx != exclude:
441
+ to_visit.append(other_idx)
442
+
443
+ return visited