MoleditPy-linux 2.2.4__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 (58) 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 +293 -0
  7. moleditpy_linux/modules/alignment_dialog.py +273 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/icon.icns +0 -0
  11. moleditpy_linux/modules/assets/icon.ico +0 -0
  12. moleditpy_linux/modules/assets/icon.png +0 -0
  13. moleditpy_linux/modules/atom_item.py +348 -0
  14. moleditpy_linux/modules/bond_item.py +406 -0
  15. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  16. moleditpy_linux/modules/calculation_worker.py +766 -0
  17. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  18. moleditpy_linux/modules/constants.py +88 -0
  19. moleditpy_linux/modules/constrained_optimization_dialog.py +679 -0
  20. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  21. moleditpy_linux/modules/custom_qt_interactor.py +59 -0
  22. moleditpy_linux/modules/dialog3_d_picking_mixin.py +108 -0
  23. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  24. moleditpy_linux/modules/main_window.py +842 -0
  25. moleditpy_linux/modules/main_window_app_state.py +780 -0
  26. moleditpy_linux/modules/main_window_compute.py +1242 -0
  27. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  28. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  29. moleditpy_linux/modules/main_window_edit_actions.py +1455 -0
  30. moleditpy_linux/modules/main_window_export.py +806 -0
  31. moleditpy_linux/modules/main_window_main_init.py +2006 -0
  32. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  33. moleditpy_linux/modules/main_window_project_io.py +434 -0
  34. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  35. moleditpy_linux/modules/main_window_ui_manager.py +606 -0
  36. moleditpy_linux/modules/main_window_view_3d.py +1531 -0
  37. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  38. moleditpy_linux/modules/mirror_dialog.py +122 -0
  39. moleditpy_linux/modules/molecular_data.py +302 -0
  40. moleditpy_linux/modules/molecule_scene.py +2000 -0
  41. moleditpy_linux/modules/move_group_dialog.py +598 -0
  42. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  43. moleditpy_linux/modules/planarize_dialog.py +221 -0
  44. moleditpy_linux/modules/plugin_interface.py +195 -0
  45. moleditpy_linux/modules/plugin_manager.py +309 -0
  46. moleditpy_linux/modules/plugin_manager_window.py +221 -0
  47. moleditpy_linux/modules/settings_dialog.py +1149 -0
  48. moleditpy_linux/modules/template_preview_item.py +157 -0
  49. moleditpy_linux/modules/template_preview_view.py +74 -0
  50. moleditpy_linux/modules/translation_dialog.py +365 -0
  51. moleditpy_linux/modules/user_template_dialog.py +692 -0
  52. moleditpy_linux/modules/zoomable_view.py +129 -0
  53. moleditpy_linux-2.2.4.dist-info/METADATA +936 -0
  54. moleditpy_linux-2.2.4.dist-info/RECORD +58 -0
  55. moleditpy_linux-2.2.4.dist-info/WHEEL +5 -0
  56. moleditpy_linux-2.2.4.dist-info/entry_points.txt +2 -0
  57. moleditpy_linux-2.2.4.dist-info/licenses/LICENSE +674 -0
  58. moleditpy_linux-2.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,59 @@
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
+
15
+
16
+ class CustomQtInteractor(QtInteractor):
17
+ def __init__(self, parent=None, main_window=None, **kwargs):
18
+ super().__init__(parent, **kwargs)
19
+ self.main_window = main_window
20
+ # Track recent clicks so we can detect and swallow triple-clicks
21
+ # Triple-clicks are not a distinct Qt event on all platforms, so we
22
+ # implement a small timing-based counter here and accept the event
23
+ # when 3 rapid clicks are detected to prevent them from reaching
24
+ # the VTK interactor and causing unexpected behaviour in the 3D view.
25
+ self._last_click_time = 0.0
26
+ self._click_count = 0
27
+
28
+ def wheelEvent(self, event):
29
+ """
30
+ マウスホイールイベントをオーバーライドする。
31
+ """
32
+ # 最初に親クラスのイベントを呼び、通常のズーム処理を実行させる
33
+ super().wheelEvent(event)
34
+
35
+ # ズーム処理の完了後、2Dビューにフォーカスを強制的に戻す
36
+ if self.main_window and hasattr(self.main_window, 'view_2d'):
37
+ self.main_window.view_2d.setFocus()
38
+
39
+ def mouseReleaseEvent(self, event):
40
+ """
41
+ Qtのマウスリリースイベントをオーバーライドし、
42
+ 3Dビューでの全ての操作完了後に2Dビューへフォーカスを戻す。
43
+ """
44
+ super().mouseReleaseEvent(event) # 親クラスのイベントを先に処理
45
+ if self.main_window and hasattr(self.main_window, 'view_2d'):
46
+ self.main_window.view_2d.setFocus()
47
+
48
+ def mouseDoubleClickEvent(self, event):
49
+ """Ignore mouse double-clicks on the 3D widget to avoid accidental actions.
50
+
51
+ Swallow the double-click event so it doesn't trigger selection, editing,
52
+ or camera jumps. We intentionally do not call the superclass handler.
53
+ """
54
+ try:
55
+ # Accept the event to mark it handled and prevent further processing.
56
+ event.accept()
57
+ except Exception:
58
+ # If event doesn't support accept for some reason, just return.
59
+ return
@@ -0,0 +1,108 @@
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
+ try:
35
+ # VTKイベント座標を取得(元のロジックと同じ)
36
+ interactor = self.main_window.plotter.interactor
37
+ click_pos = interactor.GetEventPosition()
38
+ picker = self.main_window.plotter.picker
39
+ picker.Pick(click_pos[0], click_pos[1], 0, self.main_window.plotter.renderer)
40
+
41
+ if picker.GetActor() is self.main_window.atom_actor:
42
+ picked_position = np.array(picker.GetPickPosition())
43
+ distances = np.linalg.norm(self.main_window.atom_positions_3d - picked_position, axis=1)
44
+ closest_atom_idx = np.argmin(distances)
45
+
46
+ # 範囲チェックを追加
47
+ if 0 <= closest_atom_idx < self.mol.GetNumAtoms():
48
+ # クリック閾値チェック(元のロジックと同じ)
49
+ atom = self.mol.GetAtomWithIdx(int(closest_atom_idx))
50
+ if atom:
51
+ atomic_num = atom.GetAtomicNum()
52
+ vdw_radius = pt.GetRvdw(atomic_num)
53
+ click_threshold = vdw_radius * 1.5
54
+
55
+ if distances[closest_atom_idx] < click_threshold:
56
+ # We handled the pick (atom clicked) -> consume the event so
57
+ # other UI elements (including the VTK interactor observers)
58
+ # don't also process it. Set a flag on the main window so
59
+ # the VTK-based handlers can ignore the same logical click
60
+ # when it arrives via the VTK event pipeline.
61
+ try:
62
+ self.main_window._picking_consumed = True
63
+ except Exception:
64
+ pass
65
+ self.on_atom_picked(int(closest_atom_idx))
66
+ return True
67
+
68
+ # 原子以外をクリックした場合は選択をクリア(Measurementモードと同じロジック)
69
+ if hasattr(self, 'clear_selection'):
70
+ self.clear_selection()
71
+ # We did not actually pick an atom; do NOT consume the event here so
72
+ # the interactor and CustomInteractorStyle can handle camera rotation
73
+ # and other behaviors. Returning False (or calling the base
74
+ # implementation) allows normal processing to continue.
75
+ return False
76
+
77
+ except Exception as e:
78
+ print(f"Error in eventFilter: {e}")
79
+ # On exception, don't swallow the event either — let the normal
80
+ # event pipeline continue so the UI remains responsive.
81
+ return False
82
+
83
+ return super().eventFilter(obj, event)
84
+
85
+ def enable_picking(self):
86
+ """3Dビューでの原子選択を有効にする"""
87
+ self.main_window.plotter.interactor.installEventFilter(self)
88
+ self.picking_enabled = True
89
+ # Ensure the main window flag exists
90
+ try:
91
+ self.main_window._picking_consumed = False
92
+ except Exception:
93
+ pass
94
+
95
+ def disable_picking(self):
96
+ """3Dビューでの原子選択を無効にする"""
97
+ if hasattr(self, 'picking_enabled') and self.picking_enabled:
98
+ self.main_window.plotter.interactor.removeEventFilter(self)
99
+ self.picking_enabled = False
100
+ try:
101
+ # Clear any leftover flag when picking is disabled
102
+ if hasattr(self.main_window, '_picking_consumed'):
103
+ self.main_window._picking_consumed = False
104
+ except Exception:
105
+ pass
106
+
107
+ def try_alternative_picking(self, x, y):
108
+ """代替のピッキング方法(使用しない)"""
@@ -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
+ self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint) # 常に前面表示
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