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.
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/PKG-INFO +1 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/pyproject.toml +1 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/molecular_data.py +10 -7
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager.py +89 -56
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/base_picking_dialog.py +32 -10
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/calculation_worker.py +2 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/compute_logic.py +17 -5
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +93 -14
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/edit_3d_logic.py +40 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/edit_actions_logic.py +9 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/geometry_base_dialog.py +2 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/io_logic.py +48 -6
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/main_window.py +4 -2
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/mirror_dialog.py +9 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/molecular_scene_handler.py +2 -2
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/molecule_scene.py +60 -1
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/ui_manager.py +2 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/LICENSE +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/README.md +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/setup.cfg +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/__init__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/__main__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/file_icon.ico +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.icns +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.ico +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/assets/icon.png +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/__init__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/mol_geometry.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/stereo_check.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/main.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/__init__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/__init__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/about_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/analysis_window.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/app_state.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/atom_item.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/atom_picking.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/bond_item.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/chirality_warning_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/export_logic.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/main_window_init.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_dialog_mixin.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/sdf_record_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_labels_tab.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/string_importers.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/__init__.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/constants.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/default_settings.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/finite_float.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/hit_radius.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/label_style.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/sdf_records.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
- {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/utils/suppress_log.py +0 -0
- {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.
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy-linux
|
|
3
|
-
Version: 4.11.
|
|
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
|
{moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/core/molecular_data.py
RENAMED
|
@@ -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
|
-
#
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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.
|
{moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/plugins/plugin_manager.py
RENAMED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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]]:
|
{moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/base_picking_dialog.py
RENAMED
|
@@ -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
|
-
|
|
169
|
+
self.show_atom_labels()
|
|
145
170
|
elif hasattr(self, "update_display"):
|
|
146
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
{moleditpy_linux-4.11.2 → moleditpy_linux-4.11.4}/src/moleditpy_linux/ui/calculation_worker.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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__(
|
|
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
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
542
|
+
optimization_mol, mmffVariant=ff_name
|
|
517
543
|
)
|
|
518
544
|
ff = rdForceFieldHelpers.MMFFGetMoleculeForceField(
|
|
519
|
-
|
|
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
|
-
|
|
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(
|
|
596
|
-
|
|
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:
|