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,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
|