MoleditPy-linux 3.6.3__tar.gz → 3.6.5__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.3 → moleditpy_linux-3.6.5}/PKG-INFO +1 -1
  2. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/pyproject.toml +1 -1
  3. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/align_plane_dialog.py +6 -13
  5. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/alignment_dialog.py +42 -49
  6. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/io_logic.py +1 -1
  7. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/translation_dialog.py +95 -24
  8. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/LICENSE +0 -0
  9. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/README.md +0 -0
  10. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/setup.cfg +0 -0
  11. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  12. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  13. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  14. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  15. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  16. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/__init__.py +0 -0
  17. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/__main__.py +0 -0
  18. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  19. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/assets/icon.icns +0 -0
  20. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/assets/icon.ico +0 -0
  21. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/assets/icon.png +0 -0
  22. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/core/__init__.py +0 -0
  23. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  24. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/core/molecular_data.py +0 -0
  25. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/main.py +0 -0
  26. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/plugins/__init__.py +0 -0
  27. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  28. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  29. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  30. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/__init__.py +0 -0
  31. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  32. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  33. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  34. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/app_state.py +0 -0
  35. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/atom_item.py +0 -0
  36. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  37. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  38. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/bond_item.py +0 -0
  39. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  40. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  41. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  42. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  43. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  44. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  45. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  46. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  47. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  48. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  49. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  50. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  51. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/export_logic.py +0 -0
  52. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  53. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/main_window.py +0 -0
  54. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  55. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  56. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  57. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  58. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  59. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  60. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  61. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  62. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  63. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  64. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  65. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  66. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  67. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  68. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/string_importers.py +0 -0
  69. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  70. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  71. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  72. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  73. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  74. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  75. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/utils/__init__.py +0 -0
  76. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/utils/constants.py +0 -0
  77. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/utils/default_settings.py +0 -0
  78. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  79. {moleditpy_linux-3.6.3 → moleditpy_linux-3.6.5}/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.3
3
+ Version: 3.6.5
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.3"
8
+ version = "3.6.5"
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.3
3
+ Version: 3.6.5
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
@@ -31,6 +31,11 @@ try:
31
31
  except ImportError:
32
32
  from moleditpy_linux.ui.base_picking_dialog import BasePickingDialog, SelectionList
33
33
 
34
+ try:
35
+ from ..core.mol_geometry import rodrigues_rotate
36
+ except ImportError:
37
+ from moleditpy_linux.core.mol_geometry import rodrigues_rotate
38
+
34
39
  if TYPE_CHECKING:
35
40
  from .main_window import MainWindow
36
41
 
@@ -242,18 +247,6 @@ class AlignPlaneDialog(BasePickingDialog):
242
247
  rotation_axis = np.cross(normal_vector, target_normal)
243
248
  rotation_axis_norm = np.linalg.norm(rotation_axis)
244
249
 
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
- )
256
-
257
250
  # Calculate new positions (rotated, centered back by default)
258
251
  conf = self.mol.GetConformer()
259
252
  new_positions = np.empty_like(positions)
@@ -266,7 +259,7 @@ class AlignPlaneDialog(BasePickingDialog):
266
259
  cos_angle = np.dot(normal_vector, target_normal)
267
260
  cos_angle = np.clip(cos_angle, -1.0, 1.0)
268
261
  rotation_angle = np.arccos(cos_angle)
269
- rotated_pos = rodrigues_rotation(
262
+ rotated_pos = rodrigues_rotate(
270
263
  centered_pos,
271
264
  rotation_axis_normalized,
272
265
  rotation_angle,
@@ -38,6 +38,11 @@ try:
38
38
  except ImportError:
39
39
  from moleditpy_linux.ui.base_picking_dialog import SelectionList
40
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
+
41
46
  if TYPE_CHECKING:
42
47
  from .main_window import MainWindow
43
48
 
@@ -192,21 +197,16 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
192
197
 
193
198
  conf = self.mol.GetConformer()
194
199
 
195
- # Get current atom positions
196
- pos1 = np.array(conf.GetAtomPosition(atom1_idx))
197
- pos2 = np.array(conf.GetAtomPosition(atom2_idx))
198
-
199
- # Translate entire molecule so atom1 is at the origin
200
- translation = -pos1
201
- for i in range(self.mol.GetNumAtoms()):
202
- current_pos = np.array(conf.GetAtomPosition(i))
203
- new_pos = current_pos + translation
204
- 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)
205
205
 
206
- # Get new position of atom2 after translation
207
- pos2_translated = pos2 + translation
206
+ pos1 = positions[atom1_idx]
207
+ pos2 = positions[atom2_idx]
208
208
 
209
- # Calculate rotation to align atom2 relative to the chosen axis
209
+ # Calculate rotation to align atom1 -> atom2 relative to the chosen axis
210
210
  axis_vectors = {
211
211
  "x": np.array([1.0, 0.0, 0.0]),
212
212
  "y": np.array([0.0, 1.0, 0.0]),
@@ -214,10 +214,13 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
214
214
  }
215
215
  target_axis = axis_vectors[self.axis]
216
216
 
217
- # Direction vector from origin to translated atom2
218
- current_vector = pos2_translated
217
+ # Direction vector from atom1 to atom2
218
+ current_vector = pos2 - pos1
219
219
  current_length = np.linalg.norm(current_vector)
220
220
 
221
+ # Keep track of rotated positions (initially original positions)
222
+ new_positions = np.copy(positions)
223
+
221
224
  if current_length > 1e-10: # If not a zero vector
222
225
  current_vector_normalized = current_vector / current_length
223
226
 
@@ -231,45 +234,35 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
231
234
  cos_angle = np.clip(cos_angle, -1.0, 1.0)
232
235
  rotation_angle = np.arccos(cos_angle)
233
236
 
234
- # Use Rodrigues' rotation formula
235
- def rodrigues_rotation(
236
- v: np.ndarray, k: np.ndarray, theta: float
237
- ) -> np.ndarray:
238
- cos_theta = np.cos(theta)
239
- sin_theta = np.sin(theta)
240
- return ( # type: ignore[no-any-return]
241
- v * cos_theta
242
- + np.cross(k, v) * sin_theta
243
- + k * np.dot(k, v) * (1 - cos_theta)
244
- )
245
-
246
- # Apply rotation to all atoms
237
+ # Apply rotation to all atoms about the molecule's centroid
247
238
  for i in range(self.mol.GetNumAtoms()):
248
- current_pos = np.array(conf.GetAtomPosition(i))
249
- rotated_pos = rodrigues_rotation(
250
- current_pos, rotation_axis, rotation_angle
251
- )
252
- conf.SetAtomPosition(
253
- i,
254
- Geometry.Point3D(
255
- float(rotated_pos[0]),
256
- float(rotated_pos[1]),
257
- float(rotated_pos[2]),
258
- ),
239
+ rel_pos = positions[i] - centroid
240
+ rotated_pos = rodrigues_rotate(
241
+ rel_pos, rotation_axis, rotation_angle
259
242
  )
243
+ new_positions[i] = rotated_pos + centroid
260
244
 
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())
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
+ )
268
263
 
269
264
  # Update 3D positions
270
- self.main_window.view_3d_manager.atom_positions_3d = np.array(
271
- [list(conf.GetAtomPosition(i)) for i in range(self.mol.GetNumAtoms())]
272
- )
265
+ self.main_window.view_3d_manager.atom_positions_3d = new_positions
273
266
 
274
267
  # Update 3D visualization
275
268
  self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
@@ -942,7 +942,7 @@ class IOManager:
942
942
  symbol = self.host.view_3d_manager.current_mol.GetAtomWithIdx(
943
943
  i
944
944
  ).GetSymbol()
945
- xyz_lines.append(f"{symbol} {pos.x:.6f} {pos.y:.6f} {pos.z:.6f}")
945
+ xyz_lines.append(f" {symbol:<11}{pos.x:11.8f} {pos.y:11.8f} {pos.z:11.8f}")
946
946
 
947
947
  with open(file_path, "w", encoding="utf-8") as f:
948
948
  f.write("\n".join(xyz_lines) + "\n")
@@ -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