MoleditPy-linux 4.4.1__py3-none-any.whl → 4.5.1__py3-none-any.whl
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/core/molecular_data.py +111 -34
- moleditpy_linux/main.py +2 -2
- moleditpy_linux/plugins/plugin_interface.py +23 -1
- moleditpy_linux/plugins/plugin_manager.py +50 -1
- moleditpy_linux/ui/angle_dialog.py +22 -0
- moleditpy_linux/ui/atom_item.py +2 -0
- moleditpy_linux/ui/bond_length_dialog.py +47 -1
- moleditpy_linux/ui/compute_logic.py +25 -0
- moleditpy_linux/ui/custom_interactor_style.py +570 -183
- moleditpy_linux/ui/dihedral_dialog.py +23 -0
- moleditpy_linux/ui/edit_actions_logic.py +17 -22
- moleditpy_linux/ui/io_logic.py +31 -18
- moleditpy_linux/ui/move_group_dialog.py +5 -5
- moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +28 -0
- moleditpy_linux/ui/settings_tabs/settings_other_tab.py +2 -2
- moleditpy_linux/ui/string_importers.py +1 -0
- moleditpy_linux/utils/constants.py +3 -0
- moleditpy_linux/utils/default_settings.py +2 -0
- {moleditpy_linux-4.4.1.dist-info → moleditpy_linux-4.5.1.dist-info}/METADATA +1 -1
- {moleditpy_linux-4.4.1.dist-info → moleditpy_linux-4.5.1.dist-info}/RECORD +24 -24
- {moleditpy_linux-4.4.1.dist-info → moleditpy_linux-4.5.1.dist-info}/WHEEL +0 -0
- {moleditpy_linux-4.4.1.dist-info → moleditpy_linux-4.5.1.dist-info}/entry_points.txt +0 -0
- {moleditpy_linux-4.4.1.dist-info → moleditpy_linux-4.5.1.dist-info}/licenses/LICENSE +0 -0
- {moleditpy_linux-4.4.1.dist-info → moleditpy_linux-4.5.1.dist-info}/top_level.txt +0 -0
|
@@ -30,6 +30,93 @@ class PointTuple(tuple):
|
|
|
30
30
|
return float(self[1])
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _mdl_radical_code(radical_electrons: int) -> int:
|
|
34
|
+
"""Map a count of unpaired electrons to an MDL radical code."""
|
|
35
|
+
# MDL encodes 1 = singlet, 2 = doublet, 3 = triplet.
|
|
36
|
+
if radical_electrons <= 0:
|
|
37
|
+
return 0
|
|
38
|
+
return 2 if radical_electrons == 1 else 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mdl_property_lines(atom_records: List[Dict[str, Any]]) -> List[str]:
|
|
42
|
+
"""Build the M CHG / M RAD property lines for a V2000 block."""
|
|
43
|
+
lines = []
|
|
44
|
+
for tag, key, encode in (
|
|
45
|
+
("CHG", "charge", int),
|
|
46
|
+
("RAD", "radical", _mdl_radical_code),
|
|
47
|
+
):
|
|
48
|
+
entries = [
|
|
49
|
+
(idx, encode(rec[key]))
|
|
50
|
+
for idx, rec in enumerate(atom_records, start=1)
|
|
51
|
+
if rec[key]
|
|
52
|
+
]
|
|
53
|
+
# The MDL property block allows at most 8 entries per line.
|
|
54
|
+
for start in range(0, len(entries), 8):
|
|
55
|
+
chunk = entries[start : start + 8]
|
|
56
|
+
lines.append(
|
|
57
|
+
f"M {tag}{len(chunk):3d}"
|
|
58
|
+
+ "".join(f"{idx:4d}{val:4d}" for idx, val in chunk)
|
|
59
|
+
)
|
|
60
|
+
return lines
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _to_v2000_block(
|
|
64
|
+
atom_records: List[Dict[str, Any]], bond_records: List[Dict[str, Any]]
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Render collected atom/bond records as an MDL V2000 MOL block."""
|
|
67
|
+
lines = ["", " MoleditPy", ""]
|
|
68
|
+
lines.append(
|
|
69
|
+
f"{len(atom_records):3d}{len(bond_records):3d} 0 0 0 0 0 0 0 0999 V2000"
|
|
70
|
+
)
|
|
71
|
+
for rec in atom_records:
|
|
72
|
+
# Charge and radical travel in the M CHG / M RAD blocks below, so every
|
|
73
|
+
# per-atom field here stays zero.
|
|
74
|
+
lines.append(
|
|
75
|
+
f"{rec['x']:10.4f}{rec['y']:10.4f}{0.0:10.4f} {rec['symbol']:<3}"
|
|
76
|
+
" 0 0 0 0 0 0 0 0 0 0 0 0"
|
|
77
|
+
)
|
|
78
|
+
for rec in bond_records:
|
|
79
|
+
lines.append(
|
|
80
|
+
f"{rec['a1']:3d}{rec['a2']:3d}{rec['order']:3d}{rec['stereo']:3d} 0 0 0"
|
|
81
|
+
)
|
|
82
|
+
lines.extend(_mdl_property_lines(atom_records))
|
|
83
|
+
lines.append("M END")
|
|
84
|
+
return "\n".join(lines) + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _to_v3000_block(
|
|
88
|
+
atom_records: List[Dict[str, Any]], bond_records: List[Dict[str, Any]]
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Render collected atom/bond records as an MDL V3000 MOL block."""
|
|
91
|
+
lines = ["", " MoleditPy", "", " 0 0 0 0 0 0 0 0 0 0999 V3000"]
|
|
92
|
+
lines.append("M V30 BEGIN CTAB")
|
|
93
|
+
lines.append(f"M V30 COUNTS {len(atom_records)} {len(bond_records)} 0 0 0")
|
|
94
|
+
lines.append("M V30 BEGIN ATOM")
|
|
95
|
+
for idx, rec in enumerate(atom_records, start=1):
|
|
96
|
+
extra = ""
|
|
97
|
+
if rec["charge"]:
|
|
98
|
+
extra += f" CHG={int(rec['charge'])}"
|
|
99
|
+
if rec["radical"]:
|
|
100
|
+
extra += f" RAD={_mdl_radical_code(rec['radical'])}"
|
|
101
|
+
lines.append(
|
|
102
|
+
f"M V30 {idx} {rec['symbol']} "
|
|
103
|
+
f"{rec['x']:.4f} {rec['y']:.4f} 0.0000 0{extra}"
|
|
104
|
+
)
|
|
105
|
+
lines.append("M V30 END ATOM")
|
|
106
|
+
lines.append("M V30 BEGIN BOND")
|
|
107
|
+
for idx, rec in enumerate(bond_records, start=1):
|
|
108
|
+
cfg = ""
|
|
109
|
+
if rec["stereo"] == 1:
|
|
110
|
+
cfg = " CFG=1"
|
|
111
|
+
elif rec["stereo"] == 6:
|
|
112
|
+
cfg = " CFG=3"
|
|
113
|
+
lines.append(f"M V30 {idx} {rec['order']} {rec['a1']} {rec['a2']}{cfg}")
|
|
114
|
+
lines.append("M V30 END BOND")
|
|
115
|
+
lines.append("M V30 END CTAB")
|
|
116
|
+
lines.append("M END")
|
|
117
|
+
return "\n".join(lines) + "\n"
|
|
118
|
+
|
|
119
|
+
|
|
33
120
|
class MolecularData:
|
|
34
121
|
"""In-memory graph of atoms and bonds, independent of any UI framework."""
|
|
35
122
|
|
|
@@ -348,7 +435,7 @@ class MolecularData:
|
|
|
348
435
|
|
|
349
436
|
# Counts line and bond indices must only reflect atoms actually written
|
|
350
437
|
atom_map: Dict[int, int] = {}
|
|
351
|
-
|
|
438
|
+
atom_records: List[Dict[str, Any]] = []
|
|
352
439
|
for old_id, atom in self.atoms.items():
|
|
353
440
|
# Convert scene pixel coordinates to angstroms when emitting MOL block
|
|
354
441
|
pos = atom.get("pos")
|
|
@@ -362,37 +449,23 @@ class MolecularData:
|
|
|
362
449
|
else:
|
|
363
450
|
continue
|
|
364
451
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
elif charge == 1:
|
|
375
|
-
chg_code = 3
|
|
376
|
-
elif charge == -1:
|
|
377
|
-
chg_code = 5
|
|
378
|
-
elif charge == -2:
|
|
379
|
-
chg_code = 6
|
|
380
|
-
elif charge == -3:
|
|
381
|
-
chg_code = 7
|
|
382
|
-
|
|
383
|
-
atom_map[old_id] = len(atom_lines)
|
|
384
|
-
atom_lines.append(
|
|
385
|
-
f"{x:10.4f}{y:10.4f}{z:10.4f} {symbol:<3} 0 0 0{chg_code:3d} 0 0 0 0 0 0 0\n"
|
|
452
|
+
atom_map[old_id] = len(atom_records)
|
|
453
|
+
atom_records.append(
|
|
454
|
+
{
|
|
455
|
+
"x": x_px * ANGSTROM_PER_PIXEL,
|
|
456
|
+
"y": y_px * ANGSTROM_PER_PIXEL,
|
|
457
|
+
"symbol": atom["symbol"],
|
|
458
|
+
"charge": int(atom.get("charge", 0) or 0),
|
|
459
|
+
"radical": int(atom.get("radical", 0) or 0),
|
|
460
|
+
}
|
|
386
461
|
)
|
|
387
462
|
|
|
388
|
-
|
|
463
|
+
bond_records: List[Dict[str, Any]] = []
|
|
389
464
|
for (id1, id2), bond in self.bonds.items():
|
|
390
465
|
if id1 not in atom_map or id2 not in atom_map:
|
|
391
466
|
continue
|
|
392
|
-
|
|
393
|
-
# Bond order may be a float (1.5 = aromatic); V2000 uses code 4.
|
|
467
|
+
# Bond order may be a float (1.5 = aromatic); MDL uses code 4.
|
|
394
468
|
order_val = float(bond["order"])
|
|
395
|
-
order = 4 if order_val == 1.5 else int(order_val)
|
|
396
469
|
stereo_code = 0
|
|
397
470
|
bond_stereo = bond.get("stereo", 0)
|
|
398
471
|
if bond_stereo == 1:
|
|
@@ -400,16 +473,20 @@ class MolecularData:
|
|
|
400
473
|
elif bond_stereo == 2:
|
|
401
474
|
stereo_code = 6
|
|
402
475
|
|
|
403
|
-
|
|
404
|
-
|
|
476
|
+
bond_records.append(
|
|
477
|
+
{
|
|
478
|
+
"a1": atom_map[id1] + 1,
|
|
479
|
+
"a2": atom_map[id2] + 1,
|
|
480
|
+
"order": 4 if order_val == 1.5 else int(order_val),
|
|
481
|
+
"stereo": stereo_code,
|
|
482
|
+
}
|
|
405
483
|
)
|
|
406
484
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return mol_block
|
|
485
|
+
# V2000 packs the counts into three-character fields, so anything past
|
|
486
|
+
# 999 would run the atom count into the bond count and corrupt the file.
|
|
487
|
+
if len(atom_records) > 999 or len(bond_records) > 999:
|
|
488
|
+
return _to_v3000_block(atom_records, bond_records)
|
|
489
|
+
return _to_v2000_block(atom_records, bond_records)
|
|
413
490
|
|
|
414
491
|
def to_template_dict(
|
|
415
492
|
self, name: str, version: str = "1.0", application_version: str = ""
|
moleditpy_linux/main.py
CHANGED
|
@@ -189,7 +189,7 @@ def setup_logging() -> None:
|
|
|
189
189
|
log_dir = os.path.join(os.path.expanduser("~"), ".moleditpy")
|
|
190
190
|
try:
|
|
191
191
|
os.makedirs(log_dir, exist_ok=True)
|
|
192
|
-
log_path = os.path.join(log_dir, "
|
|
192
|
+
log_path = os.path.join(log_dir, "moleditpy.log")
|
|
193
193
|
fh = logging.handlers.RotatingFileHandler(
|
|
194
194
|
log_path, maxBytes=1_048_576, backupCount=3, encoding="utf-8"
|
|
195
195
|
)
|
|
@@ -231,7 +231,7 @@ def main() -> None:
|
|
|
231
231
|
if sys.platform == "win32":
|
|
232
232
|
# Taskbar grouping ID follows the major version
|
|
233
233
|
major = VERSION.split(".")[0] if VERSION and VERSION != "Unknown" else "0"
|
|
234
|
-
myappid = f"hyoko.
|
|
234
|
+
myappid = f"hyoko.moleditpy.{major}"
|
|
235
235
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
|
236
236
|
|
|
237
237
|
parser = argparse.ArgumentParser(
|
|
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
|
-
from typing import Any, Callable, List, Optional, Union
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class PluginContext:
|
|
@@ -353,6 +353,28 @@ class PluginContext:
|
|
|
353
353
|
"""
|
|
354
354
|
self._manager.register_document_reset_handler(self._plugin_name, callback)
|
|
355
355
|
|
|
356
|
+
def register_atom_drag_handler(
|
|
357
|
+
self,
|
|
358
|
+
callback: Callable[
|
|
359
|
+
[str, List[int], Dict[int, Tuple[float, float, float]]], None
|
|
360
|
+
],
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Register a handler called during 3D atom or group dragging.
|
|
363
|
+
|
|
364
|
+
The callback receives:
|
|
365
|
+
event_type (str): ``"start"``, ``"move"``, or ``"end"``.
|
|
366
|
+
atom_indices (List[int]): RDKit atom indices being dragged.
|
|
367
|
+
positions (dict[int, tuple[float, float, float]]):
|
|
368
|
+
Current 3D positions of the dragged atoms.
|
|
369
|
+
Empty dict on ``"start"``.
|
|
370
|
+
"""
|
|
371
|
+
self._manager.register_atom_drag_handler(self._plugin_name, callback)
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def is_dragging_atom(self) -> bool:
|
|
375
|
+
"""Return True if an atom or group is currently being dragged in 3D."""
|
|
376
|
+
return self._manager.is_dragging_atom() # type: ignore[no-any-return]
|
|
377
|
+
|
|
356
378
|
def get_setting(self, key: str, default: Any = None) -> Any:
|
|
357
379
|
"""
|
|
358
380
|
Get a plugin-specific persistent setting.
|
|
@@ -23,8 +23,9 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
23
23
|
|
|
24
24
|
from PyQt6.QtCore import QUrl
|
|
25
25
|
from PyQt6.QtGui import QDesktopServices
|
|
26
|
-
from PyQt6.QtWidgets import QMessageBox
|
|
26
|
+
from PyQt6.QtWidgets import QApplication, QMessageBox
|
|
27
27
|
|
|
28
|
+
from ..utils.constants import MOVE_DIALOG_TYPES
|
|
28
29
|
from .plugin_interface import PluginContext
|
|
29
30
|
|
|
30
31
|
|
|
@@ -95,6 +96,7 @@ class PluginManager:
|
|
|
95
96
|
self.load_handlers: Dict[str, Callable] = {}
|
|
96
97
|
self.custom_3d_styles: Dict[str, Dict[str, Any]] = {}
|
|
97
98
|
self.document_reset_handlers: List[Dict[str, Any]] = []
|
|
99
|
+
self.atom_drag_handlers: List[Dict[str, Any]] = []
|
|
98
100
|
self.plugin_windows: Dict[
|
|
99
101
|
str, Dict[str, Any]
|
|
100
102
|
] = {} # Map of plugin_name -> {window_id -> window}
|
|
@@ -247,6 +249,7 @@ class PluginManager:
|
|
|
247
249
|
self.load_handlers = {}
|
|
248
250
|
self.custom_3d_styles = {}
|
|
249
251
|
self.document_reset_handlers = []
|
|
252
|
+
self.atom_drag_handlers = []
|
|
250
253
|
|
|
251
254
|
if not os.path.exists(self.plugin_dir):
|
|
252
255
|
return []
|
|
@@ -558,6 +561,10 @@ class PluginManager:
|
|
|
558
561
|
{"plugin": plugin_name, "callback": callback}
|
|
559
562
|
)
|
|
560
563
|
|
|
564
|
+
def register_atom_drag_handler(self, plugin_name: str, callback: Callable) -> None:
|
|
565
|
+
"""Register a handler called during 3D atom/group dragging."""
|
|
566
|
+
self.atom_drag_handlers.append({"plugin": plugin_name, "callback": callback})
|
|
567
|
+
|
|
561
568
|
# --- New API Implementation ---
|
|
562
569
|
def show_status_message(self, message: str, timeout: int = 3000) -> None:
|
|
563
570
|
"""Display a message in the MainWindow status bar."""
|
|
@@ -653,6 +660,48 @@ class PluginManager:
|
|
|
653
660
|
exc_info=True,
|
|
654
661
|
)
|
|
655
662
|
|
|
663
|
+
def invoke_atom_drag_handlers(
|
|
664
|
+
self,
|
|
665
|
+
event_type: str,
|
|
666
|
+
atom_indices: List[int],
|
|
667
|
+
positions: Dict[int, tuple],
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Call all registered atom drag handlers."""
|
|
670
|
+
for handler in self.atom_drag_handlers:
|
|
671
|
+
try:
|
|
672
|
+
handler["callback"](event_type, atom_indices, positions)
|
|
673
|
+
except Exception as e: # plugins have full app access; catch everything to keep the drag alive
|
|
674
|
+
logging.warning(
|
|
675
|
+
"Error in atom drag handler for %s: %s",
|
|
676
|
+
handler["plugin"],
|
|
677
|
+
e,
|
|
678
|
+
exc_info=True,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
def is_dragging_atom(self) -> bool:
|
|
682
|
+
"""Return True if an atom or group is currently being dragged in 3D."""
|
|
683
|
+
mw = self.main_window
|
|
684
|
+
if mw is None:
|
|
685
|
+
return False
|
|
686
|
+
if getattr(mw, "dragged_atom_info", None) is not None:
|
|
687
|
+
return True
|
|
688
|
+
return self._is_dragging_group()
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def _is_dragging_group() -> bool:
|
|
692
|
+
"""Return True while a Move Group / Move Selected Atoms gesture is active."""
|
|
693
|
+
try:
|
|
694
|
+
for widget in QApplication.topLevelWidgets():
|
|
695
|
+
if type(widget).__name__ not in MOVE_DIALOG_TYPES:
|
|
696
|
+
continue
|
|
697
|
+
if getattr(widget, "is_dragging_group_vtk", False) or getattr(
|
|
698
|
+
widget, "is_rotating_group_vtk", False
|
|
699
|
+
):
|
|
700
|
+
return True
|
|
701
|
+
except (AttributeError, RuntimeError, TypeError):
|
|
702
|
+
logging.debug("Suppressed non-critical error", exc_info=True)
|
|
703
|
+
return False
|
|
704
|
+
|
|
656
705
|
def get_plugin_info_safe(self, file_path: str) -> Dict[str, str]:
|
|
657
706
|
"""Extracts plugin metadata using AST parsing (safe, no execution)."""
|
|
658
707
|
info = {
|
|
@@ -35,6 +35,7 @@ from ..core.mol_geometry import (
|
|
|
35
35
|
calc_angle_deg,
|
|
36
36
|
get_connected_group,
|
|
37
37
|
)
|
|
38
|
+
from ..utils.suppress_log import suppress_log
|
|
38
39
|
|
|
39
40
|
if TYPE_CHECKING:
|
|
40
41
|
from .main_window import MainWindow
|
|
@@ -468,5 +469,26 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
468
469
|
atoms_to_move,
|
|
469
470
|
)
|
|
470
471
|
|
|
472
|
+
achieved = calc_angle_deg(positions[idx_a], positions[idx_b], positions[idx_c])
|
|
473
|
+
|
|
471
474
|
# Write updated positions back using inherited helper
|
|
472
475
|
self._update_molecule_geometry(positions)
|
|
476
|
+
|
|
477
|
+
self._warn_if_angle_not_applied(new_angle_deg, achieved)
|
|
478
|
+
|
|
479
|
+
def _warn_if_angle_not_applied(self, requested: float, achieved: float) -> None:
|
|
480
|
+
"""Tell the user when the requested angle could not be reached.
|
|
481
|
+
|
|
482
|
+
For an angle inside a ring the connected group reached from one arm
|
|
483
|
+
comes back around and contains all three atoms, so they rotate
|
|
484
|
+
together and the angle is unchanged. Leaving the old value silently
|
|
485
|
+
looks like the edit was applied.
|
|
486
|
+
"""
|
|
487
|
+
if abs(achieved - requested) < 0.5:
|
|
488
|
+
return
|
|
489
|
+
with suppress_log(AttributeError, RuntimeError, TypeError):
|
|
490
|
+
self.main_window.statusBar().showMessage(
|
|
491
|
+
f"Angle unchanged ({achieved:.2f} deg): the three atoms rotate "
|
|
492
|
+
"together, which happens for an angle inside a ring.",
|
|
493
|
+
5000,
|
|
494
|
+
)
|
moleditpy_linux/ui/atom_item.py
CHANGED
|
@@ -63,6 +63,8 @@ class AtomItem(QGraphicsItem):
|
|
|
63
63
|
self.setFlags(
|
|
64
64
|
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
|
65
65
|
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
|
66
|
+
# Without this Qt never calls itemChange for moves, so bonds lag.
|
|
67
|
+
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
66
68
|
)
|
|
67
69
|
self.setZValue(1)
|
|
68
70
|
self.update_style()
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import logging
|
|
15
15
|
|
|
16
16
|
import numpy as np
|
|
17
|
-
from typing import Any, TYPE_CHECKING, Optional, Sequence
|
|
17
|
+
from typing import Any, Dict, TYPE_CHECKING, Optional, Sequence
|
|
18
18
|
|
|
19
19
|
from PyQt6.QtCore import Qt
|
|
20
20
|
from PyQt6.QtWidgets import (
|
|
@@ -32,6 +32,7 @@ from rdkit import Chem
|
|
|
32
32
|
|
|
33
33
|
from .geometry_base_dialog import GeometryBaseDialog
|
|
34
34
|
from ..core.mol_geometry import calc_distance, get_connected_group
|
|
35
|
+
from ..utils.suppress_log import suppress_log
|
|
35
36
|
|
|
36
37
|
if TYPE_CHECKING:
|
|
37
38
|
from .main_window import MainWindow
|
|
@@ -359,6 +360,8 @@ class BondLengthDialog(GeometryBaseDialog):
|
|
|
359
360
|
if current_distance == 0:
|
|
360
361
|
return
|
|
361
362
|
|
|
363
|
+
original = {i: np.array(p, dtype=float) for i, p in positions.items()}
|
|
364
|
+
|
|
362
365
|
direction = direction / current_distance
|
|
363
366
|
|
|
364
367
|
if self.both_groups_radio.isChecked():
|
|
@@ -400,3 +403,46 @@ class BondLengthDialog(GeometryBaseDialog):
|
|
|
400
403
|
|
|
401
404
|
# Write updated positions back using inherited helper
|
|
402
405
|
self._update_molecule_geometry(positions)
|
|
406
|
+
|
|
407
|
+
self._warn_if_other_bonds_moved(original, positions, idx1, idx2)
|
|
408
|
+
|
|
409
|
+
def _warn_if_other_bonds_moved(
|
|
410
|
+
self,
|
|
411
|
+
before: Dict[int, Any],
|
|
412
|
+
after: Dict[int, Any],
|
|
413
|
+
idx1: int,
|
|
414
|
+
idx2: int,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Report a neighbouring bond that this edit resized as a side effect.
|
|
417
|
+
|
|
418
|
+
A bond inside a ring cannot be resized alone: the group translated
|
|
419
|
+
along the bond axis wraps back around the ring, so the ring closes at
|
|
420
|
+
a different length. Stretching a benzene bond to 1.80 A pulls its
|
|
421
|
+
neighbour to 1.24 A, shorter than a double bond, with nothing on
|
|
422
|
+
screen to say so.
|
|
423
|
+
"""
|
|
424
|
+
edited = {idx1, idx2}
|
|
425
|
+
worst_delta = 0.0
|
|
426
|
+
worst_label = ""
|
|
427
|
+
for bond in self.mol.GetBonds():
|
|
428
|
+
i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
|
|
429
|
+
if {i, j} == edited or i not in before or j not in before:
|
|
430
|
+
continue
|
|
431
|
+
delta = calc_distance(after[i], after[j]) - calc_distance(
|
|
432
|
+
before[i], before[j]
|
|
433
|
+
)
|
|
434
|
+
if abs(delta) > abs(worst_delta):
|
|
435
|
+
worst_delta = delta
|
|
436
|
+
worst_label = (
|
|
437
|
+
f"{self.mol.GetAtomWithIdx(i).GetSymbol()}{i}-"
|
|
438
|
+
f"{self.mol.GetAtomWithIdx(j).GetSymbol()}{j}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if abs(worst_delta) < 1e-4:
|
|
442
|
+
return
|
|
443
|
+
with suppress_log(AttributeError, RuntimeError, TypeError):
|
|
444
|
+
self.main_window.statusBar().showMessage(
|
|
445
|
+
f"Bond {worst_label} also changed by {worst_delta:+.3f} A: a bond "
|
|
446
|
+
"inside a ring cannot be resized on its own.",
|
|
447
|
+
5000,
|
|
448
|
+
)
|
|
@@ -19,6 +19,7 @@ from PyQt6.QtCore import QThread, QTimer, QPoint
|
|
|
19
19
|
from PyQt6.QtGui import QAction, QColor
|
|
20
20
|
from PyQt6.QtWidgets import QMenu, QMessageBox, QWidget
|
|
21
21
|
from rdkit import Chem
|
|
22
|
+
from rdkit.Chem import AllChem
|
|
22
23
|
|
|
23
24
|
from . import OBABEL_AVAILABLE
|
|
24
25
|
|
|
@@ -458,7 +459,31 @@ class ComputeManager:
|
|
|
458
459
|
# Defer so Qt finishes routing the modal result before restoring focus.
|
|
459
460
|
QTimer.singleShot(0, self.host.init_manager.view_2d.setFocus)
|
|
460
461
|
|
|
462
|
+
def _ez_consistent_mol_block(self) -> Optional[str]:
|
|
463
|
+
"""MOL block whose 2D geometry encodes the editor's E/Z labels."""
|
|
464
|
+
data = self.host.state_manager.data
|
|
465
|
+
if not any(b.get("stereo", 0) in (3, 4) for b in data.bonds.values()):
|
|
466
|
+
return None
|
|
467
|
+
stereo_mol = data.to_rdkit_mol(use_2d_stereo=False)
|
|
468
|
+
if stereo_mol is None:
|
|
469
|
+
return None
|
|
470
|
+
try:
|
|
471
|
+
# MDL carries double-bond cis/trans only in the 2D coordinates, so a
|
|
472
|
+
# label that contradicts how the user drew the bond is lost unless
|
|
473
|
+
# the layout is rebuilt from the stereo. These coordinates only seed
|
|
474
|
+
# 3D generation; the 2D canvas keeps the user's own layout.
|
|
475
|
+
planar = Chem.Mol(stereo_mol)
|
|
476
|
+
planar.RemoveAllConformers()
|
|
477
|
+
AllChem.Compute2DCoords(planar)
|
|
478
|
+
return str(Chem.MolToMolBlock(planar, includeStereo=True))
|
|
479
|
+
except (RuntimeError, ValueError) as e:
|
|
480
|
+
logging.warning("E/Z-consistent 2D layout failed: %s", e)
|
|
481
|
+
return None
|
|
482
|
+
|
|
461
483
|
def _setup_mol_block_for_worker(self, mol: Chem.Mol) -> str:
|
|
484
|
+
ez_block = self._ez_consistent_mol_block()
|
|
485
|
+
if ez_block:
|
|
486
|
+
return ez_block
|
|
462
487
|
mol_block = self.host.state_manager.data.to_mol_block()
|
|
463
488
|
if not mol_block:
|
|
464
489
|
mol_block = Chem.MolToMolBlock(mol, includeStereo=True)
|