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.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,440 @@
|
|
|
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
|
+
from PyQt6.QtWidgets import QMessageBox
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AngleDialog(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 # vertex atom
|
|
35
|
+
self.atom3_idx = None
|
|
36
|
+
|
|
37
|
+
# 事前選択された原子を設定
|
|
38
|
+
if preselected_atoms and len(preselected_atoms) >= 3:
|
|
39
|
+
self.atom1_idx = preselected_atoms[0]
|
|
40
|
+
self.atom2_idx = preselected_atoms[1] # vertex
|
|
41
|
+
self.atom3_idx = preselected_atoms[2]
|
|
42
|
+
|
|
43
|
+
self.init_ui()
|
|
44
|
+
|
|
45
|
+
def init_ui(self):
|
|
46
|
+
self.setWindowTitle("Adjust Angle")
|
|
47
|
+
self.setModal(False) # モードレスにしてクリックを阻害しない
|
|
48
|
+
# 常に前面表示
|
|
49
|
+
layout = QVBoxLayout(self)
|
|
50
|
+
|
|
51
|
+
# Instructions
|
|
52
|
+
instruction_label = QLabel("Click three atoms in order: first-vertex-third. The angle around the vertex atom will be adjusted.")
|
|
53
|
+
instruction_label.setWordWrap(True)
|
|
54
|
+
layout.addWidget(instruction_label)
|
|
55
|
+
|
|
56
|
+
# Selected atoms display
|
|
57
|
+
self.selection_label = QLabel("No atoms selected")
|
|
58
|
+
layout.addWidget(self.selection_label)
|
|
59
|
+
|
|
60
|
+
# Current angle display
|
|
61
|
+
self.angle_label = QLabel("")
|
|
62
|
+
layout.addWidget(self.angle_label)
|
|
63
|
+
|
|
64
|
+
# New angle input
|
|
65
|
+
angle_layout = QHBoxLayout()
|
|
66
|
+
angle_layout.addWidget(QLabel("New angle (degrees):"))
|
|
67
|
+
self.angle_input = QLineEdit()
|
|
68
|
+
self.angle_input.setPlaceholderText("109.5")
|
|
69
|
+
angle_layout.addWidget(self.angle_input)
|
|
70
|
+
layout.addLayout(angle_layout)
|
|
71
|
+
|
|
72
|
+
# Movement options
|
|
73
|
+
group_box = QWidget()
|
|
74
|
+
group_layout = QVBoxLayout(group_box)
|
|
75
|
+
group_layout.addWidget(QLabel("Rotation Options:"))
|
|
76
|
+
|
|
77
|
+
self.rotate_group_radio = QRadioButton("Atom 1,2: Fixed, Atom 3: Rotate connected group")
|
|
78
|
+
self.rotate_group_radio.setChecked(True)
|
|
79
|
+
group_layout.addWidget(self.rotate_group_radio)
|
|
80
|
+
|
|
81
|
+
self.rotate_atom_radio = QRadioButton("Atom 1,2: Fixed, Atom 3: Rotate atom only")
|
|
82
|
+
group_layout.addWidget(self.rotate_atom_radio)
|
|
83
|
+
|
|
84
|
+
self.both_groups_radio = QRadioButton("Vertex fixed: Both arms rotate equally")
|
|
85
|
+
group_layout.addWidget(self.both_groups_radio)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
layout.addWidget(group_box)
|
|
89
|
+
|
|
90
|
+
# Buttons
|
|
91
|
+
button_layout = QHBoxLayout()
|
|
92
|
+
self.clear_button = QPushButton("Clear Selection")
|
|
93
|
+
self.clear_button.clicked.connect(self.clear_selection)
|
|
94
|
+
button_layout.addWidget(self.clear_button)
|
|
95
|
+
|
|
96
|
+
button_layout.addStretch()
|
|
97
|
+
|
|
98
|
+
self.apply_button = QPushButton("Apply")
|
|
99
|
+
self.apply_button.clicked.connect(self.apply_changes)
|
|
100
|
+
self.apply_button.setEnabled(False)
|
|
101
|
+
button_layout.addWidget(self.apply_button)
|
|
102
|
+
|
|
103
|
+
close_button = QPushButton("Close")
|
|
104
|
+
close_button.clicked.connect(self.reject)
|
|
105
|
+
button_layout.addWidget(close_button)
|
|
106
|
+
|
|
107
|
+
layout.addLayout(button_layout)
|
|
108
|
+
|
|
109
|
+
# Connect to main window's picker for AngleDialog
|
|
110
|
+
self.picker_connection = None
|
|
111
|
+
self.enable_picking()
|
|
112
|
+
|
|
113
|
+
# 事前選択された原子がある場合は初期表示を更新
|
|
114
|
+
if self.atom1_idx is not None:
|
|
115
|
+
self.show_atom_labels()
|
|
116
|
+
self.update_display()
|
|
117
|
+
|
|
118
|
+
def on_atom_picked(self, atom_idx):
|
|
119
|
+
"""原子がピックされたときの処理"""
|
|
120
|
+
if self.atom1_idx is None:
|
|
121
|
+
self.atom1_idx = atom_idx
|
|
122
|
+
elif self.atom2_idx is None:
|
|
123
|
+
self.atom2_idx = atom_idx
|
|
124
|
+
elif self.atom3_idx is None:
|
|
125
|
+
self.atom3_idx = atom_idx
|
|
126
|
+
else:
|
|
127
|
+
# Reset and start over
|
|
128
|
+
self.atom1_idx = atom_idx
|
|
129
|
+
self.atom2_idx = None
|
|
130
|
+
self.atom3_idx = None
|
|
131
|
+
|
|
132
|
+
# 原子ラベルを表示
|
|
133
|
+
self.show_atom_labels()
|
|
134
|
+
self.update_display()
|
|
135
|
+
|
|
136
|
+
def keyPressEvent(self, event):
|
|
137
|
+
"""キーボードイベントを処理"""
|
|
138
|
+
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
|
139
|
+
if self.apply_button.isEnabled():
|
|
140
|
+
self.apply_changes()
|
|
141
|
+
event.accept()
|
|
142
|
+
else:
|
|
143
|
+
super().keyPressEvent(event)
|
|
144
|
+
|
|
145
|
+
def closeEvent(self, event):
|
|
146
|
+
"""ダイアログが閉じられる時の処理"""
|
|
147
|
+
self.clear_atom_labels()
|
|
148
|
+
self.disable_picking()
|
|
149
|
+
super().closeEvent(event)
|
|
150
|
+
|
|
151
|
+
def reject(self):
|
|
152
|
+
"""キャンセル時の処理"""
|
|
153
|
+
self.clear_atom_labels()
|
|
154
|
+
self.disable_picking()
|
|
155
|
+
super().reject()
|
|
156
|
+
|
|
157
|
+
def accept(self):
|
|
158
|
+
"""OK時の処理"""
|
|
159
|
+
self.clear_atom_labels()
|
|
160
|
+
self.disable_picking()
|
|
161
|
+
super().accept()
|
|
162
|
+
|
|
163
|
+
def clear_selection(self):
|
|
164
|
+
"""選択をクリア"""
|
|
165
|
+
self.atom1_idx = None
|
|
166
|
+
self.atom2_idx = None # vertex atom
|
|
167
|
+
self.atom3_idx = None
|
|
168
|
+
self.clear_selection_labels()
|
|
169
|
+
self.update_display()
|
|
170
|
+
|
|
171
|
+
def show_atom_labels(self):
|
|
172
|
+
"""選択された原子にラベルを表示"""
|
|
173
|
+
# 既存のラベルをクリア
|
|
174
|
+
self.clear_atom_labels()
|
|
175
|
+
|
|
176
|
+
# 新しいラベルを表示
|
|
177
|
+
if not hasattr(self, 'selection_labels'):
|
|
178
|
+
self.selection_labels = []
|
|
179
|
+
|
|
180
|
+
selected_atoms = [self.atom1_idx, self.atom2_idx, self.atom3_idx]
|
|
181
|
+
labels = ["1st", "2nd (vertex)", "3rd"]
|
|
182
|
+
colors = ["yellow", "yellow", "yellow"] # 全て黄色に統一
|
|
183
|
+
|
|
184
|
+
for i, atom_idx in enumerate(selected_atoms):
|
|
185
|
+
if atom_idx is not None:
|
|
186
|
+
pos = self.main_window.atom_positions_3d[atom_idx]
|
|
187
|
+
label_text = f"{labels[i]}"
|
|
188
|
+
|
|
189
|
+
# ラベルを追加
|
|
190
|
+
label_actor = self.main_window.plotter.add_point_labels(
|
|
191
|
+
[pos], [label_text],
|
|
192
|
+
point_size=20,
|
|
193
|
+
font_size=12,
|
|
194
|
+
text_color=colors[i],
|
|
195
|
+
always_visible=True
|
|
196
|
+
)
|
|
197
|
+
self.selection_labels.append(label_actor)
|
|
198
|
+
|
|
199
|
+
def clear_atom_labels(self):
|
|
200
|
+
"""原子ラベルをクリア"""
|
|
201
|
+
if hasattr(self, 'selection_labels'):
|
|
202
|
+
for label_actor in self.selection_labels:
|
|
203
|
+
try:
|
|
204
|
+
self.main_window.plotter.remove_actor(label_actor)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
self.selection_labels = []
|
|
208
|
+
|
|
209
|
+
def clear_selection_labels(self):
|
|
210
|
+
"""選択ラベルをクリア"""
|
|
211
|
+
if hasattr(self, 'selection_labels'):
|
|
212
|
+
for label_actor in self.selection_labels:
|
|
213
|
+
try:
|
|
214
|
+
self.main_window.plotter.remove_actor(label_actor)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
self.selection_labels = []
|
|
218
|
+
|
|
219
|
+
def add_selection_label(self, atom_idx, label_text):
|
|
220
|
+
"""選択された原子にラベルを追加"""
|
|
221
|
+
if not hasattr(self, 'selection_labels'):
|
|
222
|
+
self.selection_labels = []
|
|
223
|
+
|
|
224
|
+
# 原子の位置を取得
|
|
225
|
+
pos = self.main_window.atom_positions_3d[atom_idx]
|
|
226
|
+
|
|
227
|
+
# ラベルを追加
|
|
228
|
+
label_actor = self.main_window.plotter.add_point_labels(
|
|
229
|
+
[pos], [label_text],
|
|
230
|
+
point_size=20,
|
|
231
|
+
font_size=12,
|
|
232
|
+
text_color='yellow',
|
|
233
|
+
always_visible=True
|
|
234
|
+
)
|
|
235
|
+
self.selection_labels.append(label_actor)
|
|
236
|
+
|
|
237
|
+
def update_display(self):
|
|
238
|
+
"""表示を更新"""
|
|
239
|
+
# 既存のラベルをクリア
|
|
240
|
+
self.clear_selection_labels()
|
|
241
|
+
|
|
242
|
+
if self.atom1_idx is None:
|
|
243
|
+
self.selection_label.setText("No atoms selected")
|
|
244
|
+
self.angle_label.setText("")
|
|
245
|
+
self.apply_button.setEnabled(False)
|
|
246
|
+
# Clear angle input when no selection
|
|
247
|
+
try:
|
|
248
|
+
self.angle_input.clear()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
elif self.atom2_idx is None:
|
|
252
|
+
symbol1 = self.mol.GetAtomWithIdx(self.atom1_idx).GetSymbol()
|
|
253
|
+
self.selection_label.setText(f"First atom: {symbol1} (index {self.atom1_idx})")
|
|
254
|
+
self.angle_label.setText("")
|
|
255
|
+
self.apply_button.setEnabled(False)
|
|
256
|
+
# ラベル追加
|
|
257
|
+
self.add_selection_label(self.atom1_idx, "1")
|
|
258
|
+
# Clear angle input while selection is incomplete
|
|
259
|
+
try:
|
|
260
|
+
self.angle_input.clear()
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
elif self.atom3_idx is None:
|
|
264
|
+
symbol1 = self.mol.GetAtomWithIdx(self.atom1_idx).GetSymbol()
|
|
265
|
+
symbol2 = self.mol.GetAtomWithIdx(self.atom2_idx).GetSymbol()
|
|
266
|
+
self.selection_label.setText(f"Selected: {symbol1}({self.atom1_idx}) - {symbol2}({self.atom2_idx}) - ?")
|
|
267
|
+
self.angle_label.setText("")
|
|
268
|
+
self.apply_button.setEnabled(False)
|
|
269
|
+
# ラベル追加
|
|
270
|
+
self.add_selection_label(self.atom1_idx, "1")
|
|
271
|
+
self.add_selection_label(self.atom2_idx, "2(vertex)")
|
|
272
|
+
# Clear angle input while selection is incomplete
|
|
273
|
+
try:
|
|
274
|
+
self.angle_input.clear()
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
else:
|
|
278
|
+
symbol1 = self.mol.GetAtomWithIdx(self.atom1_idx).GetSymbol()
|
|
279
|
+
symbol2 = self.mol.GetAtomWithIdx(self.atom2_idx).GetSymbol()
|
|
280
|
+
symbol3 = self.mol.GetAtomWithIdx(self.atom3_idx).GetSymbol()
|
|
281
|
+
self.selection_label.setText(f"Angle: {symbol1}({self.atom1_idx}) - {symbol2}({self.atom2_idx}) - {symbol3}({self.atom3_idx})")
|
|
282
|
+
|
|
283
|
+
# Calculate current angle
|
|
284
|
+
current_angle = self.calculate_angle()
|
|
285
|
+
self.angle_label.setText(f"Current angle: {current_angle:.2f}°")
|
|
286
|
+
self.apply_button.setEnabled(True)
|
|
287
|
+
# Update angle input box with current angle
|
|
288
|
+
try:
|
|
289
|
+
self.angle_input.setText(f"{current_angle:.2f}")
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
# ラベル追加
|
|
293
|
+
self.add_selection_label(self.atom1_idx, "1")
|
|
294
|
+
self.add_selection_label(self.atom2_idx, "2(vertex)")
|
|
295
|
+
self.add_selection_label(self.atom3_idx, "3")
|
|
296
|
+
|
|
297
|
+
def calculate_angle(self):
|
|
298
|
+
"""現在の角度を計算"""
|
|
299
|
+
conf = self.mol.GetConformer()
|
|
300
|
+
pos1 = np.array(conf.GetAtomPosition(self.atom1_idx))
|
|
301
|
+
pos2 = np.array(conf.GetAtomPosition(self.atom2_idx)) # vertex
|
|
302
|
+
pos3 = np.array(conf.GetAtomPosition(self.atom3_idx))
|
|
303
|
+
|
|
304
|
+
vec1 = pos1 - pos2
|
|
305
|
+
vec2 = pos3 - pos2
|
|
306
|
+
|
|
307
|
+
cos_angle = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
|
|
308
|
+
cos_angle = np.clip(cos_angle, -1.0, 1.0)
|
|
309
|
+
angle_rad = np.arccos(cos_angle)
|
|
310
|
+
return np.degrees(angle_rad)
|
|
311
|
+
|
|
312
|
+
def apply_changes(self):
|
|
313
|
+
"""変更を適用"""
|
|
314
|
+
if self.atom1_idx is None or self.atom2_idx is None or self.atom3_idx is None:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
new_angle = float(self.angle_input.text())
|
|
319
|
+
if new_angle < 0 or new_angle >= 360:
|
|
320
|
+
QMessageBox.warning(self, "Invalid Input", "Angle must be between 0 and 360 degrees.")
|
|
321
|
+
return
|
|
322
|
+
except ValueError:
|
|
323
|
+
QMessageBox.warning(self, "Invalid Input", "Please enter a valid number.")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
# Undo状態を保存
|
|
327
|
+
self.main_window.push_undo_state()
|
|
328
|
+
|
|
329
|
+
# Apply the angle change
|
|
330
|
+
self.adjust_angle(new_angle)
|
|
331
|
+
|
|
332
|
+
# キラルラベルを更新
|
|
333
|
+
self.main_window.update_chiral_labels()
|
|
334
|
+
|
|
335
|
+
def adjust_angle(self, new_angle_deg):
|
|
336
|
+
"""角度を調整(均等回転オプション付き)"""
|
|
337
|
+
conf = self.mol.GetConformer()
|
|
338
|
+
pos1 = np.array(conf.GetAtomPosition(self.atom1_idx))
|
|
339
|
+
pos2 = np.array(conf.GetAtomPosition(self.atom2_idx)) # vertex
|
|
340
|
+
pos3 = np.array(conf.GetAtomPosition(self.atom3_idx))
|
|
341
|
+
|
|
342
|
+
vec1 = pos1 - pos2
|
|
343
|
+
vec2 = pos3 - pos2
|
|
344
|
+
|
|
345
|
+
# Current angle
|
|
346
|
+
current_angle_rad = np.arccos(np.clip(
|
|
347
|
+
np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2)), -1.0, 1.0))
|
|
348
|
+
|
|
349
|
+
# Target angle
|
|
350
|
+
target_angle_rad = np.radians(new_angle_deg)
|
|
351
|
+
|
|
352
|
+
# Rotation axis (perpendicular to the plane containing vec1 and vec2)
|
|
353
|
+
rotation_axis = np.cross(vec1, vec2)
|
|
354
|
+
rotation_axis_norm = np.linalg.norm(rotation_axis)
|
|
355
|
+
|
|
356
|
+
if rotation_axis_norm == 0:
|
|
357
|
+
# Vectors are parallel, cannot rotate
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
rotation_axis = rotation_axis / rotation_axis_norm
|
|
361
|
+
|
|
362
|
+
# Total rotation angle needed
|
|
363
|
+
total_rotation_angle = target_angle_rad - current_angle_rad
|
|
364
|
+
|
|
365
|
+
# Rodrigues' rotation formula
|
|
366
|
+
def rotate_vector(v, axis, angle):
|
|
367
|
+
cos_a = np.cos(angle)
|
|
368
|
+
sin_a = np.sin(angle)
|
|
369
|
+
return v * cos_a + np.cross(axis, v) * sin_a + axis * np.dot(axis, v) * (1 - cos_a)
|
|
370
|
+
|
|
371
|
+
if self.both_groups_radio.isChecked():
|
|
372
|
+
# Both arms rotate equally (half angle each in opposite directions)
|
|
373
|
+
half_rotation = total_rotation_angle / 2
|
|
374
|
+
|
|
375
|
+
# Get both connected groups
|
|
376
|
+
group1_atoms = self.get_connected_group(self.atom1_idx, exclude=self.atom2_idx)
|
|
377
|
+
group3_atoms = self.get_connected_group(self.atom3_idx, exclude=self.atom2_idx)
|
|
378
|
+
|
|
379
|
+
# Rotate group 1 by -half_rotation
|
|
380
|
+
for atom_idx in group1_atoms:
|
|
381
|
+
current_pos = np.array(conf.GetAtomPosition(atom_idx))
|
|
382
|
+
relative_pos = current_pos - pos2
|
|
383
|
+
rotated_pos = rotate_vector(relative_pos, rotation_axis, -half_rotation)
|
|
384
|
+
new_pos = pos2 + rotated_pos
|
|
385
|
+
conf.SetAtomPosition(atom_idx, new_pos.tolist())
|
|
386
|
+
self.main_window.atom_positions_3d[atom_idx] = new_pos
|
|
387
|
+
|
|
388
|
+
# Rotate group 3 by +half_rotation
|
|
389
|
+
for atom_idx in group3_atoms:
|
|
390
|
+
current_pos = np.array(conf.GetAtomPosition(atom_idx))
|
|
391
|
+
relative_pos = current_pos - pos2
|
|
392
|
+
rotated_pos = rotate_vector(relative_pos, rotation_axis, half_rotation)
|
|
393
|
+
new_pos = pos2 + rotated_pos
|
|
394
|
+
conf.SetAtomPosition(atom_idx, new_pos.tolist())
|
|
395
|
+
self.main_window.atom_positions_3d[atom_idx] = new_pos
|
|
396
|
+
|
|
397
|
+
elif self.rotate_atom_radio.isChecked():
|
|
398
|
+
# Move only the third atom
|
|
399
|
+
new_vec2 = rotate_vector(vec2, rotation_axis, total_rotation_angle)
|
|
400
|
+
new_pos3 = pos2 + new_vec2
|
|
401
|
+
conf.SetAtomPosition(self.atom3_idx, new_pos3.tolist())
|
|
402
|
+
self.main_window.atom_positions_3d[self.atom3_idx] = new_pos3
|
|
403
|
+
else:
|
|
404
|
+
# Rotate the connected group around atom2 (vertex) - default behavior
|
|
405
|
+
atoms_to_move = self.get_connected_group(self.atom3_idx, exclude=self.atom2_idx)
|
|
406
|
+
|
|
407
|
+
for atom_idx in atoms_to_move:
|
|
408
|
+
current_pos = np.array(conf.GetAtomPosition(atom_idx))
|
|
409
|
+
# Transform to coordinate system centered at atom2
|
|
410
|
+
relative_pos = current_pos - pos2
|
|
411
|
+
# Rotate around the rotation axis
|
|
412
|
+
rotated_pos = rotate_vector(relative_pos, rotation_axis, total_rotation_angle)
|
|
413
|
+
# Transform back to world coordinates
|
|
414
|
+
new_pos = pos2 + rotated_pos
|
|
415
|
+
conf.SetAtomPosition(atom_idx, new_pos.tolist())
|
|
416
|
+
self.main_window.atom_positions_3d[atom_idx] = new_pos
|
|
417
|
+
|
|
418
|
+
# Update the 3D view
|
|
419
|
+
self.main_window.draw_molecule_3d(self.mol)
|
|
420
|
+
|
|
421
|
+
def get_connected_group(self, start_atom, exclude=None):
|
|
422
|
+
"""指定された原子から連結されているグループを取得"""
|
|
423
|
+
visited = set()
|
|
424
|
+
to_visit = [start_atom]
|
|
425
|
+
|
|
426
|
+
while to_visit:
|
|
427
|
+
current = to_visit.pop()
|
|
428
|
+
if current in visited or current == exclude:
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
visited.add(current)
|
|
432
|
+
|
|
433
|
+
# Get neighboring atoms
|
|
434
|
+
atom = self.mol.GetAtomWithIdx(current)
|
|
435
|
+
for bond in atom.GetBonds():
|
|
436
|
+
other_idx = bond.GetOtherAtomIdx(current)
|
|
437
|
+
if other_idx not in visited and other_idx != exclude:
|
|
438
|
+
to_visit.append(other_idx)
|
|
439
|
+
|
|
440
|
+
return visited
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|