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.
@@ -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
- atom_lines: List[str] = []
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
- x, y = x_px * ANGSTROM_PER_PIXEL, y_px * ANGSTROM_PER_PIXEL
366
- z, symbol = 0.0, atom["symbol"]
367
- charge = atom.get("charge", 0)
368
-
369
- chg_code = 0
370
- if charge == 3:
371
- chg_code = 1
372
- elif charge == 2:
373
- chg_code = 2
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
- bond_lines = []
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
- idx1, idx2 = atom_map[id1] + 1, atom_map[id2] + 1
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
- bond_lines.append(
404
- f"{idx1:3d}{idx2:3d}{order:3d}{stereo_code:3d} 0 0 0\n"
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
- mol_block = "\n MoleditPy\n\n"
408
- mol_block += f"{len(atom_lines):3d}{len(bond_lines):3d} 0 0 0 0 0 0 0 0999 V2000\n"
409
- mol_block += "".join(atom_lines)
410
- mol_block += "".join(bond_lines)
411
- mol_block += "M END\n"
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, "moleditpy_linux.log")
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.moleditpy_linux.{major}"
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
+ )
@@ -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)