MoleditPy 1.15.1__py3-none-any.whl → 1.16.0__py3-none-any.whl

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