MoleditPy-linux 4.11.2__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.2 → moleditpy_linux-4.11.4}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/molecular_data.py +10 -7
  5. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager.py +89 -56
  6. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/base_picking_dialog.py +32 -10
  7. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/calculation_worker.py +2 -1
  8. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/compute_logic.py +17 -5
  9. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +93 -14
  10. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/edit_3d_logic.py +40 -1
  11. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/edit_actions_logic.py +9 -1
  12. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/geometry_base_dialog.py +2 -0
  13. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/io_logic.py +48 -6
  14. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/main_window.py +4 -2
  15. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/mirror_dialog.py +9 -0
  16. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/molecular_scene_handler.py +2 -2
  17. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/molecule_scene.py +60 -1
  18. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/ui_manager.py +2 -0
  19. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/LICENSE +0 -0
  20. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/README.md +0 -0
  21. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/setup.cfg +0 -0
  22. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  23. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  24. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  25. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  26. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  27. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/__init__.py +0 -0
  28. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/__main__.py +0 -0
  29. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  30. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.icns +0 -0
  31. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.ico +0 -0
  32. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.png +0 -0
  33. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/__init__.py +0 -0
  34. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  35. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/stereo_check.py +0 -0
  36. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/main.py +0 -0
  37. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/__init__.py +0 -0
  38. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  39. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  40. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/__init__.py +0 -0
  41. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  42. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  43. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  44. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  45. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  46. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/app_state.py +0 -0
  47. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/atom_item.py +0 -0
  48. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  49. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/bond_item.py +0 -0
  50. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  51. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  52. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/chirality_warning_dialog.py +0 -0
  53. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  54. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  55. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  56. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  57. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  58. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  59. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/export_logic.py +0 -0
  60. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  61. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_dialog_mixin.py +0 -0
  62. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  63. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  64. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  66. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  67. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
  68. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/sdf_record_dialog.py +0 -0
  69. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  70. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  71. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
  72. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  73. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  74. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_labels_tab.py +0 -0
  75. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  76. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  77. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/string_importers.py +0 -0
  78. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  79. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  80. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  81. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  82. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  83. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  84. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/__init__.py +0 -0
  85. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/constants.py +0 -0
  86. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/default_settings.py +0 -0
  87. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/finite_float.py +0 -0
  88. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  89. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/label_style.py +0 -0
  90. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/sdf_records.py +0 -0
  91. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  92. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  93. {moleditpy_linux-4.11.2 → 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.2
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.2"
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.2
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
@@ -438,13 +438,16 @@ class MolecularData:
438
438
  bond.SetStereo(Chem.BondStereo.STEREOE)
439
439
  ez_wanted[bond_idx] = "Z" if stereo_type == 3 else "E"
440
440
 
441
- # Clear BondDir (wedge/dash) of adjacent single bonds assigned via coordinates to avoid label conflicts
442
- b1 = final_mol.GetBondBetweenAtoms(begin_atom_idx, neigh1_idx)
443
- b2 = final_mol.GetBondBetweenAtoms(end_atom_idx, neigh2_idx)
444
- if b1 is not None:
445
- b1.SetBondDir(Chem.BondDir.NONE)
446
- if b2 is not None:
447
- b2.SetBondDir(Chem.BondDir.NONE)
441
+ # Preserve explicit wedge/dash labels, including those applied later.
442
+ for center_idx, neighbor_idx in (
443
+ (begin_atom_idx, neigh1_idx),
444
+ (end_atom_idx, neigh2_idx),
445
+ ):
446
+ adjacent = final_mol.GetBondBetweenAtoms(center_idx, neighbor_idx)
447
+ if adjacent is not None and bond_stereo_info.get(
448
+ adjacent.GetIdx(), {}
449
+ ).get("type") not in (1, 2):
450
+ adjacent.SetBondDir(Chem.BondDir.NONE)
448
451
 
449
452
  # The labels are CIP E/Z; the neighbors picked above are not always
450
453
  # the CIP-higher ones.
@@ -16,8 +16,10 @@ import hashlib
16
16
  import importlib.util
17
17
  import json
18
18
  import logging
19
+ import ntpath
19
20
  import os
20
21
  import shutil
22
+ import stat
21
23
  import sys
22
24
  import zipfile
23
25
  from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -164,6 +166,88 @@ class PluginManager:
164
166
  self.ensure_plugin_dir()
165
167
  QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
166
168
 
169
+ @staticmethod
170
+ def _safe_install_path(root: str, name: str) -> str:
171
+ """Resolve a portable relative path strictly within an install directory."""
172
+ normalized = name.replace("\\", "/").rstrip("/")
173
+ parts = normalized.split("/")
174
+ reserved = {"CON", "PRN", "AUX", "NUL"} | {
175
+ f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
176
+ }
177
+ if (
178
+ not normalized
179
+ or normalized.startswith("/")
180
+ or ntpath.splitdrive(normalized)[0]
181
+ or any(
182
+ part in ("", ".", "..")
183
+ or part.endswith((".", " "))
184
+ or any(char in part for char in '<>:"|?*\0')
185
+ or part.split(".")[0].upper() in reserved
186
+ for part in parts
187
+ )
188
+ ):
189
+ raise ValueError(f"Unsafe plugin path: {name!r}")
190
+ destination = os.path.abspath(os.path.join(root, *parts))
191
+ resolved_root = os.path.normcase(os.path.realpath(root))
192
+ resolved_destination = os.path.normcase(os.path.realpath(destination))
193
+ if (
194
+ resolved_destination == resolved_root
195
+ or os.path.commonpath([resolved_root, resolved_destination])
196
+ != resolved_root
197
+ ):
198
+ raise ValueError(f"Plugin path escapes install directory: {name!r}")
199
+ return destination
200
+
201
+ def _install_zip(self, file_path: str, filename: str) -> str:
202
+ """Validate every archive member before removing or writing plugin files."""
203
+ with zipfile.ZipFile(file_path, "r") as archive:
204
+ members = []
205
+ for member in archive.infolist():
206
+ self._safe_install_path(self.plugin_dir, member.orig_filename)
207
+ if stat.S_ISLNK(member.external_attr >> 16):
208
+ raise ValueError(
209
+ "Plugin ZIP archives cannot contain symbolic links"
210
+ )
211
+ members.append((member, member.filename.replace("\\", "/")))
212
+ if not members:
213
+ raise ValueError("Plugin ZIP archive is empty")
214
+
215
+ roots = {name.split("/")[0] for _, name in members}
216
+ nested = (
217
+ len(roots) == 1
218
+ and any("/" in name for _, name in members)
219
+ and next(iter(roots)) != "__init__.py"
220
+ )
221
+ folder = next(iter(roots)) if nested else os.path.splitext(filename)[0]
222
+ destination = self._safe_install_path(self.plugin_dir, folder)
223
+ relative_members = [
224
+ (member, name.partition("/")[2] if nested else name)
225
+ for member, name in members
226
+ ]
227
+ # Validate against the package boundary as well as the plugin root.
228
+ for _, name in relative_members:
229
+ if name:
230
+ self._safe_install_path(destination, name)
231
+
232
+ if os.path.exists(destination):
233
+ if os.path.isdir(destination):
234
+ shutil.rmtree(destination)
235
+ else:
236
+ os.remove(destination)
237
+ os.makedirs(destination)
238
+ for member, name in relative_members:
239
+ if not name:
240
+ continue
241
+ target = self._safe_install_path(destination, name)
242
+ if name.endswith("/"):
243
+ os.makedirs(target, exist_ok=True)
244
+ else:
245
+ os.makedirs(os.path.dirname(target), exist_ok=True)
246
+ with archive.open(member) as source, open(target, "wb") as output:
247
+ shutil.copyfileobj(source, output)
248
+ kind = "ZIP" if nested else "Flat ZIP"
249
+ return f"Installed package {folder} (from {kind})"
250
+
167
251
  def install_plugin(self, file_path: str) -> Tuple[bool, str]:
168
252
  """Copies a plugin file to the plugin directory. Supports .py and .zip."""
169
253
  self.ensure_plugin_dir()
@@ -174,7 +258,7 @@ class PluginManager:
174
258
 
175
259
  if os.path.isdir(file_path):
176
260
  # Copy entire directory
177
- dest_path = os.path.join(self.plugin_dir, filename)
261
+ dest_path = self._safe_install_path(self.plugin_dir, filename)
178
262
  if os.path.exists(dest_path):
179
263
  # Option 1: Overwrite (remove then copy) - safer for clean install
180
264
  if os.path.isdir(dest_path):
@@ -190,63 +274,10 @@ class PluginManager:
190
274
  )
191
275
  msg = f"Installed package {filename}"
192
276
  elif filename.lower().endswith(".zip"):
193
- # Extract ZIP contents
194
- with zipfile.ZipFile(file_path, "r") as zf:
195
- # Smart Extraction: Check if ZIP has a single top-level folder
196
- # Fix for paths with backslashes on Windows if zip was created on Windows
197
- roots = set()
198
- root_is_dir = False
199
- for name in zf.namelist():
200
- # Normalize path separators to forward slash for consistent check
201
- name = name.replace("\\", "/")
202
- parts = name.split("/")
203
- if parts[0]:
204
- roots.add(parts[0])
205
- # Sub-entries prove the root is a folder
206
- if len(parts) > 1:
207
- root_is_dir = True
208
-
209
- is_nested = len(roots) == 1 and root_is_dir
210
-
211
- if is_nested:
212
- # Case A: ZIP contains a single folder (e.g. MyPlugin/init.py)
213
- top_folder = list(roots)[0]
214
-
215
- # A root named __init__.py always needs a wrapper folder
216
- if top_folder == "__init__.py":
217
- is_nested = False
218
-
219
- if is_nested:
220
- # Case A (Confirmed): Extract directly
221
- dest_path = os.path.join(self.plugin_dir, top_folder)
222
-
223
- # Clean Install: Remove existing folder to prevent stale files
224
- if os.path.exists(dest_path):
225
- if os.path.isdir(dest_path):
226
- shutil.rmtree(dest_path)
227
- else:
228
- os.remove(dest_path)
229
-
230
- zf.extractall(self.plugin_dir)
231
- msg = f"Installed package {top_folder} (from ZIP)"
232
- else:
233
- # Case B: ZIP is flat (e.g. file1.py, file2.py or just __init__.py)
234
- # Extract into a new folder named after the ZIP file
235
- folder_name = os.path.splitext(filename)[0]
236
- dest_path = os.path.join(self.plugin_dir, folder_name)
237
-
238
- if os.path.exists(dest_path):
239
- if os.path.isdir(dest_path):
240
- shutil.rmtree(dest_path)
241
- else:
242
- os.remove(dest_path)
243
-
244
- os.makedirs(dest_path)
245
- zf.extractall(dest_path)
246
- msg = f"Installed package {folder_name} (from Flat ZIP)"
277
+ msg = self._install_zip(file_path, filename)
247
278
  else:
248
279
  # Standard file copy
249
- dest_path = os.path.join(self.plugin_dir, filename)
280
+ dest_path = self._safe_install_path(self.plugin_dir, filename)
250
281
  if os.path.exists(dest_path):
251
282
  if os.path.isdir(dest_path):
252
283
  shutil.rmtree(dest_path)
@@ -265,7 +296,9 @@ class PluginManager:
265
296
  OSError,
266
297
  ImportError,
267
298
  SyntaxError,
299
+ zipfile.BadZipFile,
268
300
  ) as e:
301
+ logging.warning("Plugin installation failed: %s", e, exc_info=True)
269
302
  return False, str(e)
270
303
 
271
304
  def discover_plugins(self, parent: Any = None) -> List[Dict[str, Any]]:
@@ -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
@@ -58,6 +58,7 @@ class ComputeManager:
58
58
  """Initialize ComputeManager with reference to host window."""
59
59
  self._calculating_text_actor: Any = None
60
60
  self.original_atom_properties: Dict[int, int] = {}
61
+ self._worker_atom_properties: Dict[int, Dict[int, int]] = {}
61
62
  self.host = host
62
63
  self.last_successful_optimization_method: Optional[str] = None
63
64
  self._active_calc_threads: List[QThread] = []
@@ -270,6 +271,7 @@ class ComputeManager:
270
271
  self.next_conversion_id = run_id + 1
271
272
  self.active_worker_ids.add(run_id)
272
273
  self._conversion_run_ids.add(run_id)
274
+ self._worker_atom_properties[run_id] = self.original_atom_properties.copy()
273
275
 
274
276
  # The worker only runs built-in force fields. If the selected method is a
275
277
  # plugin optimizer, pre-optimize with MMFF (RDKit) in the worker (which
@@ -336,6 +338,7 @@ class ComputeManager:
336
338
  self.halt_ids.update(wids_to_halt)
337
339
  self.active_worker_ids.clear()
338
340
  self._pending_plugin_opt.clear()
341
+ self._worker_atom_properties.clear()
339
342
  getattr(self, "_conversion_run_ids", set()).clear()
340
343
 
341
344
  self._restore_button_ui()
@@ -394,6 +397,11 @@ class ComputeManager:
394
397
  run_id = int(getattr(self, "next_conversion_id", 1))
395
398
  self.next_conversion_id = run_id + 1
396
399
  options["worker_id"] = run_id
400
+ self._worker_atom_properties[run_id] = {
401
+ atom.GetIdx(): atom.GetIntProp("_original_atom_id")
402
+ for atom in self.host.view_3d_manager.current_mol.GetAtoms()
403
+ if atom.HasProp("_original_atom_id")
404
+ }
397
405
 
398
406
  self.active_worker_ids.add(run_id)
399
407
 
@@ -601,9 +609,14 @@ class ComputeManager:
601
609
  self.host.set_current_molecule(mol)
602
610
  self.host.is_xyz_derived = False
603
611
 
604
- # Restore properties
612
+ # MOL blocks omit custom atom properties; restore the submitting molecule's IDs.
613
+ atom_properties = (
614
+ self._worker_atom_properties.pop(worker_id, {})
615
+ if worker_id is not None
616
+ else self.original_atom_properties
617
+ )
605
618
  if mol:
606
- for i, orig_id in self.original_atom_properties.items():
619
+ for i, orig_id in atom_properties.items():
607
620
  if i < mol.GetNumAtoms():
608
621
  mol.GetAtomWithIdx(i).SetIntProp("_original_atom_id", orig_id)
609
622
 
@@ -713,11 +726,10 @@ class ComputeManager:
713
726
  # Accept either a string or (worker_id, message) tuple from the worker signal
714
727
  if isinstance(message, tuple) and len(message) == 2:
715
728
  worker_id, msg = message
729
+ self._worker_atom_properties.pop(worker_id, None)
716
730
  getattr(self, "_conversion_run_ids", set()).discard(worker_id)
717
731
  if worker_id not in self.active_worker_ids:
718
- # Still cleanup overlay/buttons even if stale
719
- self._remove_calculating_text()
720
- self._restore_button_ui()
732
+ # A stale worker must not reset the current calculation's UI.
721
733
  # If it was a halt or error from a stale worker, show with ID
722
734
  if msg == "Halt" or msg == "Halted":
723
735
  self.host.statusBar().showMessage( # type: ignore[union-attr]
@@ -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: