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.
Files changed (59) 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 +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. 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