MoleditPy-linux 3.6.1__tar.gz → 3.6.3__tar.gz

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 (79) hide show
  1. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/PKG-INFO +1 -1
  2. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/pyproject.toml +1 -1
  3. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/align_plane_dialog.py +100 -40
  5. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/alignment_dialog.py +63 -24
  6. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/base_picking_dialog.py +20 -0
  7. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/dialog_logic.py +18 -5
  8. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/move_group_dialog.py +13 -1
  9. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +13 -1
  10. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/LICENSE +0 -0
  11. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/README.md +0 -0
  12. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/setup.cfg +0 -0
  13. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  14. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  15. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  16. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  17. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  18. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/__init__.py +0 -0
  19. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/__main__.py +0 -0
  20. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  21. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/assets/icon.icns +0 -0
  22. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/assets/icon.ico +0 -0
  23. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/assets/icon.png +0 -0
  24. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/core/__init__.py +0 -0
  25. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  26. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/core/molecular_data.py +0 -0
  27. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/main.py +0 -0
  28. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/plugins/__init__.py +0 -0
  29. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  30. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  31. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  32. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/__init__.py +0 -0
  33. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  34. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  35. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  36. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/app_state.py +0 -0
  37. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/atom_item.py +0 -0
  38. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  39. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/bond_item.py +0 -0
  40. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  41. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  42. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  43. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  44. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  45. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  46. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  47. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  48. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  49. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  50. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  51. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/export_logic.py +0 -0
  52. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  53. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/io_logic.py +0 -0
  54. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/main_window.py +0 -0
  55. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  56. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  57. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  58. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  59. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  60. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  61. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  62. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  63. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  64. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  65. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  66. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  67. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/string_importers.py +0 -0
  68. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  69. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  70. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  71. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  72. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  73. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  74. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  75. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/utils/__init__.py +0 -0
  76. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/utils/constants.py +0 -0
  77. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/utils/default_settings.py +0 -0
  78. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  79. {moleditpy_linux-3.6.1 → moleditpy_linux-3.6.3}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 3.6.1
3
+ Version: 3.6.3
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "3.6.1"
8
+ version = "3.6.3"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 3.6.1
3
+ Version: 3.6.3
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -10,10 +10,13 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
- import numpy as np
13
+ import logging
14
14
  from typing import TYPE_CHECKING, Literal, Optional, Sequence
15
15
 
16
+ import numpy as np
17
+
16
18
  from PyQt6.QtWidgets import (
19
+ QCheckBox,
17
20
  QHBoxLayout,
18
21
  QLabel,
19
22
  QMessageBox,
@@ -24,15 +27,17 @@ from PyQt6.QtWidgets import (
24
27
  from rdkit import Chem
25
28
 
26
29
  try:
27
- from .base_picking_dialog import BasePickingDialog
30
+ from .base_picking_dialog import BasePickingDialog, SelectionList
28
31
  except ImportError:
29
- from moleditpy_linux.ui.base_picking_dialog import BasePickingDialog
32
+ from moleditpy_linux.ui.base_picking_dialog import BasePickingDialog, SelectionList
30
33
 
31
34
  if TYPE_CHECKING:
32
35
  from .main_window import MainWindow
33
36
 
34
37
 
35
38
  class AlignPlaneDialog(BasePickingDialog):
39
+ """Dialog for aligning selected atoms to a principal plane (XY, XZ, or YZ)."""
40
+
36
41
  def __init__(
37
42
  self,
38
43
  mol: Chem.Mol,
@@ -43,20 +48,31 @@ class AlignPlaneDialog(BasePickingDialog):
43
48
  ) -> None:
44
49
  super().__init__(mol, main_window, parent)
45
50
  self.plane = plane
46
- self.selected_atoms: set[int] = set()
51
+ self._selected_atoms = SelectionList()
47
52
 
48
53
  # Add preselected atoms
49
54
  if preselected_atoms:
50
- self.selected_atoms.update(preselected_atoms)
55
+ self._selected_atoms.update(preselected_atoms)
51
56
 
52
57
  self.init_ui()
53
58
 
54
59
  # Add labels to preselected atoms
55
- if self.selected_atoms:
60
+ if self._selected_atoms:
56
61
  self.show_atom_labels()
57
62
  self.update_display()
58
63
 
64
+ @property
65
+ def selected_atoms(self) -> SelectionList:
66
+ """Return the ordered list of selected atom indices."""
67
+ return self._selected_atoms
68
+
69
+ @selected_atoms.setter
70
+ def selected_atoms(self, val: object) -> None:
71
+ """Replace the selection with a new SelectionList built from val."""
72
+ self._selected_atoms = SelectionList(val) # type: ignore[arg-type]
73
+
59
74
  def init_ui(self) -> None:
75
+ """Build and lay out all widgets for the plane-alignment dialog."""
60
76
  plane_names = {"xy": "XY", "xz": "XZ", "yz": "YZ"}
61
77
  self.setWindowTitle(f"Align to {plane_names[self.plane]} Plane")
62
78
  self.setModal(False)
@@ -64,11 +80,18 @@ class AlignPlaneDialog(BasePickingDialog):
64
80
 
65
81
  # Instructions
66
82
  instruction_label = QLabel(
67
- 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."
83
+ f"Click atoms in the 3D view to select them for align to "
84
+ f"the {plane_names[self.plane]} plane. At least 3 atoms "
85
+ f"are required."
68
86
  )
69
87
  instruction_label.setWordWrap(True)
70
88
  layout.addWidget(instruction_label)
71
89
 
90
+ # Move to zero plane option (default False)
91
+ self.move_to_zero_plane_checkbox = QCheckBox("Move the plane to the zero plane")
92
+ self.move_to_zero_plane_checkbox.setChecked(False)
93
+ layout.addWidget(self.move_to_zero_plane_checkbox)
94
+
72
95
  # Selected atoms display
73
96
  self.selection_label = QLabel("No atoms selected")
74
97
  layout.addWidget(self.selection_label)
@@ -106,11 +129,12 @@ class AlignPlaneDialog(BasePickingDialog):
106
129
 
107
130
  def on_atom_picked(self, atom_idx: int) -> None:
108
131
  """Handle the event when an atom is picked in the 3D view."""
109
- if atom_idx in self.selected_atoms:
110
- self.selected_atoms.remove(atom_idx)
132
+ if atom_idx in self._selected_atoms:
133
+ self._selected_atoms.remove(atom_idx)
111
134
  else:
112
- self.selected_atoms.add(atom_idx)
135
+ self._selected_atoms.append(atom_idx)
113
136
 
137
+ self.show_atom_labels()
114
138
  self.update_display()
115
139
 
116
140
  def clear_selection(self) -> None:
@@ -139,6 +163,7 @@ class AlignPlaneDialog(BasePickingDialog):
139
163
  self.update_display()
140
164
 
141
165
  except (AttributeError, RuntimeError, TypeError, KeyError) as e:
166
+ logging.exception("Failed to select all atoms")
142
167
  QMessageBox.warning(self, "Warning", f"Failed to select all atoms: {e}")
143
168
 
144
169
  def update_display(self) -> None:
@@ -150,8 +175,15 @@ class AlignPlaneDialog(BasePickingDialog):
150
175
  )
151
176
  self.apply_button.setEnabled(False)
152
177
  else:
153
- # Just show the count of selected atoms (to prevent dialog resizing)
154
- self.selection_label.setText(f"Selected {count} atoms")
178
+ atom_list = sorted(self.selected_atoms)
179
+ atom_display = []
180
+ for i, atom_idx in enumerate(atom_list):
181
+ symbol = self.mol.GetAtomWithIdx(atom_idx).GetSymbol()
182
+ atom_display.append(f"#{i + 1}: {symbol}({atom_idx})")
183
+
184
+ self.selection_label.setText(
185
+ f"Selected {count} atoms: {', '.join(atom_display)}"
186
+ )
155
187
  self.apply_button.setEnabled(count >= 3)
156
188
 
157
189
  def show_atom_labels(self) -> None:
@@ -159,7 +191,7 @@ class AlignPlaneDialog(BasePickingDialog):
159
191
  if self.selected_atoms:
160
192
  sorted_atoms = sorted(self.selected_atoms)
161
193
  pairs = [(idx, f"#{i + 1}") for i, idx in enumerate(sorted_atoms)]
162
- self.show_atom_labels_for(pairs, color="blue")
194
+ self.show_atom_labels_for(pairs, color="yellow")
163
195
  else:
164
196
  self.clear_atom_labels()
165
197
 
@@ -167,7 +199,9 @@ class AlignPlaneDialog(BasePickingDialog):
167
199
  """Apply plane alignment (rotation-based)."""
168
200
  if len(self.selected_atoms) < 3:
169
201
  QMessageBox.warning(
170
- self, "Warning", "Please select at least 3 atoms for align."
202
+ self,
203
+ "Warning",
204
+ "Please select at least 3 atoms for align.",
171
205
  )
172
206
  return
173
207
  try:
@@ -197,7 +231,8 @@ class AlignPlaneDialog(BasePickingDialog):
197
231
  elif self.plane == "yz":
198
232
  target_normal = np.array([1, 0, 0]) # X-axis direction
199
233
  else:
200
- target_normal = np.array([0, 0, 1]) # Default to Z-axis (XY plane)
234
+ # Default to Z-axis (XY plane)
235
+ target_normal = np.array([0, 0, 1])
201
236
 
202
237
  # Adjust normal vector direction
203
238
  if np.dot(normal_vector, target_normal) < 0:
@@ -207,36 +242,61 @@ class AlignPlaneDialog(BasePickingDialog):
207
242
  rotation_axis = np.cross(normal_vector, target_normal)
208
243
  rotation_axis_norm = np.linalg.norm(rotation_axis)
209
244
 
210
- if rotation_axis_norm > 1e-10:
211
- rotation_axis = rotation_axis / rotation_axis_norm
212
- cos_angle = np.dot(normal_vector, target_normal)
213
- cos_angle = np.clip(cos_angle, -1.0, 1.0)
214
- rotation_angle = np.arccos(cos_angle)
215
-
216
- # Rodrigues' rotation formula
217
- def rodrigues_rotation(
218
- v: np.ndarray, axis: np.ndarray, angle: float
219
- ) -> np.ndarray:
220
- cos_a = np.cos(angle)
221
- sin_a = np.sin(angle)
222
- return ( # type: ignore[no-any-return]
223
- v * cos_a
224
- + np.cross(axis, v) * sin_a
225
- + axis * np.dot(axis, v) * (1 - cos_a)
226
- )
245
+ # Rodrigues' rotation formula
246
+ def rodrigues_rotation(
247
+ v: np.ndarray, axis: np.ndarray, angle: float
248
+ ) -> np.ndarray:
249
+ cos_a = np.cos(angle)
250
+ sin_a = np.sin(angle)
251
+ return ( # type: ignore[no-any-return]
252
+ v * cos_a
253
+ + np.cross(axis, v) * sin_a
254
+ + axis * np.dot(axis, v) * (1 - cos_a)
255
+ )
227
256
 
228
- # Rotate all atoms
229
- conf = self.mol.GetConformer()
230
- for i in range(self.mol.GetNumAtoms()):
231
- current_pos = np.array(conf.GetAtomPosition(i))
232
- centered_pos = current_pos - centroid
257
+ # Calculate new positions (rotated, centered back by default)
258
+ conf = self.mol.GetConformer()
259
+ new_positions = np.empty_like(positions)
260
+ for i in range(self.mol.GetNumAtoms()):
261
+ current_pos = np.array(conf.GetAtomPosition(i))
262
+ centered_pos = current_pos - centroid
263
+ if rotation_axis_norm > 1e-10:
264
+ rot_norm = rotation_axis_norm
265
+ rotation_axis_normalized = rotation_axis / rot_norm
266
+ cos_angle = np.dot(normal_vector, target_normal)
267
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
268
+ rotation_angle = np.arccos(cos_angle)
233
269
  rotated_pos = rodrigues_rotation(
234
- centered_pos, rotation_axis, rotation_angle
270
+ centered_pos,
271
+ rotation_axis_normalized,
272
+ rotation_angle,
235
273
  )
236
- new_pos = rotated_pos + centroid
237
- positions[i] = new_pos
274
+ else:
275
+ rotated_pos = centered_pos
276
+ new_pos = rotated_pos + centroid
277
+ new_positions[i] = new_pos
278
+
279
+ # If move_to_zero_plane is True, translate so the plane
280
+ # of selected atoms is at zero
281
+ if self.move_to_zero_plane_checkbox.isChecked():
282
+ selected_new_positions = new_positions[selected_indices]
283
+ new_centroid = np.mean(selected_new_positions, axis=0)
284
+ translation_offset = np.zeros(3)
285
+ if self.plane == "xy":
286
+ translation_offset[2] = new_centroid[2]
287
+ elif self.plane == "xz":
288
+ translation_offset[1] = new_centroid[1]
289
+ elif self.plane == "yz":
290
+ translation_offset[0] = new_centroid[0]
291
+ new_positions = new_positions - translation_offset
292
+
293
+ # Update the conformer positions array in place
294
+ for i in range(self.mol.GetNumAtoms()):
295
+ positions[i] = new_positions[i]
238
296
 
239
297
  self._update_molecule_geometry(positions)
298
+ self.show_atom_labels()
240
299
 
241
300
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
301
+ logging.exception("Failed to apply align")
242
302
  QMessageBox.critical(self, "Error", f"Failed to apply align: {str(e)}")
@@ -10,11 +10,14 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
- import numpy as np
13
+ import logging
14
14
  from typing import TYPE_CHECKING, Literal, Optional, Sequence
15
15
 
16
+ import numpy as np
17
+
16
18
  from PyQt6.QtGui import QCloseEvent
17
19
  from PyQt6.QtWidgets import (
20
+ QCheckBox,
18
21
  QDialog,
19
22
  QHBoxLayout,
20
23
  QLabel,
@@ -30,11 +33,18 @@ try:
30
33
  except ImportError:
31
34
  from moleditpy_linux.ui.dialog_3d_picking_mixin import Dialog3DPickingMixin
32
35
 
36
+ try:
37
+ from .base_picking_dialog import SelectionList
38
+ except ImportError:
39
+ from moleditpy_linux.ui.base_picking_dialog import SelectionList
40
+
33
41
  if TYPE_CHECKING:
34
42
  from .main_window import MainWindow
35
43
 
36
44
 
37
45
  class AlignmentDialog(Dialog3DPickingMixin, QDialog):
46
+ """Dialog for aligning two selected atoms along a principal axis (X, Y, or Z)."""
47
+
38
48
  def __init__(
39
49
  self,
40
50
  mol: Chem.Mol,
@@ -48,21 +58,32 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
48
58
  self.mol = mol
49
59
  self.main_window = main_window
50
60
  self.axis = axis
51
- self.selected_atoms: set[int] = set()
61
+ self._selected_atoms = SelectionList()
52
62
 
53
63
  # Add preselected atoms (maximum 2)
54
64
  if preselected_atoms:
55
- self.selected_atoms.update(preselected_atoms[:2])
65
+ self._selected_atoms.update(preselected_atoms[:2])
56
66
 
57
67
  self.init_ui()
58
68
 
59
69
  # Add labels to preselected atoms
60
- if self.selected_atoms:
61
- for i, atom_idx in enumerate(sorted(self.selected_atoms), 1):
62
- self.add_selection_label(atom_idx, f"Atom {i}")
70
+ if self._selected_atoms:
71
+ for i, atom_idx in enumerate(self._selected_atoms, 1):
72
+ self.add_selection_label(atom_idx, f"#{i}", color="yellow")
63
73
  self.update_display()
64
74
 
75
+ @property
76
+ def selected_atoms(self) -> SelectionList:
77
+ """Return the ordered list of selected atom indices."""
78
+ return self._selected_atoms
79
+
80
+ @selected_atoms.setter
81
+ def selected_atoms(self, val: object) -> None:
82
+ """Replace the selection with a new SelectionList built from val."""
83
+ self._selected_atoms = SelectionList(val) # type: ignore[arg-type]
84
+
65
85
  def init_ui(self) -> None:
86
+ """Build and lay out all widgets for the alignment dialog."""
66
87
  axis_names = {"x": "X-axis", "y": "Y-axis", "z": "Z-axis"}
67
88
  self.setWindowTitle(f"Align to {axis_names[self.axis]}")
68
89
  self.setModal(False)
@@ -70,11 +91,17 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
70
91
 
71
92
  # Instructions
72
93
  instruction_label = QLabel(
73
- f"Click atoms in the 3D view to select them for alignment to the {axis_names[self.axis]}. Exactly 2 atoms are required. The first atom will be moved to the origin, and the second atom will be positioned on the {axis_names[self.axis]}."
94
+ f"Click atoms in the 3D view to select them for alignment to the "
95
+ f"{axis_names[self.axis]}. Exactly 2 atoms are required."
74
96
  )
75
97
  instruction_label.setWordWrap(True)
76
98
  layout.addWidget(instruction_label)
77
99
 
100
+ # Move to origin option (default False)
101
+ self.move_to_origin_checkbox = QCheckBox("Move the first atom to the origin")
102
+ self.move_to_origin_checkbox.setChecked(False)
103
+ layout.addWidget(self.move_to_origin_checkbox)
104
+
78
105
  # Selected atoms display
79
106
  self.selection_label = QLabel("No atoms selected")
80
107
  layout.addWidget(self.selection_label)
@@ -114,10 +141,10 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
114
141
  else:
115
142
  # Maximum of 2 atoms can be selected
116
143
  if len(self.selected_atoms) < 2:
117
- self.selected_atoms.add(atom_idx)
144
+ self.selected_atoms.append(atom_idx)
118
145
  # Show label indicating selection order
119
- label_text = f"Atom {len(self.selected_atoms)}"
120
- self.add_selection_label(atom_idx, label_text)
146
+ label_text = f"#{len(self.selected_atoms)}"
147
+ self.add_selection_label(atom_idx, label_text, color="yellow")
121
148
 
122
149
  self.update_display()
123
150
 
@@ -129,14 +156,12 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
129
156
  )
130
157
  self.apply_button.setEnabled(False)
131
158
  elif len(self.selected_atoms) == 1:
132
- selected_list = list(self.selected_atoms)
133
- atom = self.mol.GetAtomWithIdx(selected_list[0])
159
+ atom = self.mol.GetAtomWithIdx(self.selected_atoms[0])
134
160
  self.selection_label.setText(f"Selected 1 atom: {atom.GetSymbol()}")
135
161
  self.apply_button.setEnabled(False)
136
162
  elif len(self.selected_atoms) == 2:
137
- selected_list = sorted(list(self.selected_atoms))
138
- atom1 = self.mol.GetAtomWithIdx(selected_list[0])
139
- atom2 = self.mol.GetAtomWithIdx(selected_list[1])
163
+ atom1 = self.mol.GetAtomWithIdx(self.selected_atoms[0])
164
+ atom2 = self.mol.GetAtomWithIdx(self.selected_atoms[1])
140
165
  self.selection_label.setText(
141
166
  f"Selected 2 atoms: {atom1.GetSymbol()}, {atom2.GetSymbol()}"
142
167
  )
@@ -148,13 +173,11 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
148
173
  self.selected_atoms.clear()
149
174
  self.update_display()
150
175
 
151
- def remove_atom_label(self, atom_idx: int) -> None:
152
- """Remove a label for a specific atom."""
153
- # Re-draw all labels for simplicity
176
+ def remove_atom_label(self, _atom_idx: int) -> None:
177
+ """Remove a label for a specific atom (redraws all labels)."""
154
178
  self.clear_selection_labels()
155
- for i, idx in enumerate(sorted(self.selected_atoms), 1):
156
- if idx != atom_idx:
157
- self.add_selection_label(idx, f"Atom {i}")
179
+ for i, idx in enumerate(self.selected_atoms, 1):
180
+ self.add_selection_label(idx, f"#{i}", color="yellow")
158
181
 
159
182
  def apply_alignment(self) -> None:
160
183
  """Apply the specific axial alignment to the molecule."""
@@ -164,8 +187,8 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
164
187
  )
165
188
  return
166
189
  try:
167
- selected_list = sorted(list(self.selected_atoms))
168
- atom1_idx, atom2_idx = selected_list[0], selected_list[1]
190
+ atom1_idx = self.selected_atoms[0]
191
+ atom2_idx = self.selected_atoms[1]
169
192
 
170
193
  conf = self.mol.GetConformer()
171
194
 
@@ -235,6 +258,14 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
235
258
  ),
236
259
  )
237
260
 
261
+ # If move_to_origin is False, translate back so atom1 is
262
+ # at its original position
263
+ if not self.move_to_origin_checkbox.isChecked():
264
+ for i in range(self.mol.GetNumAtoms()):
265
+ current_pos = np.array(conf.GetAtomPosition(i))
266
+ restored_pos = current_pos - translation
267
+ conf.SetAtomPosition(i, restored_pos.tolist())
268
+
238
269
  # Update 3D positions
239
270
  self.main_window.view_3d_manager.atom_positions_3d = np.array(
240
271
  [list(conf.GetAtomPosition(i)) for i in range(self.mol.GetNumAtoms())]
@@ -243,6 +274,11 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
243
274
  # Update 3D visualization
244
275
  self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
245
276
 
277
+ # Restore selection labels
278
+ self.clear_selection_labels()
279
+ for i, idx in enumerate(self.selected_atoms, 1):
280
+ self.add_selection_label(idx, f"#{i}", color="yellow")
281
+
246
282
  # Update chirality labels
247
283
  self.main_window.view_3d_manager.update_chiral_labels()
248
284
 
@@ -250,10 +286,13 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
250
286
  self.main_window.edit_actions_manager.push_undo_state()
251
287
 
252
288
  QMessageBox.information(
253
- self, "Success", f"Alignment to {self.axis.upper()}-axis completed."
289
+ self,
290
+ "Success",
291
+ f"Alignment to {self.axis.upper()}-axis completed.",
254
292
  )
255
293
 
256
294
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
295
+ logging.exception("Failed to apply alignment")
257
296
  QMessageBox.critical(self, "Error", f"Failed to apply alignment: {str(e)}")
258
297
 
259
298
  def closeEvent(self, event: Optional[QCloseEvent]) -> None:
@@ -28,6 +28,26 @@ if TYPE_CHECKING:
28
28
  from .main_window import MainWindow
29
29
 
30
30
 
31
+ class SelectionList(list):
32
+ """Order-preserving list that compares equal to sets/lists/tuples of same elements."""
33
+
34
+ def __eq__(self, other: object) -> bool:
35
+ """Compare by membership, ignoring order."""
36
+ if isinstance(other, (set, list, tuple)):
37
+ return set(self) == set(other)
38
+ return super().__eq__(other)
39
+
40
+ def add(self, item: int) -> None:
41
+ """Append item only if not already present."""
42
+ if item not in self:
43
+ self.append(item)
44
+
45
+ def update(self, items: object) -> None:
46
+ """Append each item that is not already present."""
47
+ for item in items: # type: ignore[union-attr]
48
+ self.add(item)
49
+
50
+
31
51
  class BasePickingDialog(Dialog3DPickingMixin, QDialog):
32
52
  """
33
53
  Base class for any dialog requiring 3D atom picking.
@@ -16,6 +16,7 @@ import logging
16
16
 
17
17
  import json
18
18
  import os
19
+ import sys
19
20
  from typing import Any, List, Literal, Optional, cast
20
21
 
21
22
  from PyQt6.QtWidgets import QInputDialog, QMessageBox, QDialog
@@ -23,7 +24,7 @@ from PyQt6.QtCore import Qt
23
24
 
24
25
 
25
26
  try:
26
- # package relative imports (preferred when running as `python -m moleditpy`)
27
+ # package relative imports (preferred when running as python -m moleditpy)
27
28
  from .about_dialog import AboutDialog
28
29
  from .align_plane_dialog import AlignPlaneDialog
29
30
  from .alignment_dialog import AlignmentDialog
@@ -71,13 +72,19 @@ except ImportError:
71
72
 
72
73
 
73
74
  class DialogManager:
74
- """Independent manager for UI dialogs, ported from MainWindowDialogManager mixin."""
75
+ """Independent manager for UI dialogs.
76
+
77
+ Ported from MainWindowDialogManager mixin.
78
+ """
75
79
 
76
80
  def __init__(self, host: Any) -> None:
77
81
  self.host = host
78
82
 
79
83
  def _get_preselected_atoms_3d(self) -> List[int]:
80
- """Helper to collect preselected atoms from measurement mode (3D Select)."""
84
+ """Helper to collect preselected atoms from measurement mode.
85
+
86
+ Specifically for 3D Select.
87
+ """
81
88
  preselected_atoms = []
82
89
  if hasattr(self.host, "edit_3d_manager"):
83
90
  if self.host.edit_3d_manager.selected_atoms_for_measurement:
@@ -96,6 +103,7 @@ class DialogManager:
96
103
  dialog.exec()
97
104
 
98
105
  def open_periodic_table_dialog(self) -> None:
106
+ """Open the periodic table dialog and wire up element-selection callback."""
99
107
  dialog = PeriodicTableDialog(self.host)
100
108
  dialog.element_selected.connect(
101
109
  self.host.ui_manager.set_atom_from_periodic_table
@@ -108,6 +116,7 @@ class DialogManager:
108
116
  dialog.exec()
109
117
 
110
118
  def open_analysis_window(self) -> None:
119
+ """Open the analysis window for the current 3D molecule, if available."""
111
120
  if self.host.view_3d_manager.current_mol:
112
121
  dialog = AnalysisWindow(
113
122
  self.host.view_3d_manager.current_mol,
@@ -126,7 +135,10 @@ class DialogManager:
126
135
  dialog.exec()
127
136
 
128
137
  def open_template_dialog_and_activate(self) -> None:
129
- """Open the template dialog and activate the selected template for use in the main window"""
138
+ """Open the template dialog and activate it.
139
+
140
+ Used in the main window.
141
+ """
130
142
  # Check for existing dialog
131
143
  _template_dialog = getattr(self.host, "_template_dialog", None)
132
144
  if _template_dialog and not _template_dialog.isHidden():
@@ -218,7 +230,8 @@ class DialogManager:
218
230
 
219
231
  def _show_modeless_dialog(self, dialog: QDialog) -> None:
220
232
  """Show a modeless dialog on top, especially important for macOS."""
221
- dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
233
+ if sys.platform == "darwin":
234
+ dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
222
235
  dialog.show()
223
236
  dialog.raise_()
224
237
  dialog.activateWindow()
@@ -460,13 +460,18 @@ class MoveGroupDialog(BasePickingDialog):
460
460
 
461
461
  def show_atom_labels(self) -> None:
462
462
  """Highlight atoms in the selected group."""
463
+ plotter = self.main_window.view_3d_manager.plotter
464
+ try:
465
+ cam = plotter.camera_position if plotter else None
466
+ except (AttributeError, RuntimeError, TypeError):
467
+ cam = None
468
+
463
469
  self.clear_atom_labels()
464
470
 
465
471
  if not self.group_atoms:
466
472
  return
467
473
 
468
474
  selected_indices = list(self.group_atoms)
469
- plotter = self.main_window.view_3d_manager.plotter
470
475
  if self.main_window.view_3d_manager.atom_positions_3d is None:
471
476
  logging.error("atom_positions_3d is None in update_atom_labels")
472
477
  return
@@ -496,8 +501,15 @@ class MoveGroupDialog(BasePickingDialog):
496
501
  opacity=0.3,
497
502
  name="move_group_highlight",
498
503
  pickable=False,
504
+ reset_camera=False,
499
505
  )
500
506
 
507
+ if cam is not None:
508
+ try:
509
+ plotter.camera_position = cam
510
+ except (AttributeError, RuntimeError, TypeError):
511
+ pass
512
+
501
513
  plotter.render()
502
514
 
503
515
  def clear_atom_labels(self) -> None:
@@ -471,13 +471,18 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
471
471
 
472
472
  def show_atom_labels(self) -> None:
473
473
  """Highlight selected atoms."""
474
+ plotter = self.main_window.view_3d_manager.plotter
475
+ try:
476
+ cam = plotter.camera_position if plotter else None
477
+ except (AttributeError, RuntimeError, TypeError):
478
+ cam = None
479
+
474
480
  self.clear_atom_labels()
475
481
 
476
482
  if not self.selected_atoms:
477
483
  return
478
484
 
479
485
  selected_indices = list(self.selected_atoms)
480
- plotter = self.main_window.view_3d_manager.plotter
481
486
  if self.main_window.view_3d_manager.atom_positions_3d is None:
482
487
  logging.error("atom_positions_3d is None in update_atom_labels")
483
488
  return
@@ -507,8 +512,15 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
507
512
  opacity=0.3,
508
513
  name="move_selected_atoms_highlight",
509
514
  pickable=False,
515
+ reset_camera=False,
510
516
  )
511
517
 
518
+ if cam is not None:
519
+ try:
520
+ plotter.camera_position = cam
521
+ except (AttributeError, RuntimeError, TypeError):
522
+ pass
523
+
512
524
  plotter.render()
513
525
 
514
526
  def clear_atom_labels(self) -> None:
File without changes