MoleditPy-linux 4.11.3__tar.gz → 4.11.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 (93) hide show
  1. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/base_picking_dialog.py +32 -10
  5. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/calculation_worker.py +2 -1
  6. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +93 -14
  7. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/edit_3d_logic.py +40 -1
  8. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/geometry_base_dialog.py +2 -0
  9. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/io_logic.py +7 -0
  10. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/main_window.py +4 -2
  11. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/mirror_dialog.py +9 -0
  12. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/molecular_scene_handler.py +2 -2
  13. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/molecule_scene.py +60 -1
  14. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/ui_manager.py +2 -0
  15. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/LICENSE +0 -0
  16. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/README.md +0 -0
  17. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/setup.cfg +0 -0
  18. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  19. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  20. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  21. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  22. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  23. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/__init__.py +0 -0
  24. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/__main__.py +0 -0
  25. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  26. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.icns +0 -0
  27. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.ico +0 -0
  28. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.png +0 -0
  29. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/__init__.py +0 -0
  30. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  31. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/molecular_data.py +0 -0
  32. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/stereo_check.py +0 -0
  33. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/main.py +0 -0
  34. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/__init__.py +0 -0
  35. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  36. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  37. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  38. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/__init__.py +0 -0
  39. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  40. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  41. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  42. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  43. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  44. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/app_state.py +0 -0
  45. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/atom_item.py +0 -0
  46. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  47. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/bond_item.py +0 -0
  48. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  49. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  50. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/chirality_warning_dialog.py +0 -0
  51. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  52. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  53. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  54. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  55. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  56. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  57. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  58. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  59. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/export_logic.py +0 -0
  60. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  61. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_dialog_mixin.py +0 -0
  62. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  63. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  64. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  66. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  67. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
  68. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/sdf_record_dialog.py +0 -0
  69. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  70. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  71. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
  72. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  73. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  74. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_labels_tab.py +0 -0
  75. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  76. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  77. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/string_importers.py +0 -0
  78. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  79. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  80. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  81. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  82. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  83. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  84. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/__init__.py +0 -0
  85. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/constants.py +0 -0
  86. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/default_settings.py +0 -0
  87. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/finite_float.py +0 -0
  88. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  89. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/label_style.py +0 -0
  90. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/sdf_records.py +0 -0
  91. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  92. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  93. {moleditpy_linux-4.11.3 → moleditpy_linux-4.11.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: 4.11.3
3
+ Version: 4.11.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 = "4.11.3"
8
+ version = "4.11.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: 4.11.3
3
+ Version: 4.11.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
@@ -62,10 +62,21 @@ class BasePickingDialog(Dialog3DPickingMixin, QDialog):
62
62
  Dialog3DPickingMixin.__init__(self)
63
63
  self.mol = mol
64
64
  self.main_window = main_window
65
+ self._invalidated = False
66
+ self._geometry_refresh_timer = QTimer(self)
67
+ self._geometry_refresh_timer.setSingleShot(True)
68
+ self._geometry_refresh_timer.timeout.connect(self._refresh_geometry_display)
65
69
  self._molecule_modified = (
66
70
  False # Track if any modifications were made during this session
67
71
  )
68
72
 
73
+ def invalidate_molecule(self) -> None:
74
+ """Close a dialog bound to a replaced molecule without recording an edit."""
75
+ self._invalidated = True
76
+ self._molecule_modified = False
77
+ self.setEnabled(False)
78
+ self.reject()
79
+
69
80
  def keyPressEvent(self, event: Optional[QKeyEvent]) -> None:
70
81
  """Standard keyboard handler: Enter/Return triggers 'Apply'."""
71
82
  if event is None:
@@ -80,18 +91,21 @@ class BasePickingDialog(Dialog3DPickingMixin, QDialog):
80
91
 
81
92
  def closeEvent(self, event: Optional[QCloseEvent]) -> None:
82
93
  """Cleanup on window close."""
94
+ self._geometry_refresh_timer.stop()
83
95
  self.clear_atom_labels()
84
96
  self.disable_picking()
85
97
  super().closeEvent(event)
86
98
 
87
99
  def reject(self) -> None:
88
100
  """Cleanup on cancel."""
101
+ self._geometry_refresh_timer.stop()
89
102
  self.clear_atom_labels()
90
103
  self.disable_picking()
91
104
  super().reject()
92
105
 
93
106
  def accept(self) -> None:
94
107
  """Cleanup on OK."""
108
+ self._geometry_refresh_timer.stop()
95
109
  self.clear_atom_labels()
96
110
  self.disable_picking()
97
111
  super().accept()
@@ -103,6 +117,8 @@ class BasePickingDialog(Dialog3DPickingMixin, QDialog):
103
117
  Update the molecule's conformer and the 3D position cache, then redraw.
104
118
  :param positions: A numpy array or dictionary of all atom positions.
105
119
  """
120
+ if self._invalidated:
121
+ return
106
122
  conf = self.mol.GetConformer()
107
123
  num_atoms = conf.GetNumAtoms()
108
124
 
@@ -138,24 +154,30 @@ class BasePickingDialog(Dialog3DPickingMixin, QDialog):
138
154
  self._molecule_modified = True
139
155
 
140
156
  # 4. Refresh display (deferred to ensure stability)
157
+ self._geometry_refresh_timer.start(200)
158
+
159
+ # 5. Refresh chiral/cis-trans labels if applicable
160
+ self.main_window.view_3d_manager.update_chiral_labels()
161
+
162
+ def _refresh_geometry_display(self) -> None:
163
+ """Refresh labels and rendering while this dialog still owns its molecule."""
164
+ if self._invalidated:
165
+ return
141
166
  is_dragging = getattr(self, "_slider_dragging", False)
142
167
 
143
168
  if is_dragging and hasattr(self, "show_atom_labels"):
144
- QTimer.singleShot(200, self.show_atom_labels)
169
+ self.show_atom_labels()
145
170
  elif hasattr(self, "update_display"):
146
- QTimer.singleShot(200, self.update_display)
147
-
148
- if (
149
- hasattr(self.main_window.view_3d_manager, "plotter")
150
- and self.main_window.view_3d_manager.plotter
151
- ):
152
- QTimer.singleShot(200, self.main_window.view_3d_manager.plotter.render)
171
+ self.update_display()
153
172
 
154
- # 5. Refresh chiral/cis-trans labels if applicable
155
- self.main_window.view_3d_manager.update_chiral_labels()
173
+ plotter = self.main_window.view_3d_manager.plotter
174
+ if plotter is not None:
175
+ plotter.render()
156
176
 
157
177
  def _push_undo(self) -> None:
158
178
  """Centralized undo logic to push current state to the undo stack."""
179
+ if self._invalidated:
180
+ return
159
181
  self.main_window.edit_actions_manager.push_undo_state()
160
182
  self._molecule_modified = False
161
183
 
@@ -1016,7 +1016,8 @@ class CalculationWorker(QObject):
1016
1016
  t = 3.0 if s == Chem.BondStereo.STEREOZ else 5.0
1017
1017
  bm[satoms[0]][satoms[1]] = bm[satoms[1]][satoms[0]] = t
1018
1018
  DoTriangleSmoothing(bm)
1019
- conf_id = rdDistGeom.EmbedMolecule(mol, bm, params)
1019
+ params.SetBoundsMat(bm)
1020
+ conf_id = rdDistGeom.EmbedMolecule(mol, params)
1020
1021
 
1021
1022
  if conf_id == -1:
1022
1023
  return False
@@ -11,9 +11,10 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import logging
14
- from typing import Any
14
+ from typing import Any, Optional
15
15
 
16
- from PyQt6.QtCore import Qt, QThread, pyqtSignal
16
+ import numpy as np
17
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, pyqtSlot
17
18
  from PyQt6.QtWidgets import (
18
19
  QAbstractItemView,
19
20
  QComboBox,
@@ -28,6 +29,7 @@ from PyQt6.QtWidgets import (
28
29
  QTableWidgetItem,
29
30
  QVBoxLayout,
30
31
  )
32
+ from rdkit import Chem
31
33
  from rdkit.Chem import rdForceFieldHelpers, rdMolTransforms
32
34
 
33
35
  from .dialog_3d_picking_mixin import Dialog3DPickingMixin
@@ -40,18 +42,32 @@ class ConstrainedOptimizationThread(QThread):
40
42
  optimization_finished = pyqtSignal()
41
43
  error_occurred = pyqtSignal(str)
42
44
 
43
- def __init__(self, ff: Any, max_iters: int = 20000, parent: Any = None) -> None:
45
+ def __init__(
46
+ self,
47
+ ff: Any,
48
+ max_iters: int = 20000,
49
+ parent: Any = None,
50
+ mol: Optional[Chem.Mol] = None,
51
+ ) -> None:
44
52
  """Initialize constrained optimization worker thread."""
45
53
  super().__init__(parent)
46
54
  self.ff = ff
47
55
  self.max_iters = max_iters
56
+ # Keep the private conformer backing the force field alive during minimization.
57
+ self.mol = mol
48
58
 
49
59
  def run(self) -> None:
50
60
  """Execute force-field minimization and emit finished or error signal."""
51
61
  try:
52
- self.ff.Minimize(maxIts=self.max_iters)
53
- self.optimization_finished.emit()
62
+ for offset in range(0, self.max_iters, 200):
63
+ if self.isInterruptionRequested():
64
+ return
65
+ if self.ff.Minimize(maxIts=min(200, self.max_iters - offset)) == 0:
66
+ break
67
+ if not self.isInterruptionRequested():
68
+ self.optimization_finished.emit()
54
69
  except Exception as e:
70
+ logging.exception("Constrained minimization failed")
55
71
  self.error_occurred.emit(str(e))
56
72
 
57
73
 
@@ -62,14 +78,17 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
62
78
  """Initialize constrained force field optimization dialog."""
63
79
  QDialog.__init__(self, parent)
64
80
  Dialog3DPickingMixin.__init__(self)
65
- self.mol = mol
81
+ self.mol: Chem.Mol = mol
66
82
  self.main_window = main_window
67
83
  self.selected_atoms: list[int] = [] # Using a list because order matters
68
84
  self.constraints: list[Any] = [] # (type, atoms_indices, value)
69
85
  self.constraint_labels: list[Any] = [] # 3D label actors
70
- self._opt_thread: Any = None
86
+ self._opt_thread: Optional[ConstrainedOptimizationThread] = None
71
87
  # Closed dialogs must discard late optimization-thread results
72
88
  self._closed = False
89
+ self._invalidated = False
90
+ self._optimization_baseline: Optional[np.ndarray] = None
91
+ self._optimization_method = ""
73
92
  self.init_ui()
74
93
  self.enable_picking()
75
94
 
@@ -500,12 +519,19 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
500
519
 
501
520
  def apply_optimization(self) -> None:
502
521
  """Run force-field minimization with the configured constraints."""
522
+ if self._closed:
523
+ return
524
+ if self._opt_thread is not None:
525
+ if self._opt_thread.isRunning():
526
+ return
527
+ self._opt_thread = None
503
528
  if not self.mol or self.mol.GetNumConformers() == 0:
504
529
  QMessageBox.warning(self, "Error", "No valid 3D molecule found.")
505
530
  return
506
531
 
507
532
  ff_name = self.ff_combo.currentText()
508
- conf = self.mol.GetConformer()
533
+ optimization_mol = Chem.Mol(self.mol)
534
+ self._optimization_baseline = self.mol.GetConformer().GetPositions().copy()
509
535
 
510
536
  try:
511
537
  ignore_interfrag = not self.main_window.init_manager.settings.get(
@@ -513,10 +539,10 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
513
539
  )
514
540
  if ff_name.startswith("MMFF"):
515
541
  props = rdForceFieldHelpers.MMFFGetMoleculeProperties(
516
- self.mol, mmffVariant=ff_name
542
+ optimization_mol, mmffVariant=ff_name
517
543
  )
518
544
  ff = rdForceFieldHelpers.MMFFGetMoleculeForceField(
519
- self.mol,
545
+ optimization_mol,
520
546
  props,
521
547
  confId=0,
522
548
  ignoreInterfragInteractions=ignore_interfrag,
@@ -526,7 +552,9 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
526
552
  add_torsion_constraint = ff.MMFFAddTorsionConstraint
527
553
  else: # UFF
528
554
  ff = rdForceFieldHelpers.UFFGetMoleculeForceField(
529
- self.mol, confId=0, ignoreInterfragInteractions=ignore_interfrag
555
+ optimization_mol,
556
+ confId=0,
557
+ ignoreInterfragInteractions=ignore_interfrag,
530
558
  )
531
559
  add_dist_constraint = ff.UFFAddDistanceConstraint
532
560
  add_angle_constraint = ff.UFFAddAngleConstraint
@@ -592,17 +620,38 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
592
620
 
593
621
  self.optimize_button.setEnabled(False)
594
622
 
595
- self._opt_thread = ConstrainedOptimizationThread(ff, 20000, self)
596
- self._opt_thread.optimization_finished.connect(
597
- lambda: self._on_optimization_finished(ff_name, conf)
623
+ self._opt_thread = ConstrainedOptimizationThread(
624
+ ff, 20000, mol=optimization_mol
598
625
  )
626
+ self._optimization_method = ff_name
627
+ self._opt_thread.optimization_finished.connect(self._on_worker_result)
599
628
  self._opt_thread.error_occurred.connect(self._on_optimization_error)
629
+ self._opt_thread.finished.connect(self._on_worker_finished)
630
+ self.main_window.edit_3d_manager.register_optimization_thread(
631
+ self._opt_thread
632
+ )
600
633
  self._opt_thread.start()
601
634
 
602
635
  except Exception as e:
603
636
  logging.exception("Failed to start optimization: %s", e)
604
637
  self.optimize_button.setEnabled(True)
605
638
 
639
+ @pyqtSlot()
640
+ def _on_worker_result(self) -> None:
641
+ """Receive a private conformer on the GUI thread while the dialog exists."""
642
+ thread = self._opt_thread
643
+ if thread is None or self.sender() is not thread or thread.mol is None:
644
+ return
645
+ self._on_optimization_finished(
646
+ self._optimization_method, thread.mol.GetConformer()
647
+ )
648
+
649
+ @pyqtSlot()
650
+ def _on_worker_finished(self) -> None:
651
+ """Drop the completed worker reference before its owner deletes it."""
652
+ if self.sender() is self._opt_thread:
653
+ self._opt_thread = None
654
+
606
655
  def _on_optimization_error(self, err_msg: str) -> None:
607
656
  """Handle optimization thread failure callback."""
608
657
  if self._closed:
@@ -622,7 +671,24 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
622
671
  logging.info("Discarding constrained optimization result after close.")
623
672
  return
624
673
  self.optimize_button.setEnabled(True)
674
+ current_mol = self.main_window.view_3d_manager.current_mol
675
+ if current_mol is None or current_mol is not self.mol:
676
+ logging.info("Discarding constrained optimization for a replaced molecule.")
677
+ return
678
+ if self._optimization_baseline is not None and not np.array_equal(
679
+ self.mol.GetConformer().GetPositions(), self._optimization_baseline
680
+ ):
681
+ status_bar = self.main_window.statusBar()
682
+ if status_bar is not None:
683
+ status_bar.showMessage(
684
+ "Optimization discarded: molecule geometry changed while running."
685
+ )
686
+ return
625
687
  try:
688
+ live_conf = self.mol.GetConformer()
689
+ for i in range(self.mol.GetNumAtoms()):
690
+ pos = conf.GetAtomPosition(i)
691
+ live_conf.SetAtomPosition(i, (float(pos.x), float(pos.y), float(pos.z)))
626
692
  # Apply optimized coordinates to the main window's numpy array
627
693
  cache = self.main_window.view_3d_manager.atom_positions_3d
628
694
  for i in range(self.mol.GetNumAtoms()):
@@ -670,6 +736,8 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
670
736
  self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
671
737
  self.main_window.view_3d_manager.update_chiral_labels()
672
738
  self.main_window.edit_actions_manager.push_undo_state()
739
+ self.main_window.state_manager.has_unsaved_changes = True
740
+ self.main_window.state_manager.update_window_title()
673
741
  status_bar = self.main_window.statusBar()
674
742
  if status_bar is not None:
675
743
  status_bar.showMessage("Constrained optimization finished.")
@@ -687,6 +755,12 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
687
755
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
688
756
  logging.exception("Optimization failed: %s", e)
689
757
 
758
+ def invalidate_molecule(self) -> None:
759
+ """Discard pending work and constraints when the document is replaced."""
760
+ self._invalidated = True
761
+ self.setEnabled(False)
762
+ self.reject()
763
+
690
764
  def closeEvent(self, event: Any) -> None:
691
765
  """Delegate window close to reject."""
692
766
  self.reject()
@@ -695,9 +769,14 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
695
769
  def reject(self) -> None:
696
770
  """Clear labels, disable picking, and save constraints before closing."""
697
771
  self._closed = True
772
+ if self._opt_thread is not None:
773
+ self._opt_thread.requestInterruption()
698
774
  self.clear_constraint_labels()
699
775
  self.clear_selection_labels()
700
776
  self.disable_picking()
777
+ if self._invalidated:
778
+ super().reject()
779
+ return
701
780
 
702
781
  # Save constraints list to MainWindow when closing the dialog
703
782
  try:
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional
20
20
 
21
21
  import numpy as np
22
22
  import pyvista as pv
23
- from PyQt6.QtCore import QPointF
23
+ from PyQt6.QtCore import QPointF, QThread, QTimer
24
24
  from PyQt6.QtGui import QColor, QFont
25
25
  from PyQt6.QtWidgets import QGraphicsTextItem
26
26
 
@@ -53,6 +53,38 @@ class Edit3DManager:
53
53
  self.is_3d_edit_mode = False
54
54
  self.dragged_atom_info = None
55
55
  self.constraints_3d: list[Any] = []
56
+ self._optimization_threads: set[QThread] = set()
57
+ self._close_after_optimization = False
58
+
59
+ def register_optimization_thread(self, thread: QThread) -> None:
60
+ """Own minimization workers independently of their modeless dialogs."""
61
+ thread.setParent(self.host)
62
+ self._optimization_threads.add(thread)
63
+ thread.finished.connect(lambda: self._release_optimization_thread(thread))
64
+
65
+ def _release_optimization_thread(self, thread: QThread) -> None:
66
+ """Delete completed workers and resume a deferred application close."""
67
+ self._optimization_threads.discard(thread)
68
+ thread.deleteLater()
69
+ if self._close_after_optimization and not self._optimization_threads:
70
+ self._close_after_optimization = False
71
+ QTimer.singleShot(0, self.host.close)
72
+
73
+ def prepare_optimization_shutdown(self) -> bool:
74
+ """Cancel workers and defer window destruction while minimization exits."""
75
+ running = [
76
+ thread for thread in self._optimization_threads if thread.isRunning()
77
+ ]
78
+ if not running:
79
+ return True
80
+ self._close_after_optimization = True
81
+ self.close_all_3d_edit_dialogs()
82
+ for thread in running:
83
+ thread.requestInterruption()
84
+ self.host.update_status_message(
85
+ "Canceling constrained optimization before closing..."
86
+ )
87
+ return False
56
88
 
57
89
  def _label_kwargs(self, kind: str) -> Dict[str, Any]:
58
90
  """add_point_labels style arguments for one label kind, from settings."""
@@ -93,6 +125,13 @@ class Edit3DManager:
93
125
  else:
94
126
  self.host.statusBar().showMessage("Measurement mode disabled.")
95
127
 
128
+ def invalidate_molecule_dialogs(self) -> None:
129
+ """Discard molecule-bound dialog sessions before replacing the document."""
130
+ for dialog in self.active_3d_dialogs.copy():
131
+ if not sip_isdeleted_safe(dialog):
132
+ dialog.invalidate_molecule()
133
+ self.active_3d_dialogs.clear()
134
+
96
135
  def close_all_3d_edit_dialogs(self) -> None:
97
136
  """Close all active 3D edit dialogs."""
98
137
  dialogs_to_close = self.active_3d_dialogs.copy()
@@ -118,6 +118,8 @@ class GeometryBaseDialog(BasePickingDialog):
118
118
  def on_slider_released(self) -> None:
119
119
  """Finalize a slider drag operation."""
120
120
  self._slider_dragging = False
121
+ if self._invalidated:
122
+ return
121
123
  # Snapshot is usually kept until selection changes to preserve turn direction,
122
124
  # but subclasses can override this behavior if needed.
123
125
  self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
@@ -466,6 +466,8 @@ class IOManager:
466
466
  self, xyz_text: str, source_name: str = "XYZ data"
467
467
  ) -> Optional[Any]:
468
468
  """Load XYZ text, set it as the current molecule, and draw it in 3D."""
469
+ if not self.host.state_manager.check_unsaved_changes():
470
+ return None
469
471
  try:
470
472
  mol = self.load_xyz_block(xyz_text)
471
473
  if mol is None:
@@ -490,6 +492,7 @@ class IOManager:
490
492
  self.host.statusBar().showMessage(
491
493
  f"3D Viewer Mode: Loaded {source_name}"
492
494
  )
495
+ self.host.edit_actions_manager.reset_history()
493
496
  self.host.set_has_unsaved_changes(False)
494
497
  self.host.state_manager.update_window_title()
495
498
  return mol
@@ -1055,6 +1058,8 @@ class IOManager:
1055
1058
 
1056
1059
  def load_xyz_for_3d_viewing(self, file_path: Optional[str] = None) -> None:
1057
1060
  """Load XYZ file and display in 3D viewer."""
1061
+ if not self.host.state_manager.check_unsaved_changes():
1062
+ return
1058
1063
  if not file_path:
1059
1064
  default_dir = (
1060
1065
  os.path.dirname(self.host.init_manager.current_file_path)
@@ -1113,6 +1118,7 @@ class IOManager:
1113
1118
  )
1114
1119
  self.host.statusBar().showMessage(message)
1115
1120
  self.host.set_current_file_path(file_path)
1121
+ self.host.edit_actions_manager.reset_history()
1116
1122
  self.host.set_has_unsaved_changes(False)
1117
1123
  self.host.state_manager.update_window_title()
1118
1124
  except (OSError, IOError, ValueError, RuntimeError, AttributeError) as e:
@@ -1160,6 +1166,7 @@ class IOManager:
1160
1166
  self.host.view_3d_manager.update_atom_id_menu_state()
1161
1167
  self.host.update_status_message(f"Loaded {file_path} in 3D viewer")
1162
1168
  self.host.set_current_file_path(file_path)
1169
+ self.host.edit_actions_manager.reset_history()
1163
1170
  self.host.set_has_unsaved_changes(False)
1164
1171
  self.host.state_manager.update_window_title()
1165
1172
  except SdfSelectionCancelled:
@@ -133,6 +133,8 @@ class MainWindow(QMainWindow):
133
133
 
134
134
  def set_current_molecule(self, mol: Optional[Chem.Mol]) -> None:
135
135
  """Set the current 3D molecule and trigger rendering updates."""
136
+ if mol is not self.view_3d_manager.current_mol:
137
+ self.edit_3d_manager.invalidate_molecule_dialogs()
136
138
  self.view_3d_manager.current_mol = mol
137
139
 
138
140
  def set_3d_atom_positions(self, positions: Any) -> None:
@@ -141,7 +143,7 @@ class MainWindow(QMainWindow):
141
143
 
142
144
  def clear_3d_view(self) -> None:
143
145
  """Clear 3D molecule state and plotter rendering."""
144
- self.view_3d_manager.current_mol = None
146
+ self.set_current_molecule(None)
145
147
  if self.view_3d_manager.plotter:
146
148
  self.view_3d_manager.plotter.clear()
147
149
 
@@ -292,7 +294,7 @@ class MainWindow(QMainWindow):
292
294
  @current_mol.setter
293
295
  def current_mol(self, value: Any) -> None:
294
296
  """Proxy for current 3D molecule (read/write)."""
295
- self.view_3d_manager.current_mol = value
297
+ self.set_current_molecule(value)
296
298
 
297
299
  @property
298
300
  def plotter(self) -> Optional[CustomQtInteractor]:
@@ -37,6 +37,7 @@ class MirrorDialog(QDialog):
37
37
  super().__init__(parent)
38
38
  self.mol = mol
39
39
  self.main_window = main_window
40
+ self._invalidated = False
40
41
  self.plane_group: Optional[QButtonGroup] = None
41
42
  self.xy_radio: Optional[QRadioButton] = None
42
43
  self.xz_radio: Optional[QRadioButton] = None
@@ -87,8 +88,16 @@ class MirrorDialog(QDialog):
87
88
 
88
89
  layout.addLayout(button_layout)
89
90
 
91
+ def invalidate_molecule(self) -> None:
92
+ """Close the mirror tool when its molecule is replaced."""
93
+ self._invalidated = True
94
+ self.setEnabled(False)
95
+ self.reject()
96
+
90
97
  def apply_mirror(self) -> None:
91
98
  """Apply mirror transformation across the selected plane."""
99
+ if self._invalidated:
100
+ return
92
101
  if not self.mol or self.mol.GetNumConformers() == 0:
93
102
  QMessageBox.warning(self, "Error", "No 3D coordinates available.")
94
103
  return
@@ -445,10 +445,10 @@ class TemplateMixin:
445
445
  # policy, so an unchanged order means keep this bond
446
446
  if order == exist_b.order:
447
447
  continue
448
+ key = (id1, id2) if (id1, id2) in self.data.bonds else (id2, id1)
449
+ self.data.bonds[key].update(order=order, stereo=0)
448
450
  exist_b.order = order
449
451
  exist_b.stereo = 0
450
- self.data.bonds[(id1, id2)]["order"] = order
451
- self.data.bonds[(id1, id2)]["stereo"] = 0
452
452
  exist_b.update()
453
453
  else:
454
454
  # Create new bond
@@ -12,7 +12,8 @@ DOI: 10.5281/zenodo.17268532
12
12
 
13
13
  from __future__ import annotations
14
14
  import logging
15
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
15
+ import copy
16
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
16
17
 
17
18
  from PyQt6.QtCore import QEvent, QLineF, Qt, QPointF
18
19
  from PyQt6.QtGui import QPen
@@ -216,6 +217,7 @@ class MoleculeScene(
216
217
  self, raw_atoms: Dict[int, Any], raw_bonds: Dict[Tuple[int, int], Any]
217
218
  ) -> None:
218
219
  """Restore scene items from undo/redo state (dict-of-dicts format)."""
220
+ self.data.adjacency_list = {atom_id: [] for atom_id in raw_atoms}
219
221
  for atom_id, data in raw_atoms.items():
220
222
  raw_pos = tuple(data["pos"])
221
223
  pos_q = QPointF(raw_pos[0], raw_pos[1])
@@ -245,6 +247,8 @@ class MoleculeScene(
245
247
  "order": data.get("order", 1),
246
248
  "stereo": data.get("stereo", 0),
247
249
  }
250
+ self.data.adjacency_list[id1].append(id2)
251
+ self.data.adjacency_list[id2].append(id1)
248
252
  self.bond_items[key_tuple] = bond_item
249
253
  atom1_item.bonds.append(bond_item)
250
254
  atom2_item.bonds.append(bond_item)
@@ -258,6 +262,7 @@ class MoleculeScene(
258
262
  self, atoms_2d: List[Dict[str, Any]], bonds_2d: List[Dict[str, Any]]
259
263
  ) -> None:
260
264
  """Restore scene items from PMEPRJ JSON (list-of-dicts format)."""
265
+ self.data.adjacency_list = {atom["id"]: [] for atom in atoms_2d}
261
266
  for atom_data in atoms_2d:
262
267
  atom_id = atom_data["id"]
263
268
  symbol = atom_data["symbol"]
@@ -290,6 +295,8 @@ class MoleculeScene(
290
295
  "order": bond_order,
291
296
  "stereo": stereo,
292
297
  }
298
+ self.data.adjacency_list[atom1_id].append(atom2_id)
299
+ self.data.adjacency_list[atom2_id].append(atom1_id)
293
300
  self.bond_items[(atom1_id, atom2_id)] = bond_item
294
301
  self.addItem(bond_item)
295
302
 
@@ -297,6 +304,58 @@ class MoleculeScene(
297
304
  atom_item.update_style()
298
305
  self.update_all_items()
299
306
 
307
+ def add_molecule_fragment(
308
+ self,
309
+ points: List[Union[QPointF, Tuple[float, float]]],
310
+ bonds_info: List[Tuple[int, int, int]],
311
+ existing_items: Optional[List[AtomItem]] = None,
312
+ symbol: str = "C",
313
+ ) -> List[Optional[AtomItem]]:
314
+ """Insert a template, restoring model and scene if a Qt/model write fails."""
315
+ atoms = copy.deepcopy(self.data.atoms)
316
+ bonds = copy.deepcopy(self.data.bonds)
317
+ next_atom_id = self.data.next_atom_id
318
+ selected_ids = {i for i, item in self.atom_items.items() if item.isSelected()}
319
+ try:
320
+ return TemplateMixin.add_molecule_fragment(
321
+ self, points, bonds_info, existing_items, symbol
322
+ )
323
+ except (RuntimeError, ValueError, TypeError, KeyError, IndexError):
324
+ logging.exception("Template insertion failed; restoring the previous scene")
325
+ self.clear()
326
+ self.data.atoms.clear()
327
+ self.data.bonds.clear()
328
+ self.data.next_atom_id = next_atom_id
329
+ self.reinitialize_items()
330
+ self.restore_atoms_and_bonds(atoms, bonds)
331
+ for atom_id in selected_ids:
332
+ self.atom_items[atom_id].setSelected(True)
333
+ raise
334
+
335
+ def clear(self) -> None:
336
+ """Release item and interaction references before Qt deletes the scene."""
337
+ self.atom_items.clear()
338
+ self.bond_items.clear()
339
+ self._deleted_items.clear()
340
+ self.initial_positions_in_event.clear()
341
+ self.template_context.clear()
342
+ self.template_preview = None
343
+ self.template_preview_points = []
344
+ self.start_atom = None
345
+ self.temp_line = None
346
+ self.hovered_item = None
347
+ self.start_pos = None
348
+ self.press_pos = None
349
+ self.chain_start_atom = None
350
+ self.chain_end_atom = None
351
+ self.chain_anchor = None
352
+ self.chain_points = []
353
+ self.chain_active = False
354
+ self.data_changed_in_event = False
355
+ self.mouse_moved_since_press = False
356
+ self.was_selected_on_press = False
357
+ super().clear()
358
+
300
359
  def reinitialize_items(self) -> None:
301
360
  """Reset transient scene state including template preview and deleted-item list."""
302
361
  self.template_preview = TemplatePreviewItem()
@@ -219,6 +219,8 @@ class UIManager(QObject):
219
219
  Handle application close logic.
220
220
  Returns True if close should proceed, False if it should be cancelled.
221
221
  """
222
+ if not self.host.edit_3d_manager.prepare_optimization_shutdown():
223
+ return False
222
224
  # 1. Persist settings
223
225
  try:
224
226
  modified = self.host.init_manager.settings_dirty or (