MoleditPy-linux 3.6.2__tar.gz → 3.6.4__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.2 → moleditpy_linux-3.6.4}/PKG-INFO +1 -1
  2. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/pyproject.toml +1 -1
  3. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/align_plane_dialog.py +95 -42
  5. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/alignment_dialog.py +98 -66
  6. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/base_picking_dialog.py +20 -0
  7. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/dialog_logic.py +18 -5
  8. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/translation_dialog.py +95 -24
  9. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/LICENSE +0 -0
  10. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/README.md +0 -0
  11. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/setup.cfg +0 -0
  12. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  13. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  14. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  15. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  16. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  17. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/__init__.py +0 -0
  18. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/__main__.py +0 -0
  19. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  20. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/assets/icon.icns +0 -0
  21. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/assets/icon.ico +0 -0
  22. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/assets/icon.png +0 -0
  23. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/core/__init__.py +0 -0
  24. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  25. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/core/molecular_data.py +0 -0
  26. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/main.py +0 -0
  27. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/plugins/__init__.py +0 -0
  28. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  29. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  30. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  31. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/__init__.py +0 -0
  32. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  33. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  34. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  35. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/app_state.py +0 -0
  36. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/atom_item.py +0 -0
  37. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  38. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/bond_item.py +0 -0
  39. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  40. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  41. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  42. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  43. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  44. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  45. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  46. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  47. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  48. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  49. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  50. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/export_logic.py +0 -0
  51. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  52. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/io_logic.py +0 -0
  53. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/main_window.py +0 -0
  54. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  55. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  56. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  57. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  58. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  59. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  60. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  61. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  62. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  63. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  64. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  65. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  66. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  67. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  68. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/string_importers.py +0 -0
  69. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  70. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  71. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  72. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  73. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  74. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  75. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/utils/__init__.py +0 -0
  76. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/utils/constants.py +0 -0
  77. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/utils/default_settings.py +0 -0
  78. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  79. {moleditpy_linux-3.6.2 → moleditpy_linux-3.6.4}/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.2
3
+ Version: 3.6.4
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.2"
8
+ version = "3.6.4"
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.2
3
+ Version: 3.6.4
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,22 @@ 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
33
+
34
+ try:
35
+ from ..core.mol_geometry import rodrigues_rotate
36
+ except ImportError:
37
+ from moleditpy_linux.core.mol_geometry import rodrigues_rotate
30
38
 
31
39
  if TYPE_CHECKING:
32
40
  from .main_window import MainWindow
33
41
 
34
42
 
35
43
  class AlignPlaneDialog(BasePickingDialog):
44
+ """Dialog for aligning selected atoms to a principal plane (XY, XZ, or YZ)."""
45
+
36
46
  def __init__(
37
47
  self,
38
48
  mol: Chem.Mol,
@@ -43,20 +53,31 @@ class AlignPlaneDialog(BasePickingDialog):
43
53
  ) -> None:
44
54
  super().__init__(mol, main_window, parent)
45
55
  self.plane = plane
46
- self.selected_atoms: set[int] = set()
56
+ self._selected_atoms = SelectionList()
47
57
 
48
58
  # Add preselected atoms
49
59
  if preselected_atoms:
50
- self.selected_atoms.update(preselected_atoms)
60
+ self._selected_atoms.update(preselected_atoms)
51
61
 
52
62
  self.init_ui()
53
63
 
54
64
  # Add labels to preselected atoms
55
- if self.selected_atoms:
65
+ if self._selected_atoms:
56
66
  self.show_atom_labels()
57
67
  self.update_display()
58
68
 
69
+ @property
70
+ def selected_atoms(self) -> SelectionList:
71
+ """Return the ordered list of selected atom indices."""
72
+ return self._selected_atoms
73
+
74
+ @selected_atoms.setter
75
+ def selected_atoms(self, val: object) -> None:
76
+ """Replace the selection with a new SelectionList built from val."""
77
+ self._selected_atoms = SelectionList(val) # type: ignore[arg-type]
78
+
59
79
  def init_ui(self) -> None:
80
+ """Build and lay out all widgets for the plane-alignment dialog."""
60
81
  plane_names = {"xy": "XY", "xz": "XZ", "yz": "YZ"}
61
82
  self.setWindowTitle(f"Align to {plane_names[self.plane]} Plane")
62
83
  self.setModal(False)
@@ -64,11 +85,18 @@ class AlignPlaneDialog(BasePickingDialog):
64
85
 
65
86
  # Instructions
66
87
  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."
88
+ f"Click atoms in the 3D view to select them for align to "
89
+ f"the {plane_names[self.plane]} plane. At least 3 atoms "
90
+ f"are required."
68
91
  )
69
92
  instruction_label.setWordWrap(True)
70
93
  layout.addWidget(instruction_label)
71
94
 
95
+ # Move to zero plane option (default False)
96
+ self.move_to_zero_plane_checkbox = QCheckBox("Move the plane to the zero plane")
97
+ self.move_to_zero_plane_checkbox.setChecked(False)
98
+ layout.addWidget(self.move_to_zero_plane_checkbox)
99
+
72
100
  # Selected atoms display
73
101
  self.selection_label = QLabel("No atoms selected")
74
102
  layout.addWidget(self.selection_label)
@@ -106,11 +134,12 @@ class AlignPlaneDialog(BasePickingDialog):
106
134
 
107
135
  def on_atom_picked(self, atom_idx: int) -> None:
108
136
  """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)
137
+ if atom_idx in self._selected_atoms:
138
+ self._selected_atoms.remove(atom_idx)
111
139
  else:
112
- self.selected_atoms.add(atom_idx)
140
+ self._selected_atoms.append(atom_idx)
113
141
 
142
+ self.show_atom_labels()
114
143
  self.update_display()
115
144
 
116
145
  def clear_selection(self) -> None:
@@ -139,6 +168,7 @@ class AlignPlaneDialog(BasePickingDialog):
139
168
  self.update_display()
140
169
 
141
170
  except (AttributeError, RuntimeError, TypeError, KeyError) as e:
171
+ logging.exception("Failed to select all atoms")
142
172
  QMessageBox.warning(self, "Warning", f"Failed to select all atoms: {e}")
143
173
 
144
174
  def update_display(self) -> None:
@@ -150,8 +180,15 @@ class AlignPlaneDialog(BasePickingDialog):
150
180
  )
151
181
  self.apply_button.setEnabled(False)
152
182
  else:
153
- # Just show the count of selected atoms (to prevent dialog resizing)
154
- self.selection_label.setText(f"Selected {count} atoms")
183
+ atom_list = sorted(self.selected_atoms)
184
+ atom_display = []
185
+ for i, atom_idx in enumerate(atom_list):
186
+ symbol = self.mol.GetAtomWithIdx(atom_idx).GetSymbol()
187
+ atom_display.append(f"#{i + 1}: {symbol}({atom_idx})")
188
+
189
+ self.selection_label.setText(
190
+ f"Selected {count} atoms: {', '.join(atom_display)}"
191
+ )
155
192
  self.apply_button.setEnabled(count >= 3)
156
193
 
157
194
  def show_atom_labels(self) -> None:
@@ -159,7 +196,7 @@ class AlignPlaneDialog(BasePickingDialog):
159
196
  if self.selected_atoms:
160
197
  sorted_atoms = sorted(self.selected_atoms)
161
198
  pairs = [(idx, f"#{i + 1}") for i, idx in enumerate(sorted_atoms)]
162
- self.show_atom_labels_for(pairs, color="blue")
199
+ self.show_atom_labels_for(pairs, color="yellow")
163
200
  else:
164
201
  self.clear_atom_labels()
165
202
 
@@ -167,7 +204,9 @@ class AlignPlaneDialog(BasePickingDialog):
167
204
  """Apply plane alignment (rotation-based)."""
168
205
  if len(self.selected_atoms) < 3:
169
206
  QMessageBox.warning(
170
- self, "Warning", "Please select at least 3 atoms for align."
207
+ self,
208
+ "Warning",
209
+ "Please select at least 3 atoms for align.",
171
210
  )
172
211
  return
173
212
  try:
@@ -197,7 +236,8 @@ class AlignPlaneDialog(BasePickingDialog):
197
236
  elif self.plane == "yz":
198
237
  target_normal = np.array([1, 0, 0]) # X-axis direction
199
238
  else:
200
- target_normal = np.array([0, 0, 1]) # Default to Z-axis (XY plane)
239
+ # Default to Z-axis (XY plane)
240
+ target_normal = np.array([0, 0, 1])
201
241
 
202
242
  # Adjust normal vector direction
203
243
  if np.dot(normal_vector, target_normal) < 0:
@@ -207,36 +247,49 @@ class AlignPlaneDialog(BasePickingDialog):
207
247
  rotation_axis = np.cross(normal_vector, target_normal)
208
248
  rotation_axis_norm = np.linalg.norm(rotation_axis)
209
249
 
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
- )
227
-
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
233
- rotated_pos = rodrigues_rotation(
234
- centered_pos, rotation_axis, rotation_angle
250
+ # Calculate new positions (rotated, centered back by default)
251
+ conf = self.mol.GetConformer()
252
+ new_positions = np.empty_like(positions)
253
+ for i in range(self.mol.GetNumAtoms()):
254
+ current_pos = np.array(conf.GetAtomPosition(i))
255
+ centered_pos = current_pos - centroid
256
+ if rotation_axis_norm > 1e-10:
257
+ rot_norm = rotation_axis_norm
258
+ rotation_axis_normalized = rotation_axis / rot_norm
259
+ cos_angle = np.dot(normal_vector, target_normal)
260
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
261
+ rotation_angle = np.arccos(cos_angle)
262
+ rotated_pos = rodrigues_rotate(
263
+ centered_pos,
264
+ rotation_axis_normalized,
265
+ rotation_angle,
235
266
  )
236
- new_pos = rotated_pos + centroid
237
- positions[i] = new_pos
267
+ else:
268
+ rotated_pos = centered_pos
269
+ new_pos = rotated_pos + centroid
270
+ new_positions[i] = new_pos
271
+
272
+ # If move_to_zero_plane is True, translate so the plane
273
+ # of selected atoms is at zero
274
+ if self.move_to_zero_plane_checkbox.isChecked():
275
+ selected_new_positions = new_positions[selected_indices]
276
+ new_centroid = np.mean(selected_new_positions, axis=0)
277
+ translation_offset = np.zeros(3)
278
+ if self.plane == "xy":
279
+ translation_offset[2] = new_centroid[2]
280
+ elif self.plane == "xz":
281
+ translation_offset[1] = new_centroid[1]
282
+ elif self.plane == "yz":
283
+ translation_offset[0] = new_centroid[0]
284
+ new_positions = new_positions - translation_offset
285
+
286
+ # Update the conformer positions array in place
287
+ for i in range(self.mol.GetNumAtoms()):
288
+ positions[i] = new_positions[i]
238
289
 
239
290
  self._update_molecule_geometry(positions)
291
+ self.show_atom_labels()
240
292
 
241
293
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
294
+ logging.exception("Failed to apply align")
242
295
  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,23 @@ 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
+
41
+ try:
42
+ from ..core.mol_geometry import rodrigues_rotate
43
+ except ImportError:
44
+ from moleditpy_linux.core.mol_geometry import rodrigues_rotate
45
+
33
46
  if TYPE_CHECKING:
34
47
  from .main_window import MainWindow
35
48
 
36
49
 
37
50
  class AlignmentDialog(Dialog3DPickingMixin, QDialog):
51
+ """Dialog for aligning two selected atoms along a principal axis (X, Y, or Z)."""
52
+
38
53
  def __init__(
39
54
  self,
40
55
  mol: Chem.Mol,
@@ -48,21 +63,32 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
48
63
  self.mol = mol
49
64
  self.main_window = main_window
50
65
  self.axis = axis
51
- self.selected_atoms: set[int] = set()
66
+ self._selected_atoms = SelectionList()
52
67
 
53
68
  # Add preselected atoms (maximum 2)
54
69
  if preselected_atoms:
55
- self.selected_atoms.update(preselected_atoms[:2])
70
+ self._selected_atoms.update(preselected_atoms[:2])
56
71
 
57
72
  self.init_ui()
58
73
 
59
74
  # 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}")
75
+ if self._selected_atoms:
76
+ for i, atom_idx in enumerate(self._selected_atoms, 1):
77
+ self.add_selection_label(atom_idx, f"#{i}", color="yellow")
63
78
  self.update_display()
64
79
 
80
+ @property
81
+ def selected_atoms(self) -> SelectionList:
82
+ """Return the ordered list of selected atom indices."""
83
+ return self._selected_atoms
84
+
85
+ @selected_atoms.setter
86
+ def selected_atoms(self, val: object) -> None:
87
+ """Replace the selection with a new SelectionList built from val."""
88
+ self._selected_atoms = SelectionList(val) # type: ignore[arg-type]
89
+
65
90
  def init_ui(self) -> None:
91
+ """Build and lay out all widgets for the alignment dialog."""
66
92
  axis_names = {"x": "X-axis", "y": "Y-axis", "z": "Z-axis"}
67
93
  self.setWindowTitle(f"Align to {axis_names[self.axis]}")
68
94
  self.setModal(False)
@@ -70,11 +96,17 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
70
96
 
71
97
  # Instructions
72
98
  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]}."
99
+ f"Click atoms in the 3D view to select them for alignment to the "
100
+ f"{axis_names[self.axis]}. Exactly 2 atoms are required."
74
101
  )
75
102
  instruction_label.setWordWrap(True)
76
103
  layout.addWidget(instruction_label)
77
104
 
105
+ # Move to origin option (default False)
106
+ self.move_to_origin_checkbox = QCheckBox("Move the first atom to the origin")
107
+ self.move_to_origin_checkbox.setChecked(False)
108
+ layout.addWidget(self.move_to_origin_checkbox)
109
+
78
110
  # Selected atoms display
79
111
  self.selection_label = QLabel("No atoms selected")
80
112
  layout.addWidget(self.selection_label)
@@ -114,10 +146,10 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
114
146
  else:
115
147
  # Maximum of 2 atoms can be selected
116
148
  if len(self.selected_atoms) < 2:
117
- self.selected_atoms.add(atom_idx)
149
+ self.selected_atoms.append(atom_idx)
118
150
  # Show label indicating selection order
119
- label_text = f"Atom {len(self.selected_atoms)}"
120
- self.add_selection_label(atom_idx, label_text)
151
+ label_text = f"#{len(self.selected_atoms)}"
152
+ self.add_selection_label(atom_idx, label_text, color="yellow")
121
153
 
122
154
  self.update_display()
123
155
 
@@ -129,14 +161,12 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
129
161
  )
130
162
  self.apply_button.setEnabled(False)
131
163
  elif len(self.selected_atoms) == 1:
132
- selected_list = list(self.selected_atoms)
133
- atom = self.mol.GetAtomWithIdx(selected_list[0])
164
+ atom = self.mol.GetAtomWithIdx(self.selected_atoms[0])
134
165
  self.selection_label.setText(f"Selected 1 atom: {atom.GetSymbol()}")
135
166
  self.apply_button.setEnabled(False)
136
167
  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])
168
+ atom1 = self.mol.GetAtomWithIdx(self.selected_atoms[0])
169
+ atom2 = self.mol.GetAtomWithIdx(self.selected_atoms[1])
140
170
  self.selection_label.setText(
141
171
  f"Selected 2 atoms: {atom1.GetSymbol()}, {atom2.GetSymbol()}"
142
172
  )
@@ -148,13 +178,11 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
148
178
  self.selected_atoms.clear()
149
179
  self.update_display()
150
180
 
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
181
+ def remove_atom_label(self, _atom_idx: int) -> None:
182
+ """Remove a label for a specific atom (redraws all labels)."""
154
183
  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}")
184
+ for i, idx in enumerate(self.selected_atoms, 1):
185
+ self.add_selection_label(idx, f"#{i}", color="yellow")
158
186
 
159
187
  def apply_alignment(self) -> None:
160
188
  """Apply the specific axial alignment to the molecule."""
@@ -164,26 +192,21 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
164
192
  )
165
193
  return
166
194
  try:
167
- selected_list = sorted(list(self.selected_atoms))
168
- atom1_idx, atom2_idx = selected_list[0], selected_list[1]
195
+ atom1_idx = self.selected_atoms[0]
196
+ atom2_idx = self.selected_atoms[1]
169
197
 
170
198
  conf = self.mol.GetConformer()
171
199
 
172
- # Get current atom positions
173
- pos1 = np.array(conf.GetAtomPosition(atom1_idx))
174
- pos2 = np.array(conf.GetAtomPosition(atom2_idx))
175
-
176
- # Translate entire molecule so atom1 is at the origin
177
- translation = -pos1
178
- for i in range(self.mol.GetNumAtoms()):
179
- current_pos = np.array(conf.GetAtomPosition(i))
180
- new_pos = current_pos + translation
181
- conf.SetAtomPosition(i, new_pos.tolist())
200
+ # Get original atom positions
201
+ positions = np.array(
202
+ [list(conf.GetAtomPosition(i)) for i in range(self.mol.GetNumAtoms())]
203
+ )
204
+ centroid = np.mean(positions, axis=0)
182
205
 
183
- # Get new position of atom2 after translation
184
- pos2_translated = pos2 + translation
206
+ pos1 = positions[atom1_idx]
207
+ pos2 = positions[atom2_idx]
185
208
 
186
- # Calculate rotation to align atom2 relative to the chosen axis
209
+ # Calculate rotation to align atom1 -> atom2 relative to the chosen axis
187
210
  axis_vectors = {
188
211
  "x": np.array([1.0, 0.0, 0.0]),
189
212
  "y": np.array([0.0, 1.0, 0.0]),
@@ -191,10 +214,13 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
191
214
  }
192
215
  target_axis = axis_vectors[self.axis]
193
216
 
194
- # Direction vector from origin to translated atom2
195
- current_vector = pos2_translated
217
+ # Direction vector from atom1 to atom2
218
+ current_vector = pos2 - pos1
196
219
  current_length = np.linalg.norm(current_vector)
197
220
 
221
+ # Keep track of rotated positions (initially original positions)
222
+ new_positions = np.copy(positions)
223
+
198
224
  if current_length > 1e-10: # If not a zero vector
199
225
  current_vector_normalized = current_vector / current_length
200
226
 
@@ -208,41 +234,44 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
208
234
  cos_angle = np.clip(cos_angle, -1.0, 1.0)
209
235
  rotation_angle = np.arccos(cos_angle)
210
236
 
211
- # Use Rodrigues' rotation formula
212
- def rodrigues_rotation(
213
- v: np.ndarray, k: np.ndarray, theta: float
214
- ) -> np.ndarray:
215
- cos_theta = np.cos(theta)
216
- sin_theta = np.sin(theta)
217
- return ( # type: ignore[no-any-return]
218
- v * cos_theta
219
- + np.cross(k, v) * sin_theta
220
- + k * np.dot(k, v) * (1 - cos_theta)
221
- )
222
-
223
- # Apply rotation to all atoms
237
+ # Apply rotation to all atoms about the molecule's centroid
224
238
  for i in range(self.mol.GetNumAtoms()):
225
- current_pos = np.array(conf.GetAtomPosition(i))
226
- rotated_pos = rodrigues_rotation(
227
- current_pos, rotation_axis, rotation_angle
228
- )
229
- conf.SetAtomPosition(
230
- i,
231
- Geometry.Point3D(
232
- float(rotated_pos[0]),
233
- float(rotated_pos[1]),
234
- float(rotated_pos[2]),
235
- ),
239
+ rel_pos = positions[i] - centroid
240
+ rotated_pos = rodrigues_rotate(
241
+ rel_pos, rotation_axis, rotation_angle
236
242
  )
243
+ new_positions[i] = rotated_pos + centroid
244
+
245
+ # If move_to_origin is True, translate entire molecule so atom1 ends up at origin
246
+ if (
247
+ hasattr(self, "move_to_origin_checkbox")
248
+ and self.move_to_origin_checkbox.isChecked()
249
+ ):
250
+ new_pos1 = new_positions[atom1_idx]
251
+ new_positions = new_positions - new_pos1
252
+
253
+ # Update conformer positions
254
+ for i in range(self.mol.GetNumAtoms()):
255
+ conf.SetAtomPosition(
256
+ i,
257
+ Geometry.Point3D(
258
+ float(new_positions[i][0]),
259
+ float(new_positions[i][1]),
260
+ float(new_positions[i][2]),
261
+ ),
262
+ )
237
263
 
238
264
  # Update 3D positions
239
- self.main_window.view_3d_manager.atom_positions_3d = np.array(
240
- [list(conf.GetAtomPosition(i)) for i in range(self.mol.GetNumAtoms())]
241
- )
265
+ self.main_window.view_3d_manager.atom_positions_3d = new_positions
242
266
 
243
267
  # Update 3D visualization
244
268
  self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
245
269
 
270
+ # Restore selection labels
271
+ self.clear_selection_labels()
272
+ for i, idx in enumerate(self.selected_atoms, 1):
273
+ self.add_selection_label(idx, f"#{i}", color="yellow")
274
+
246
275
  # Update chirality labels
247
276
  self.main_window.view_3d_manager.update_chiral_labels()
248
277
 
@@ -250,10 +279,13 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
250
279
  self.main_window.edit_actions_manager.push_undo_state()
251
280
 
252
281
  QMessageBox.information(
253
- self, "Success", f"Alignment to {self.axis.upper()}-axis completed."
282
+ self,
283
+ "Success",
284
+ f"Alignment to {self.axis.upper()}-axis completed.",
254
285
  )
255
286
 
256
287
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
288
+ logging.exception("Failed to apply alignment")
257
289
  QMessageBox.critical(self, "Error", f"Failed to apply alignment: {str(e)}")
258
290
 
259
291
  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()
@@ -10,6 +10,7 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
+ import logging
13
14
  from typing import Any
14
15
 
15
16
  import numpy as np
@@ -35,6 +36,8 @@ _TAB_DELTA = 1
35
36
 
36
37
 
37
38
  class TranslationDialog(BasePickingDialog):
39
+ """Dialog for translating selected atoms either absolutely or relatively."""
40
+
38
41
  def __init__(
39
42
  self,
40
43
  mol: Any,
@@ -45,6 +48,19 @@ class TranslationDialog(BasePickingDialog):
45
48
  super().__init__(mol, main_window, parent)
46
49
  self.selected_atoms = set()
47
50
 
51
+ # Predefine widgets to satisfy pylint W0201
52
+ self.abs_selection_label = None
53
+ self.abs_x_input = None
54
+ self.abs_y_input = None
55
+ self.abs_z_input = None
56
+ self.move_mol_checkbox = None
57
+ self.abs_apply_btn = None
58
+ self.delta_selection_label = None
59
+ self.dx_input = None
60
+ self.dy_input = None
61
+ self.dz_input = None
62
+ self.apply_button = None
63
+
48
64
  if preselected_atoms:
49
65
  self.selected_atoms.update(preselected_atoms)
50
66
 
@@ -56,7 +72,7 @@ class TranslationDialog(BasePickingDialog):
56
72
  self.tabs.blockSignals(True)
57
73
  if len(self.selected_atoms) == 1:
58
74
  self.tabs.setCurrentIndex(_TAB_ABSOLUTE)
59
- self._populate_abs_inputs_from_atom(next(iter(self.selected_atoms)))
75
+ self._populate_abs_inputs_from_centroid()
60
76
  else:
61
77
  self.tabs.setCurrentIndex(_TAB_DELTA)
62
78
  self.tabs.blockSignals(False)
@@ -68,6 +84,7 @@ class TranslationDialog(BasePickingDialog):
68
84
  # ------------------------------------------------------------------
69
85
 
70
86
  def init_ui(self) -> None:
87
+ """Initialize and lay out all dialog widgets."""
71
88
  self.setWindowTitle("Translate Atoms")
72
89
  self.setModal(False)
73
90
  layout = QVBoxLayout(self)
@@ -89,16 +106,18 @@ class TranslationDialog(BasePickingDialog):
89
106
  self.enable_picking()
90
107
 
91
108
  def _build_absolute_tab(self) -> QWidget:
109
+ """Build the Absolute tab widget."""
92
110
  widget = QWidget()
93
111
  layout = QVBoxLayout(widget)
94
112
 
95
113
  instr = QLabel(
96
- "Click one atom to select it, then specify its target absolute coordinates (Å)."
114
+ "Click atoms to select them. The centroid of the selection "
115
+ "will be translated to the target absolute coordinates (Å)."
97
116
  )
98
117
  instr.setWordWrap(True)
99
118
  layout.addWidget(instr)
100
119
 
101
- self.abs_selection_label = QLabel("No atom selected")
120
+ self.abs_selection_label = QLabel("No atoms selected")
102
121
  layout.addWidget(self.abs_selection_label)
103
122
 
104
123
  coord_row = QHBoxLayout()
@@ -122,10 +141,17 @@ class TranslationDialog(BasePickingDialog):
122
141
  abs_clear_btn = QPushButton("Clear Selection")
123
142
  abs_clear_btn.clicked.connect(self._abs_clear_selection)
124
143
  btn_row.addWidget(abs_clear_btn)
144
+
145
+ abs_all_btn = QPushButton("Select All Atoms")
146
+ abs_all_btn.setToolTip("Select all atoms in the molecule")
147
+ abs_all_btn.clicked.connect(self._abs_select_all)
148
+ btn_row.addWidget(abs_all_btn)
149
+
125
150
  origin_btn = QPushButton("Set to Origin")
126
151
  origin_btn.setToolTip("Set target coordinates to the origin")
127
152
  origin_btn.clicked.connect(self._set_origin)
128
153
  btn_row.addWidget(origin_btn)
154
+
129
155
  btn_row.addStretch()
130
156
  self.abs_apply_btn = QPushButton("Move Molecule")
131
157
  self.abs_apply_btn.clicked.connect(self.apply_absolute)
@@ -185,7 +211,8 @@ class TranslationDialog(BasePickingDialog):
185
211
  # Tab switching
186
212
  # ------------------------------------------------------------------
187
213
 
188
- def _on_tab_changed(self, index: int) -> None:
214
+ def _on_tab_changed(self, _index: int) -> None:
215
+ """Handle tab switching event to reset active selections."""
189
216
  if hasattr(self, "_is_initializing") and self._is_initializing:
190
217
  return
191
218
  self.selected_atoms.clear()
@@ -203,9 +230,12 @@ class TranslationDialog(BasePickingDialog):
203
230
  self._delta_on_atom_picked(atom_idx)
204
231
 
205
232
  def _abs_on_atom_picked(self, atom_idx: int) -> None:
206
- # Enforce single selection: replace previous atom
207
- self.selected_atoms = {atom_idx}
208
- self._populate_abs_inputs_from_atom(atom_idx)
233
+ """Toggle atom in/out of the absolute-tab selection, then refresh centroid."""
234
+ if atom_idx in self.selected_atoms:
235
+ self.selected_atoms.discard(atom_idx)
236
+ else:
237
+ self.selected_atoms.add(atom_idx)
238
+ self._populate_abs_inputs_from_centroid()
209
239
  self.update_display()
210
240
  self.show_atom_labels()
211
241
 
@@ -221,19 +251,24 @@ class TranslationDialog(BasePickingDialog):
221
251
  # Absolute tab helpers
222
252
  # ------------------------------------------------------------------
223
253
 
224
- def _populate_abs_inputs_from_atom(self, atom_idx: int) -> None:
254
+ def _populate_abs_inputs_from_centroid(self) -> None:
255
+ """Populate X/Y/Z inputs with the centroid of all currently selected atoms."""
256
+ if not self.selected_atoms:
257
+ return
225
258
  mol = self.main_window.view_3d_manager.current_mol
226
259
  if mol is None:
227
260
  return
228
261
  conf = mol.GetConformer()
229
262
  if conf is None:
230
263
  return
231
- pos = conf.GetPositions()[atom_idx]
232
- self.abs_x_input.setText(f"{pos[0]:.4f}")
233
- self.abs_y_input.setText(f"{pos[1]:.4f}")
234
- self.abs_z_input.setText(f"{pos[2]:.4f}")
264
+ positions = conf.GetPositions()
265
+ centroid = np.mean([positions[i] for i in self.selected_atoms], axis=0)
266
+ self.abs_x_input.setText(f"{centroid[0]:.4f}")
267
+ self.abs_y_input.setText(f"{centroid[1]:.4f}")
268
+ self.abs_z_input.setText(f"{centroid[2]:.4f}")
235
269
 
236
270
  def _abs_clear_selection(self) -> None:
271
+ """Clear the absolute-tab selection and reset coordinate inputs."""
237
272
  self.selected_atoms.clear()
238
273
  self.clear_atom_labels()
239
274
  self.abs_x_input.setText("0.000")
@@ -241,19 +276,35 @@ class TranslationDialog(BasePickingDialog):
241
276
  self.abs_z_input.setText("0.000")
242
277
  self.update_display()
243
278
 
279
+ def _abs_select_all(self) -> None:
280
+ """Select all atoms in the molecule for the absolute tab."""
281
+ try:
282
+ mol = self.main_window.view_3d_manager.current_mol
283
+ if mol is not None:
284
+ self.selected_atoms = set(range(mol.GetNumAtoms()))
285
+ self._populate_abs_inputs_from_centroid()
286
+ self.show_atom_labels()
287
+ self.update_display()
288
+ except (AttributeError, RuntimeError, TypeError) as exc:
289
+ logging.exception("Failed to select all atoms: %s", exc)
290
+
244
291
  def _set_origin(self) -> None:
245
292
  self.abs_x_input.setText("0.0000")
246
293
  self.abs_y_input.setText("0.0000")
247
294
  self.abs_z_input.setText("0.0000")
248
295
 
249
- def _on_move_mol_toggled(self, state: int) -> None:
250
- label = "Move Molecule" if self.move_mol_checkbox.isChecked() else "Move Atom"
296
+ def _on_move_mol_toggled(self, _state: int) -> None:
297
+ """Update Apply button label to reflect move-molecule vs move-selected mode."""
298
+ label = (
299
+ "Move Molecule" if self.move_mol_checkbox.isChecked() else "Move Selected"
300
+ )
251
301
  self.abs_apply_btn.setText(label)
252
302
 
253
303
  def apply_absolute(self) -> None:
304
+ """Translate selected atoms so their centroid reaches the target coordinates."""
254
305
  self.mol = self.main_window.view_3d_manager.current_mol
255
- if len(self.selected_atoms) != 1:
256
- QMessageBox.warning(self, "Warning", "Please select exactly one atom.")
306
+ if not self.selected_atoms:
307
+ QMessageBox.warning(self, "Warning", "Please select at least one atom.")
257
308
  return
258
309
 
259
310
  try:
@@ -266,10 +317,9 @@ class TranslationDialog(BasePickingDialog):
266
317
  )
267
318
  return
268
319
 
269
- atom_idx = next(iter(self.selected_atoms))
270
320
  positions = self.mol.GetConformer().GetPositions()
271
- current = positions[atom_idx]
272
- delta = np.array([tx, ty, tz]) - current
321
+ centroid = np.mean([positions[i] for i in self.selected_atoms], axis=0)
322
+ delta = np.array([tx, ty, tz]) - centroid
273
323
 
274
324
  if np.allclose(delta, 0):
275
325
  return
@@ -277,10 +327,12 @@ class TranslationDialog(BasePickingDialog):
277
327
  if self.move_mol_checkbox.isChecked():
278
328
  positions += delta
279
329
  else:
280
- positions[atom_idx] += delta
330
+ for atom_idx in self.selected_atoms:
331
+ positions[atom_idx] += delta
281
332
 
282
333
  self._update_molecule_geometry(positions)
283
334
  self._push_undo()
335
+ self._populate_abs_inputs_from_centroid()
284
336
  self.show_atom_labels()
285
337
 
286
338
  # ------------------------------------------------------------------
@@ -288,11 +340,13 @@ class TranslationDialog(BasePickingDialog):
288
340
  # ------------------------------------------------------------------
289
341
 
290
342
  def clear_selection(self) -> None:
343
+ """Clear active atom selection for the relative tab."""
291
344
  self.selected_atoms.clear()
292
345
  self.clear_atom_labels()
293
346
  self.update_display()
294
347
 
295
348
  def select_all_atoms(self) -> None:
349
+ """Select all atoms in the molecule for relative translation."""
296
350
  try:
297
351
  if hasattr(self, "mol") and self.mol is not None:
298
352
  self.selected_atoms = set(range(self.mol.GetNumAtoms()))
@@ -308,6 +362,7 @@ class TranslationDialog(BasePickingDialog):
308
362
  QMessageBox.warning(self, "Warning", f"Failed to select all atoms: {e}")
309
363
 
310
364
  def apply_translation(self) -> None:
365
+ """Apply relative translation vector to selected atoms."""
311
366
  self.mol = self.main_window.view_3d_manager.current_mol
312
367
  if not self.selected_atoms:
313
368
  QMessageBox.warning(self, "Warning", "Please select at least one atom.")
@@ -340,17 +395,32 @@ class TranslationDialog(BasePickingDialog):
340
395
  # ------------------------------------------------------------------
341
396
 
342
397
  def update_display(self) -> None:
398
+ """Update label descriptions and button enablement states."""
343
399
  tab = self.tabs.currentIndex()
344
400
  count = len(self.selected_atoms)
345
401
 
346
402
  if tab == _TAB_ABSOLUTE:
347
403
  if count == 0:
348
- self.abs_selection_label.setText("Click one atom to select it")
404
+ self.abs_selection_label.setText("Click atoms to select them")
349
405
  self.abs_apply_btn.setEnabled(False)
350
406
  else:
351
- atom_idx = next(iter(self.selected_atoms))
352
- sym = self.mol.GetAtomWithIdx(atom_idx).GetSymbol()
353
- self.abs_selection_label.setText(f"Selected: atom {atom_idx} ({sym})")
407
+ mol = self.main_window.view_3d_manager.current_mol
408
+ if mol is not None and self.selected_atoms:
409
+ positions = mol.GetConformer().GetPositions()
410
+ centroid = np.mean(
411
+ [positions[i] for i in self.selected_atoms], axis=0
412
+ )
413
+ centroid_str = (
414
+ f"({centroid[0]:.3f}, {centroid[1]:.3f}, {centroid[2]:.3f})"
415
+ )
416
+ self.abs_selection_label.setText(
417
+ f"{count} atom{'s' if count != 1 else ''} selected "
418
+ f"— centroid: {centroid_str}"
419
+ )
420
+ else:
421
+ self.abs_selection_label.setText(
422
+ f"{count} atom{'s' if count != 1 else ''} selected"
423
+ )
354
424
  self.abs_apply_btn.setEnabled(True)
355
425
  else:
356
426
  if count == 0:
@@ -365,6 +435,7 @@ class TranslationDialog(BasePickingDialog):
365
435
  self.apply_button.setEnabled(True)
366
436
 
367
437
  def show_atom_labels(self) -> None:
438
+ """Redraw selection numeric tags in the active 3D viewport."""
368
439
  if self.selected_atoms:
369
440
  sorted_atoms = sorted(self.selected_atoms)
370
441
  pairs = [(idx, str(i + 1)) for i, idx in enumerate(sorted_atoms)]
File without changes