MoleditPy-linux 1.15.1__py3-none-any.whl → 1.16.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. moleditpy_linux/__init__.py +4 -0
  2. moleditpy_linux/__main__.py +29 -19742
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +29 -0
  5. moleditpy_linux/modules/about_dialog.py +92 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +281 -0
  7. moleditpy_linux/modules/alignment_dialog.py +261 -0
  8. moleditpy_linux/modules/analysis_window.py +197 -0
  9. moleditpy_linux/modules/angle_dialog.py +428 -0
  10. moleditpy_linux/modules/assets/icon.icns +0 -0
  11. moleditpy_linux/modules/atom_item.py +336 -0
  12. moleditpy_linux/modules/bond_item.py +303 -0
  13. moleditpy_linux/modules/bond_length_dialog.py +368 -0
  14. moleditpy_linux/modules/calculation_worker.py +754 -0
  15. moleditpy_linux/modules/color_settings_dialog.py +309 -0
  16. moleditpy_linux/modules/constants.py +76 -0
  17. moleditpy_linux/modules/constrained_optimization_dialog.py +667 -0
  18. moleditpy_linux/modules/custom_interactor_style.py +737 -0
  19. moleditpy_linux/modules/custom_qt_interactor.py +49 -0
  20. moleditpy_linux/modules/dialog3_d_picking_mixin.py +96 -0
  21. moleditpy_linux/modules/dihedral_dialog.py +431 -0
  22. moleditpy_linux/modules/main_window.py +830 -0
  23. moleditpy_linux/modules/main_window_app_state.py +747 -0
  24. moleditpy_linux/modules/main_window_compute.py +1203 -0
  25. moleditpy_linux/modules/main_window_dialog_manager.py +454 -0
  26. moleditpy_linux/modules/main_window_edit_3d.py +531 -0
  27. moleditpy_linux/modules/main_window_edit_actions.py +1449 -0
  28. moleditpy_linux/modules/main_window_export.py +744 -0
  29. moleditpy_linux/modules/main_window_main_init.py +1665 -0
  30. moleditpy_linux/modules/main_window_molecular_parsers.py +956 -0
  31. moleditpy_linux/modules/main_window_project_io.py +429 -0
  32. moleditpy_linux/modules/main_window_string_importers.py +270 -0
  33. moleditpy_linux/modules/main_window_ui_manager.py +567 -0
  34. moleditpy_linux/modules/main_window_view_3d.py +1163 -0
  35. moleditpy_linux/modules/main_window_view_loaders.py +350 -0
  36. moleditpy_linux/modules/mirror_dialog.py +110 -0
  37. moleditpy_linux/modules/molecular_data.py +290 -0
  38. moleditpy_linux/modules/molecule_scene.py +1895 -0
  39. moleditpy_linux/modules/move_group_dialog.py +586 -0
  40. moleditpy_linux/modules/periodic_table_dialog.py +72 -0
  41. moleditpy_linux/modules/planarize_dialog.py +209 -0
  42. moleditpy_linux/modules/settings_dialog.py +1034 -0
  43. moleditpy_linux/modules/template_preview_item.py +148 -0
  44. moleditpy_linux/modules/template_preview_view.py +62 -0
  45. moleditpy_linux/modules/translation_dialog.py +353 -0
  46. moleditpy_linux/modules/user_template_dialog.py +621 -0
  47. moleditpy_linux/modules/zoomable_view.py +98 -0
  48. {moleditpy_linux-1.15.1.dist-info → moleditpy_linux-1.16.1.dist-info}/METADATA +1 -2
  49. moleditpy_linux-1.16.1.dist-info/RECORD +54 -0
  50. moleditpy_linux-1.15.1.dist-info/RECORD +0 -9
  51. /moleditpy_linux/{assets → modules/assets}/icon.ico +0 -0
  52. /moleditpy_linux/{assets → modules/assets}/icon.png +0 -0
  53. {moleditpy_linux-1.15.1.dist-info → moleditpy_linux-1.16.1.dist-info}/WHEEL +0 -0
  54. {moleditpy_linux-1.15.1.dist-info → moleditpy_linux-1.16.1.dist-info}/entry_points.txt +0 -0
  55. {moleditpy_linux-1.15.1.dist-info → moleditpy_linux-1.16.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,37 @@
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: Apache-2.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI 10.5281/zenodo.17268532
11
+ """
12
+
13
+
14
+ import sys
15
+ import ctypes
16
+
17
+ from PyQt6.QtWidgets import QApplication
18
+
19
+ try:
20
+ # When executed as part of the package (python -m moleditpy)
21
+ from .modules.main_window import MainWindow
22
+ except Exception:
23
+ # When executed as a standalone script (python main.py) the package-relative
24
+ # import won't work; fall back to absolute import that works with sys.path
25
+ from modules.main_window import MainWindow
26
+
27
+ def main():
28
+ # --- Windows タスクバーアイコンのための追加処理 ---
29
+ if sys.platform == 'win32':
30
+ myappid = 'hyoko.moleditpy.1.0' # アプリケーション固有のID(任意)
31
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
32
+
33
+ app = QApplication(sys.argv)
34
+ file_path = sys.argv[1] if len(sys.argv) > 1 else None
35
+ window = MainWindow(initial_file=file_path)
36
+ window.show()
37
+ sys.exit(app.exec())
@@ -0,0 +1,29 @@
1
+ # Open Babel is disabled for Linux version
2
+ OBABEL_AVAILABLE = False
3
+
4
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
5
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
6
+ # it once at module import time and expose a small, robust wrapper so callers
7
+ # can avoid re-importing sip repeatedly and so we centralize exception
8
+ # handling (this reduces crash risk during teardown and deletion operations).
9
+ try:
10
+ import sip as _sip # type: ignore
11
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
12
+ except Exception:
13
+ _sip = None
14
+ _sip_isdeleted = None
15
+
16
+ def sip_isdeleted_safe(obj) -> bool:
17
+ """Return True if sip reports the given wrapper object as deleted.
18
+
19
+ This function is conservative: if SIP isn't available or any error
20
+ occurs while checking, it returns False (i.e. not deleted) so that the
21
+ caller can continue other lightweight guards (like checking scene()).
22
+ """
23
+ try:
24
+ if _sip_isdeleted is None:
25
+ return False
26
+ return bool(_sip_isdeleted(obj))
27
+ except Exception:
28
+ return False
29
+
@@ -0,0 +1,92 @@
1
+ from PyQt6.QtWidgets import (
2
+ QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout
3
+ )
4
+ from PyQt6.QtGui import QPixmap, QPainter, QPen, QCursor
5
+ from PyQt6.QtCore import Qt
6
+ import os
7
+ try:
8
+ from .constants import VERSION
9
+ except Exception:
10
+ from modules.constants import VERSION
11
+
12
+ class AboutDialog(QDialog):
13
+ def __init__(self, main_window, parent=None):
14
+ super().__init__(parent)
15
+ self.main_window = main_window
16
+ self.setWindowTitle("About MoleditPy")
17
+ self.setFixedSize(250, 300)
18
+ self.init_ui()
19
+
20
+ def init_ui(self):
21
+ layout = QVBoxLayout(self)
22
+
23
+ # Create a clickable image label
24
+ self.image_label = QLabel()
25
+ self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
26
+
27
+ # Load the original icon image
28
+ icon_path = os.path.join(os.path.dirname(__file__), 'assets', 'icon.png')
29
+ if os.path.exists(icon_path):
30
+ original_pixmap = QPixmap(icon_path)
31
+ # Scale to 2x size (160x160)
32
+ pixmap = original_pixmap.scaled(160, 160, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
33
+ else:
34
+ # Fallback: create a simple placeholder if icon.png not found
35
+ pixmap = QPixmap(160, 160)
36
+ pixmap.fill(Qt.GlobalColor.lightGray)
37
+ painter = QPainter(pixmap)
38
+ painter.setPen(QPen(Qt.GlobalColor.black, 2))
39
+ painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "MoleditPy")
40
+ painter.end()
41
+
42
+ self.image_label.setPixmap(pixmap)
43
+ try:
44
+ self.image_label.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
45
+ except Exception:
46
+ pass
47
+
48
+ self.image_label.mousePressEvent = self.image_mouse_press_event
49
+
50
+ layout.addWidget(self.image_label)
51
+
52
+ # Add text information
53
+ info_text = f"MoleditPy for Linux Ver. {VERSION}\nAuthor: Hiromichi Yokoyama\nLicense: Apache-2.0"
54
+ info_label = QLabel(info_text)
55
+ info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
56
+ layout.addWidget(info_label)
57
+
58
+ # Add OK button
59
+ ok_button = QPushButton("OK")
60
+ ok_button.setFixedSize(80, 30) # 小さいサイズに固定
61
+ ok_button.clicked.connect(self.accept)
62
+
63
+ # Center the button
64
+ button_layout = QHBoxLayout()
65
+ button_layout.addStretch()
66
+ button_layout.addWidget(ok_button)
67
+ button_layout.addStretch()
68
+ layout.addLayout(button_layout)
69
+
70
+ def image_clicked(self, event):
71
+ """Easter egg: Clear all and load bipyrimidine from SMILES"""
72
+ # Clear the current scene
73
+ self.main_window.clear_all()
74
+
75
+ bipyrimidine_smiles = "C1=CN=C(N=C1)C2=NC=CC=N2"
76
+ self.main_window.load_from_smiles(bipyrimidine_smiles)
77
+
78
+ # Close the dialog
79
+ self.accept()
80
+
81
+ def image_mouse_press_event(self, event):
82
+ """Handle mouse press on the image: trigger easter egg only for right-click."""
83
+ try:
84
+ if event.button() == Qt.MouseButton.RightButton:
85
+ self.image_clicked(event)
86
+ else:
87
+ event.ignore()
88
+ except Exception:
89
+ try:
90
+ event.ignore()
91
+ except Exception:
92
+ pass
@@ -0,0 +1,281 @@
1
+ from PyQt6.QtWidgets import (
2
+ QDialog, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QMessageBox
3
+ )
4
+ from PyQt6.QtCore import Qt
5
+ import numpy as np
6
+
7
+ try:
8
+ from .dialog3_d_picking_mixin import Dialog3DPickingMixin
9
+ except Exception:
10
+ from modules.dialog3_d_picking_mixin import Dialog3DPickingMixin
11
+
12
+ class AlignPlaneDialog(Dialog3DPickingMixin, QDialog):
13
+ def __init__(self, mol, main_window, plane, preselected_atoms=None, parent=None):
14
+ QDialog.__init__(self, parent)
15
+ Dialog3DPickingMixin.__init__(self)
16
+ self.mol = mol
17
+ self.main_window = main_window
18
+ self.plane = plane
19
+ self.selected_atoms = set()
20
+
21
+ # 事前選択された原子を追加
22
+ if preselected_atoms:
23
+ self.selected_atoms.update(preselected_atoms)
24
+
25
+ self.init_ui()
26
+
27
+ # 事前選択された原子にラベルを追加
28
+ if self.selected_atoms:
29
+ self.show_atom_labels()
30
+ self.update_display()
31
+
32
+ def init_ui(self):
33
+ plane_names = {'xy': 'XY', 'xz': 'XZ', 'yz': 'YZ'}
34
+ self.setWindowTitle(f"Align to {plane_names[self.plane]} Plane")
35
+ self.setModal(False) # モードレスにしてクリックを阻害しない
36
+ self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint) # 常に前面表示
37
+ layout = QVBoxLayout(self)
38
+
39
+ # Instructions
40
+ instruction_label = QLabel(f"Click atoms in the 3D view to select them for align to the {plane_names[self.plane]} plane. At least 3 atoms are required.")
41
+ instruction_label.setWordWrap(True)
42
+ layout.addWidget(instruction_label)
43
+
44
+ # Selected atoms display
45
+ self.selection_label = QLabel("No atoms selected")
46
+ layout.addWidget(self.selection_label)
47
+
48
+ # Buttons
49
+ button_layout = QHBoxLayout()
50
+ self.clear_button = QPushButton("Clear Selection")
51
+ self.clear_button.clicked.connect(self.clear_selection)
52
+ button_layout.addWidget(self.clear_button)
53
+
54
+ # Select all atoms button
55
+ self.select_all_button = QPushButton("Select All Atoms")
56
+ self.select_all_button.setToolTip("Select all atoms in the molecule for alignment")
57
+ self.select_all_button.clicked.connect(self.select_all_atoms)
58
+ button_layout.addWidget(self.select_all_button)
59
+
60
+ button_layout.addStretch()
61
+
62
+ self.apply_button = QPushButton("Apply align")
63
+ self.apply_button.clicked.connect(self.apply_PlaneAlign)
64
+ self.apply_button.setEnabled(False)
65
+ button_layout.addWidget(self.apply_button)
66
+
67
+ close_button = QPushButton("Close")
68
+ close_button.clicked.connect(self.reject)
69
+ button_layout.addWidget(close_button)
70
+
71
+ layout.addLayout(button_layout)
72
+
73
+ # Connect to main window's picker
74
+ self.picker_connection = None
75
+ self.enable_picking()
76
+
77
+ def enable_picking(self):
78
+ """3Dビューでの原子選択を有効にする"""
79
+ self.main_window.plotter.interactor.installEventFilter(self)
80
+ self.picking_enabled = True
81
+
82
+ def disable_picking(self):
83
+ """3Dビューでの原子選択を無効にする"""
84
+ if hasattr(self, 'picking_enabled') and self.picking_enabled:
85
+ self.main_window.plotter.interactor.removeEventFilter(self)
86
+ self.picking_enabled = False
87
+
88
+ def on_atom_picked(self, atom_idx):
89
+ """原子がピックされたときの処理"""
90
+ if atom_idx in self.selected_atoms:
91
+ self.selected_atoms.remove(atom_idx)
92
+ else:
93
+ self.selected_atoms.add(atom_idx)
94
+
95
+ # 原子ラベルを表示
96
+ self.show_atom_labels()
97
+ self.update_display()
98
+
99
+ def keyPressEvent(self, event):
100
+ """キーボードイベントを処理"""
101
+ if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
102
+ if self.apply_button.isEnabled():
103
+ self.apply_PlaneAlign()
104
+ event.accept()
105
+ else:
106
+ super().keyPressEvent(event)
107
+
108
+ def clear_selection(self):
109
+ """選択をクリア"""
110
+ self.selected_atoms.clear()
111
+ self.clear_atom_labels()
112
+ self.update_display()
113
+
114
+ def select_all_atoms(self):
115
+ """Select all atoms in the current molecule and update labels/UI."""
116
+ try:
117
+ # Prefer RDKit molecule if available
118
+ if hasattr(self, 'mol') and self.mol is not None:
119
+ try:
120
+ n = self.mol.GetNumAtoms()
121
+ # create a set of indices [0..n-1]
122
+ self.selected_atoms = set(range(n))
123
+ except Exception:
124
+ # fallback to main_window data map
125
+ self.selected_atoms = set(self.main_window.data.atoms.keys()) if hasattr(self.main_window, 'data') else set()
126
+ else:
127
+ # fallback to main_window data map
128
+ self.selected_atoms = set(self.main_window.data.atoms.keys()) if hasattr(self.main_window, 'data') else set()
129
+
130
+ # Update labels and display
131
+ self.show_atom_labels()
132
+ self.update_display()
133
+
134
+ except Exception as e:
135
+ QMessageBox.warning(self, "Warning", f"Failed to select all atoms: {e}")
136
+
137
+ def update_display(self):
138
+ """表示を更新"""
139
+ count = len(self.selected_atoms)
140
+ if count == 0:
141
+ self.selection_label.setText("Click atoms to select for align (minimum 3 required)")
142
+ self.apply_button.setEnabled(False)
143
+ else:
144
+ atom_list = sorted(self.selected_atoms)
145
+ atom_display = []
146
+ for i, atom_idx in enumerate(atom_list):
147
+ symbol = self.mol.GetAtomWithIdx(atom_idx).GetSymbol()
148
+ atom_display.append(f"#{i+1}: {symbol}({atom_idx})")
149
+
150
+ self.selection_label.setText(f"Selected {count} atoms: {', '.join(atom_display)}")
151
+ self.apply_button.setEnabled(count >= 3)
152
+
153
+ def show_atom_labels(self):
154
+ """選択された原子にラベルを表示"""
155
+ # 既存のラベルをクリア
156
+ self.clear_atom_labels()
157
+
158
+ # 新しいラベルを表示
159
+ if not hasattr(self, 'selection_labels'):
160
+ self.selection_labels = []
161
+
162
+ if self.selected_atoms:
163
+ sorted_atoms = sorted(self.selected_atoms)
164
+
165
+ for i, atom_idx in enumerate(sorted_atoms):
166
+ pos = self.main_window.atom_positions_3d[atom_idx]
167
+ label_text = f"#{i+1}"
168
+
169
+ # ラベルを追加
170
+ label_actor = self.main_window.plotter.add_point_labels(
171
+ [pos], [label_text],
172
+ point_size=20,
173
+ font_size=12,
174
+ text_color='blue',
175
+ always_visible=True
176
+ )
177
+ self.selection_labels.append(label_actor)
178
+
179
+ def clear_atom_labels(self):
180
+ """原子ラベルをクリア"""
181
+ if hasattr(self, 'selection_labels'):
182
+ for label_actor in self.selection_labels:
183
+ try:
184
+ self.main_window.plotter.remove_actor(label_actor)
185
+ except:
186
+ pass
187
+ self.selection_labels = []
188
+
189
+ def apply_PlaneAlign(self):
190
+ """alignを適用(回転ベース)"""
191
+ if len(self.selected_atoms) < 3:
192
+ QMessageBox.warning(self, "Warning", "Please select at least 3 atoms for align.")
193
+ return
194
+ try:
195
+
196
+ # 選択された原子の位置を取得
197
+ selected_indices = list(self.selected_atoms)
198
+ selected_positions = self.main_window.atom_positions_3d[selected_indices].copy()
199
+
200
+ # 重心を計算
201
+ centroid = np.mean(selected_positions, axis=0)
202
+
203
+ # 重心を原点に移動
204
+ centered_positions = selected_positions - centroid
205
+
206
+ # 主成分分析で最適な平面を見つける
207
+ # 選択された原子の座標の共分散行列を計算
208
+ cov_matrix = np.cov(centered_positions.T)
209
+ eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
210
+
211
+ # 固有値が最も小さい固有ベクトルが平面の法線方向
212
+ normal_vector = eigenvectors[:, 0] # 最小固有値に対応する固有ベクトル
213
+
214
+ # 目標の平面の法線ベクトルを定義
215
+ if self.plane == 'xy':
216
+ target_normal = np.array([0, 0, 1]) # Z軸方向
217
+ elif self.plane == 'xz':
218
+ target_normal = np.array([0, 1, 0]) # Y軸方向
219
+ elif self.plane == 'yz':
220
+ target_normal = np.array([1, 0, 0]) # X軸方向
221
+
222
+ # 法線ベクトルの向きを調整(内積が正になるように)
223
+ if np.dot(normal_vector, target_normal) < 0:
224
+ normal_vector = -normal_vector
225
+
226
+ # 回転軸と回転角度を計算
227
+ rotation_axis = np.cross(normal_vector, target_normal)
228
+ rotation_axis_norm = np.linalg.norm(rotation_axis)
229
+
230
+ if rotation_axis_norm > 1e-10: # 回転が必要な場合
231
+ rotation_axis = rotation_axis / rotation_axis_norm
232
+ cos_angle = np.dot(normal_vector, target_normal)
233
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
234
+ rotation_angle = np.arccos(cos_angle)
235
+
236
+ # Rodrigues回転公式を使用して全分子を回転
237
+ def rodrigues_rotation(v, axis, angle):
238
+ cos_a = np.cos(angle)
239
+ sin_a = np.sin(angle)
240
+ return v * cos_a + np.cross(axis, v) * sin_a + axis * np.dot(axis, v) * (1 - cos_a)
241
+
242
+ # 分子全体を回転させる
243
+ conf = self.mol.GetConformer()
244
+ for i in range(self.mol.GetNumAtoms()):
245
+ current_pos = np.array(conf.GetAtomPosition(i))
246
+ # 重心基準で回転
247
+ centered_pos = current_pos - centroid
248
+ rotated_pos = rodrigues_rotation(centered_pos, rotation_axis, rotation_angle)
249
+ new_pos = rotated_pos + centroid
250
+ conf.SetAtomPosition(i, new_pos.tolist())
251
+ self.main_window.atom_positions_3d[i] = new_pos
252
+
253
+ # 3D表示を更新
254
+ self.main_window.draw_molecule_3d(self.mol)
255
+
256
+ # キラルラベルを更新
257
+ self.main_window.update_chiral_labels()
258
+
259
+ # Undo状態を保存
260
+ self.main_window.push_undo_state()
261
+
262
+ except Exception as e:
263
+ QMessageBox.critical(self, "Error", f"Failed to apply align: {str(e)}")
264
+
265
+ def closeEvent(self, event):
266
+ """ダイアログが閉じられる時の処理"""
267
+ self.clear_atom_labels()
268
+ self.disable_picking()
269
+ super().closeEvent(event)
270
+
271
+ def reject(self):
272
+ """キャンセル時の処理"""
273
+ self.clear_atom_labels()
274
+ self.disable_picking()
275
+ super().reject()
276
+
277
+ def accept(self):
278
+ """OK時の処理"""
279
+ self.clear_atom_labels()
280
+ self.disable_picking()
281
+ super().accept()