MoleditPy-linux 4.12.0__tar.gz → 4.13.0__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.12.0 → moleditpy_linux-4.13.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/core/molecular_data.py +95 -1
  5. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/core/stereo_check.py +134 -7
  6. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/app_state.py +4 -2
  7. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/atom_item.py +91 -1
  8. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/compute_logic.py +1 -1
  9. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/edit_actions_logic.py +15 -2
  10. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/main_window_init.py +19 -7
  11. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +93 -0
  12. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/molecule_scene.py +45 -5
  13. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/ui_manager.py +6 -1
  14. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/view_3d_logic.py +12 -7
  15. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/zoomable_view.py +1 -1
  16. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/LICENSE +0 -0
  17. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/README.md +0 -0
  18. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/setup.cfg +0 -0
  19. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  20. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  21. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  22. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  23. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  24. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/__init__.py +0 -0
  25. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/__main__.py +0 -0
  26. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  27. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  28. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  29. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/assets/icon.png +0 -0
  30. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/core/__init__.py +0 -0
  31. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  32. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/main.py +0 -0
  33. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  34. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  35. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  36. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  37. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  38. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  39. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  40. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  41. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  42. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  43. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  44. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  45. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/bond_item.py +0 -0
  46. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  47. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  48. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  49. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/chirality_warning_dialog.py +0 -0
  50. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  51. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  52. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  53. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  54. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  55. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  56. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  57. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  58. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
  59. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  60. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/io_logic.py +0 -0
  61. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  62. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  63. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/move_dialog_mixin.py +0 -0
  64. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  65. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  66. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  67. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  68. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  69. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
  70. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/sdf_record_dialog.py +0 -0
  71. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  72. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  73. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
  74. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  75. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  76. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/settings_labels_tab.py +0 -0
  77. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  78. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  79. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  80. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  81. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  82. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  83. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  84. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  85. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/constants.py +0 -0
  86. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  87. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/finite_float.py +0 -0
  88. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  89. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/label_style.py +0 -0
  90. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/sdf_records.py +0 -0
  91. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  92. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  93. {moleditpy_linux-4.12.0 → moleditpy_linux-4.13.0}/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.12.0
3
+ Version: 4.13.0
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.12.0"
8
+ version = "4.13.0"
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.12.0
3
+ Version: 4.13.0
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
@@ -390,6 +390,85 @@ def resolve_wedge_chirality(mol: Chem.Mol) -> None:
390
390
  logging.warning("Resolving wedge stereochemistry failed: %s", e)
391
391
 
392
392
 
393
+ RS_LABELS = ("R", "S")
394
+
395
+
396
+ def rs_entry(atom: Dict[str, Any]) -> Dict[str, str]:
397
+ """{"rs": label} when *atom* carries a valid R/S label, else {}.
398
+
399
+ Spread into serialized atom records so an unlabelled atom is written
400
+ exactly as before the label existed.
401
+ """
402
+ label = atom.get("rs")
403
+ return {"rs": label} if label in RS_LABELS else {}
404
+
405
+
406
+ def _tag_for_label(
407
+ mol: Chem.Mol, center: int, label: str, tags: List[Chem.ChiralType]
408
+ ) -> Optional[Chem.ChiralType]:
409
+ """Chiral tag that gives *center* CIP *label*, or None if it has no R/S.
410
+
411
+ The other centers keep *tags* (their drawn configuration), since a
412
+ center's CIP label can depend on them.
413
+ """
414
+ cw, ccw = Chem.ChiralType.CHI_TETRAHEDRAL_CW, Chem.ChiralType.CHI_TETRAHEDRAL_CCW
415
+ probe = Chem.Mol(mol)
416
+ for atom, other in zip(probe.GetAtoms(), tags):
417
+ atom.SetChiralTag(other)
418
+ probe.GetAtomWithIdx(center).SetChiralTag(cw)
419
+ Chem.AssignStereochemistry(probe, cleanIt=True, force=True)
420
+ if probe.GetAtomWithIdx(center).GetChiralTag() != cw:
421
+ return None # perception dropped it: not a stereocenter
422
+ rdCIPLabeler.AssignCIPLabels(probe)
423
+ atom = probe.GetAtomWithIdx(center)
424
+ code = atom.GetProp("_CIPCode") if atom.HasProp("_CIPCode") else None
425
+ if code not in RS_LABELS:
426
+ return None # no CIP label, or pseudo-asymmetric (r/s)
427
+ # The mirror tag gives the mirror label.
428
+ return cw if code == label else ccw
429
+
430
+
431
+ def apply_rs_labels(mol: Chem.Mol, wanted: Dict[int, str]) -> None:
432
+ """Re-wedge each labelled center of *mol* so it reads as its R/S label.
433
+
434
+ *wanted* maps atom index to "R" or "S". A label is the user's explicit
435
+ choice, so it overrides whatever the wedges and hashes at that center
436
+ say; it is written back as wedges because that is what every later
437
+ reader (MOL block, 3D embedding, stereo check) takes chirality from.
438
+ Labels on atoms that are not stereocenters are ignored, and other
439
+ centers keep their drawn configuration.
440
+ """
441
+ if not wanted or mol.GetNumConformers() == 0:
442
+ return
443
+ try:
444
+ read = _rdkit_tags(mol)
445
+ fixes: Dict[int, Chem.ChiralType] = {}
446
+ for center, label in wanted.items():
447
+ tag = _tag_for_label(mol, center, label, read)
448
+ if tag is None:
449
+ logging.debug("R/S label on atom %d ignored: no stereocenter", center)
450
+ elif tag != read[center]:
451
+ fixes[center] = tag
452
+ if not fixes:
453
+ return
454
+ expected = [fixes.get(i, tag) for i, tag in enumerate(read)]
455
+ for rewedge in (_rdkit_rewedge, _clear_and_pick_wedges):
456
+ trial = Chem.Mol(mol)
457
+ rewedge(trial, fixes)
458
+ if _rdkit_tags(trial) == expected:
459
+ rewedge(mol, fixes)
460
+ return
461
+ logging.warning("Could not draw R/S labels on atoms %s", sorted(fixes))
462
+ except (RuntimeError, ValueError) as e:
463
+ logging.warning("Applying R/S labels failed: %s", e)
464
+
465
+
466
+ def _clear_and_pick_wedges(mol: Chem.Mol, fixes: Dict[int, Chem.ChiralType]) -> None:
467
+ """Drop the wedges at *fixes* and pick one free bond per center afresh."""
468
+ _clear_own_wedges(mol, fixes)
469
+ _pick_wedges(mol, fixes)
470
+
471
+
393
472
  class MolecularData:
394
473
  """In-memory graph of atoms and bonds, independent of any UI framework."""
395
474
 
@@ -511,10 +590,14 @@ class MolecularData:
511
590
  self.adjacency_list[id2].remove(id1)
512
591
  self.bonds.pop(key_to_remove, None)
513
592
 
514
- def to_rdkit_mol(self, use_2d_stereo: bool = True) -> Optional[Chem.Mol]:
593
+ def to_rdkit_mol(
594
+ self, use_2d_stereo: bool = True, use_rs_labels: bool = True
595
+ ) -> Optional[Chem.Mol]:
515
596
  """
516
597
  use_2d_stereo: True estimates E/Z from 2D coordinates (as before). False prioritizes E/Z labels.
517
598
  Call with use_2d_stereo=False for 3D conversion.
599
+ use_rs_labels: True re-wedges atoms carrying an R/S label ("rs") so the
600
+ label wins over their drawn wedges; False reads the drawing alone.
518
601
  """
519
602
  if not self.atoms:
520
603
  return None
@@ -691,6 +774,17 @@ class MolecularData:
691
774
 
692
775
  resolve_wedge_chirality(final_mol)
693
776
 
777
+ # R/S labels are the user's explicit choice and override the wedges.
778
+ if use_rs_labels:
779
+ apply_rs_labels(
780
+ final_mol,
781
+ {
782
+ atom_id_to_idx_map[atom_id]: data["rs"]
783
+ for atom_id, data in self.atoms.items()
784
+ if data.get("rs") in RS_LABELS and atom_id in atom_id_to_idx_map
785
+ },
786
+ )
787
+
694
788
  # The labels are CIP E/Z; the neighbors picked above are not always
695
789
  # the CIP-higher ones.
696
790
  align_ez_to_cip(final_mol, ez_wanted)
@@ -14,12 +14,12 @@ from __future__ import annotations
14
14
 
15
15
  import logging
16
16
  from dataclasses import dataclass
17
- from typing import Dict, FrozenSet, List, Optional, Tuple
17
+ from typing import Any, Dict, FrozenSet, List, Optional, Tuple
18
18
 
19
19
  from rdkit import Chem
20
20
  from rdkit.Chem import rdCIPLabeler
21
21
 
22
- from .molecular_data import MolecularData
22
+ from .molecular_data import RS_LABELS, MolecularData
23
23
 
24
24
 
25
25
  @dataclass(frozen=True)
@@ -55,12 +55,20 @@ def _cip_labels_by_atom_id(mol: Chem.Mol) -> Dict[int, str]:
55
55
  return labels
56
56
 
57
57
 
58
- def drawn_chirality(data: MolecularData) -> Dict[int, str]:
59
- """CIP labels of the stereocenters the 2D drawing specifies with wedges."""
60
- # Nothing is specified without a wedge or hash; skip CIP labelling.
61
- if not any(bond.get("stereo") in (1, 2) for bond in data.bonds.values()):
58
+ def drawn_chirality(data: MolecularData, use_rs_labels: bool = True) -> Dict[int, str]:
59
+ """CIP labels of the stereocenters the 2D drawing specifies.
60
+
61
+ These come from the wedges and hashes and, unless *use_rs_labels* is
62
+ False, the atoms' R/S labels, which override the wedges at their center.
63
+ """
64
+ # Nothing is specified without a wedge, hash or label; skip CIP labelling.
65
+ has_wedge = any(bond.get("stereo") in (1, 2) for bond in data.bonds.values())
66
+ has_label = use_rs_labels and any(
67
+ atom.get("rs") in RS_LABELS for atom in data.atoms.values()
68
+ )
69
+ if not has_wedge and not has_label:
62
70
  return {}
63
- mol = data.to_rdkit_mol(use_2d_stereo=False)
71
+ mol = data.to_rdkit_mol(use_2d_stereo=False, use_rs_labels=use_rs_labels)
64
72
  if mol is None:
65
73
  return {}
66
74
  Chem.AssignChiralTypesFromBondDirs(mol)
@@ -68,6 +76,125 @@ def drawn_chirality(data: MolecularData) -> Dict[int, str]:
68
76
  return _cip_labels_by_atom_id(mol)
69
77
 
70
78
 
79
+ @dataclass(frozen=True)
80
+ class WedgeForLabel:
81
+ """How to draw a bond so that the stereocenter at its narrow end reads as R/S."""
82
+
83
+ key: Tuple[int, int] # (stereocenter, other end): data.bonds key, narrow end first
84
+ stereo: int # 1 = wedge, 2 = hash
85
+ cleared: Tuple[Tuple[int, int], ...] # other wedges at the center to make plain
86
+
87
+
88
+ def wedge_for_label(
89
+ data: MolecularData,
90
+ bond_key: Tuple[int, int],
91
+ label: str,
92
+ centers: Optional[List[int]] = None,
93
+ ) -> Optional[WedgeForLabel]:
94
+ """Wedge or hash on *bond_key* that makes one of its ends CIP *label*.
95
+
96
+ *centers* is the order in which the bond's ends are tried (default: the
97
+ key order). Each end is tried first with the other wedges kept; if they
98
+ make *label* unreachable, then with this center's other wedges and hashes
99
+ made plain, so the requested label wins. Returns None when neither end
100
+ can be *label* (not a stereocenter). *data* is left unchanged.
101
+ """
102
+ if bond_key not in data.bonds or label not in RS_LABELS:
103
+ return None
104
+ ends = list(centers) if centers else list(bond_key)
105
+ original = list(data.bonds.items())
106
+ template = dict(data.bonds[bond_key], order=1)
107
+ trial_bonds = {k: dict(v) for k, v in original if k != bond_key}
108
+ try:
109
+ for center in ends:
110
+ if center not in bond_key:
111
+ continue
112
+ other = bond_key[1] if center == bond_key[0] else bond_key[0]
113
+ own = tuple(
114
+ k
115
+ for k, v in trial_bonds.items()
116
+ if k[0] == center and v.get("stereo") in (1, 2)
117
+ )
118
+ for cleared in ((), own) if own else ((),):
119
+ stereo = _stereo_reading_as(
120
+ data, trial_bonds, (center, other), template, cleared, label
121
+ )
122
+ if stereo is not None:
123
+ return WedgeForLabel((center, other), stereo, cleared)
124
+ return None
125
+ except (RuntimeError, ValueError, TypeError) as e:
126
+ logging.warning("R/S wedge search failed: %s", e)
127
+ return None
128
+ finally:
129
+ data.bonds.clear()
130
+ data.bonds.update(original)
131
+
132
+
133
+ def _stereo_reading_as(
134
+ data: MolecularData,
135
+ base: Dict[Tuple[int, int], Dict[str, Any]],
136
+ key: Tuple[int, int],
137
+ template: Dict[str, Any],
138
+ cleared: Tuple[Tuple[int, int], ...],
139
+ label: str,
140
+ ) -> Optional[int]:
141
+ """Wedge (1) or hash (2) on *key* that makes its narrow end read *label*.
142
+
143
+ Tried on *data* rebuilt from *base* with the *cleared* bonds made plain;
144
+ the caller restores *data*.
145
+ """
146
+ for stereo in (1, 2):
147
+ data.bonds.clear()
148
+ for k, v in base.items():
149
+ data.bonds[k] = dict(v, stereo=0) if k in cleared else dict(v)
150
+ data.bonds[key] = dict(template, stereo=stereo)
151
+ if drawn_chirality(data, use_rs_labels=False).get(key[0]) == label:
152
+ return stereo
153
+ return None
154
+
155
+
156
+ def align_wedges_to_label(
157
+ data: MolecularData, center: int, label: str
158
+ ) -> Dict[Tuple[int, int], int]:
159
+ """Stereo changes that make the wedges at *center* draw R/S *label*.
160
+
161
+ Only the wedges and hashes starting at *center* are touched, and only when
162
+ there are some: a center drawn without them keeps its plain bonds (its
163
+ label alone carries the configuration). If the drawing already reads as
164
+ *label*, nothing changes. Otherwise every stereo bond at the center is
165
+ swapped (wedge <-> hash), which mirrors it; if that still does not read
166
+ as *label* (an ambiguous drawing), one of them is redrawn and the rest
167
+ made plain. Returns {data.bonds key: new stereo}; empty when nothing
168
+ needs to change or no drawing gives *label*.
169
+ """
170
+ own = [
171
+ key
172
+ for key, bond in data.bonds.items()
173
+ if key[0] == center and bond.get("stereo") in (1, 2)
174
+ ]
175
+ if not own or label not in RS_LABELS:
176
+ return {}
177
+ if drawn_chirality(data, use_rs_labels=False).get(center) == label:
178
+ return {}
179
+ original = {key: data.bonds[key]["stereo"] for key in own}
180
+ swapped = {key: 3 - stereo for key, stereo in original.items()}
181
+ try:
182
+ for key, stereo in swapped.items():
183
+ data.bonds[key]["stereo"] = stereo
184
+ if drawn_chirality(data, use_rs_labels=False).get(center) == label:
185
+ return swapped
186
+ finally:
187
+ for key, stereo in original.items():
188
+ data.bonds[key]["stereo"] = stereo
189
+ for key in own:
190
+ found = wedge_for_label(data, key, label, centers=[center])
191
+ if found is not None:
192
+ changes = dict.fromkeys(found.cleared, 0)
193
+ changes[key] = found.stereo
194
+ return changes
195
+ return {}
196
+
197
+
71
198
  def actual_chirality(mol_3d: Chem.Mol) -> Dict[int, str]:
72
199
  """CIP labels of the stereocenters in a 3D structure, read from coordinates."""
73
200
  if mol_3d.GetNumConformers() == 0:
@@ -33,7 +33,7 @@ from PyQt6.QtCore import QDateTime, Qt
33
33
  from PyQt6.QtWidgets import QMessageBox
34
34
 
35
35
  from ..utils.constants import VERSION
36
- from ..core.molecular_data import MolecularData
36
+ from ..core.molecular_data import MolecularData, rs_entry
37
37
 
38
38
 
39
39
  from typing import TYPE_CHECKING
@@ -90,6 +90,7 @@ class StateManager:
90
90
  "pos": data["pos"], # Already a tuple (x, y)
91
91
  "charge": data.get("charge", 0),
92
92
  "radical": data.get("radical", 0),
93
+ **rs_entry(data),
93
94
  }
94
95
  for atom_id, data in self.data.atoms.items()
95
96
  }
@@ -332,7 +333,7 @@ class StateManager:
332
333
 
333
334
  if self.data:
334
335
  try:
335
- mol = self.data.to_rdkit_mol()
336
+ mol = self.data.to_rdkit_mol(use_rs_labels=False)
336
337
  if mol:
337
338
  mol_with_hs = Chem.AddHs(mol)
338
339
  mol_formula = rdMolDescriptors.CalcMolFormula(mol)
@@ -370,6 +371,7 @@ class StateManager:
370
371
  "y": pos[1],
371
372
  "charge": data.get("charge", 0),
372
373
  "radical": data.get("radical", 0),
374
+ **rs_entry(data),
373
375
  }
374
376
  atoms_2d.append(atom_data)
375
377
 
@@ -22,6 +22,7 @@ from PyQt6.QtGui import (
22
22
  QFontMetricsF,
23
23
  QPainter,
24
24
  QPainterPath,
25
+ QPainterPathStroker,
25
26
  QPen,
26
27
  )
27
28
  from PyQt6.QtWidgets import (
@@ -34,6 +35,7 @@ from PyQt6.QtWidgets import (
34
35
  from ..utils.constants import (
35
36
  ATOM_RADIUS,
36
37
  CPK_COLORS,
38
+ EZ_LABEL_TEXT_OUTLINE,
37
39
  FONT_FAMILY,
38
40
  FONT_WEIGHT_BOLD,
39
41
  )
@@ -86,6 +88,8 @@ class AtomItem(QGraphicsItem):
86
88
  self.radical: int = radical
87
89
  self.bonds: List[BondItem] = []
88
90
  self.chiral_label: Optional[str] = None
91
+ # User-set R/S label ("R"/"S"); mirrors data.atoms[atom_id]["rs"].
92
+ self.rs_label: Optional[str] = None
89
93
 
90
94
  self.setPos(pos)
91
95
  self.implicit_h_count: int = 0
@@ -250,9 +254,91 @@ class AtomItem(QGraphicsItem):
250
254
  def boundingRect(self) -> QRectF:
251
255
  """Return the drawn rect plus the hit shape, which Qt requires it to cover."""
252
256
  hit_r = self.hit_radius()
253
- return self.visual_rect().united(
257
+ rect = self.visual_rect().united(
254
258
  QRectF(-hit_r, -hit_r, hit_r * 2.0, hit_r * 2.0)
255
259
  )
260
+ rs_rect = self._rs_label_rect()
261
+ if rs_rect is not None:
262
+ rect = rect.united(rs_rect.adjusted(-2, -2, 2, 2))
263
+ return rect
264
+
265
+ def _rs_label_font(self) -> QFont:
266
+ """Italic font of the R/S label, sized from the atom label font."""
267
+ base = self._label_font()
268
+ font = QFont(
269
+ base.family(), max(8, int(base.pointSize() * 0.7)), FONT_WEIGHT_BOLD
270
+ )
271
+ font.setItalic(True)
272
+ return font
273
+
274
+ def _rs_label_rect(self) -> Optional[QRectF]:
275
+ """Where the "(R)"/"(S)" label goes: beside the atom, away from its bonds."""
276
+ if self.rs_label not in ("R", "S"):
277
+ return None
278
+ rect = QFontMetricsF(self._rs_label_font()).boundingRect(f"({self.rs_label})")
279
+ dx = dy = 0.0
280
+ for bond in self.bonds:
281
+ try:
282
+ partner = bond.atom2 if bond.atom1 is self else bond.atom1
283
+ if partner is None or sip_isdeleted_safe(partner):
284
+ continue
285
+ vec = partner.pos() - self.pos()
286
+ length = (vec.x() ** 2 + vec.y() ** 2) ** 0.5
287
+ if length > 1e-6:
288
+ dx += vec.x() / length
289
+ dy += vec.y() / length
290
+ except (AttributeError, RuntimeError, TypeError):
291
+ continue
292
+ length = (dx**2 + dy**2) ** 0.5
293
+ # Opposite the bonds; upper right when they balance out or there are none.
294
+ ux, uy = (-dx / length, -dy / length) if length > 0.3 else (0.71, -0.71)
295
+ distance = 14.0 + max(rect.width(), rect.height()) / 2.0
296
+ rect.moveCenter(QPointF(ux * distance, uy * distance))
297
+ return rect
298
+
299
+ def _relayout_rs_labels(self) -> None:
300
+ """Re-place the R/S labels a move of this atom shifts.
301
+
302
+ A label sits opposite its atom's bonds, so moving this atom moves its
303
+ own label and those of its labelled neighbors.
304
+ """
305
+ if self.rs_label is not None:
306
+ self.prepareGeometryChange()
307
+ for bond in self.bonds:
308
+ partner = bond.atom2 if bond.atom1 is self else bond.atom1
309
+ if partner is None or sip_isdeleted_safe(partner):
310
+ continue
311
+ if getattr(partner, "rs_label", None) is not None:
312
+ partner.prepareGeometryChange()
313
+
314
+ def _paint_rs_label(self, painter: QPainter) -> None:
315
+ """Draw the user-set R/S label, outlined like the E/Z label."""
316
+ rect = self._rs_label_rect()
317
+ if rect is None:
318
+ return
319
+ painter.save()
320
+ font = self._rs_label_font()
321
+ text = f"({self.rs_label})"
322
+ path = QPainterPath()
323
+ fm = QFontMetricsF(font)
324
+ path.addText(QPointF(rect.left(), rect.top() + fm.ascent()), font, text)
325
+ outline_color = QColor(255, 255, 255)
326
+ scene: Any = self.scene()
327
+ try:
328
+ if scene is not None:
329
+ outline_color = scene.backgroundBrush().color()
330
+ except (AttributeError, RuntimeError, TypeError):
331
+ logging.debug("R/S label outline color fallback", exc_info=True)
332
+ stroker = QPainterPathStroker()
333
+ stroker.setWidth(EZ_LABEL_TEXT_OUTLINE)
334
+ painter.setPen(Qt.PenStyle.NoPen)
335
+ painter.setBrush(outline_color)
336
+ painter.drawPath(stroker.createStroke(path))
337
+ text_color = QColor("gray")
338
+ painter.setBrush(text_color)
339
+ painter.setPen(text_color)
340
+ painter.drawPath(path)
341
+ painter.restore()
256
342
 
257
343
  def get_bg_ellipse_path(self) -> QPainterPath:
258
344
  """Return the elliptical background path used for bond endpoint clipping."""
@@ -381,6 +467,9 @@ class AtomItem(QGraphicsItem):
381
467
  QPointF(text_rect.center().x() + 5, radical_pos_y), 3, 3
382
468
  )
383
469
 
470
+ # Drawn even on a skeletal carbon, which has no symbol of its own.
471
+ self._paint_rs_label(painter)
472
+
384
473
  # --- Selection highlights etc. ---
385
474
  if self.has_problem:
386
475
  painter.setBrush(Qt.BrushStyle.NoBrush)
@@ -405,6 +494,7 @@ class AtomItem(QGraphicsItem):
405
494
  for bond in self.bonds:
406
495
  if bond.scene():
407
496
  bond.update_position()
497
+ self._relayout_rs_labels()
408
498
  elif change == QGraphicsItem.GraphicsItemChange.ItemSceneHasChanged:
409
499
  if self.scene() is not None:
410
500
  self.update_style()
@@ -817,7 +817,7 @@ class ComputeManager:
817
817
  def update_aromatic_rings(self) -> None:
818
818
  """Update aromatic ring visualization."""
819
819
  try:
820
- mol = self.host.state_manager.data.to_rdkit_mol()
820
+ mol = self.host.state_manager.data.to_rdkit_mol(use_rs_labels=False)
821
821
  if not mol:
822
822
  return
823
823
  Chem.SanitizeMol(mol)
@@ -44,7 +44,7 @@ from rdkit import Chem
44
44
  from .atom_item import AtomItem
45
45
  from .bond_item import BondItem
46
46
  from ..utils.constants import CLIPBOARD_MIME_TYPE, UNDO_STACK_MAX_DEPTH
47
- from ..core.molecular_data import MolecularData
47
+ from ..core.molecular_data import RS_LABELS, MolecularData, rs_entry
48
48
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
49
49
 
50
50
 
@@ -159,6 +159,7 @@ class EditActionsManager:
159
159
  v["pos"].y() if hasattr(v["pos"], "y") else v["pos"][1],
160
160
  v.get("charge", 0),
161
161
  v.get("radical", 0),
162
+ v.get("rs"),
162
163
  )
163
164
  for k, v in self.host.state_manager.data.atoms.items()
164
165
  },
@@ -196,6 +197,7 @@ class EditActionsManager:
196
197
  v["pos"].y() if hasattr(v["pos"], "y") else v["pos"][1],
197
198
  v.get("charge", 0),
198
199
  v.get("radical", 0),
200
+ v.get("rs"),
199
201
  )
200
202
  for k, v in last_atoms.items()
201
203
  },
@@ -322,6 +324,9 @@ class EditActionsManager:
322
324
  "rel_pos": atom.pos() - center,
323
325
  "charge": atom.charge,
324
326
  "radical": atom.radical,
327
+ **rs_entry(
328
+ self.host.state_manager.data.atoms.get(atom.atom_id, {})
329
+ ),
325
330
  }
326
331
  )
327
332
 
@@ -350,6 +355,7 @@ class EditActionsManager:
350
355
  "rel_pos": [a["rel_pos"].x(), a["rel_pos"].y()],
351
356
  "charge": a["charge"],
352
357
  "radical": a["radical"],
358
+ **rs_entry(a),
353
359
  }
354
360
  for a in fragment_atoms
355
361
  ],
@@ -441,12 +447,15 @@ class EditActionsManager:
441
447
  raise TypeError(
442
448
  "Clipboard atom charge and radical must be integers"
443
449
  )
450
+ if atom_data.get("rs") not in (None, *RS_LABELS):
451
+ raise ValueError("Clipboard atom R/S label must be R or S")
444
452
  validated_atoms.append(
445
453
  {
446
454
  "symbol": symbol,
447
455
  "rel_pos": (rel_x, rel_y),
448
456
  "charge": charge,
449
457
  "radical": radical,
458
+ **rs_entry(atom_data),
450
459
  }
451
460
  )
452
461
 
@@ -500,6 +509,10 @@ class EditActionsManager:
500
509
  radical=atom_data.get("radical", 0),
501
510
  )
502
511
  new_item = self.host.init_manager.scene.atom_items[new_id]
512
+ if atom_data.get("rs"):
513
+ self.host.init_manager.scene.set_atom_rs_label(
514
+ new_item, atom_data["rs"]
515
+ )
503
516
  new_atoms.append(new_item)
504
517
  new_item.setSelected(True)
505
518
 
@@ -1208,7 +1221,7 @@ class EditActionsManager:
1208
1221
 
1209
1222
  mol = None
1210
1223
  try:
1211
- mol = self.host.state_manager.data.to_rdkit_mol()
1224
+ mol = self.host.state_manager.data.to_rdkit_mol(use_rs_labels=False)
1212
1225
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
1213
1226
  logging.debug(f"to_rdkit_mol failed during H-update: {e}")
1214
1227
  mol = None
@@ -848,6 +848,7 @@ class MainInitManager:
848
848
  ("Triple Bond", "bond_3_0", "3", "triple"),
849
849
  ("Wedge Bond", "bond_1_1", "W", "wedge"),
850
850
  ("Dash Bond", "bond_1_2", "D", "dash"),
851
+ ("Toggle R/S", "rs_label", "R", "rs_toggle"),
851
852
  ("Toggle E/Z", "bond_2_5", "E/Z", "ez_toggle"),
852
853
  ]
853
854
 
@@ -1032,17 +1033,28 @@ class MainInitManager:
1032
1033
  offset = QPointF(v.dx(), v.dy()) * 2.0
1033
1034
  painter.drawLine(linez.translated(offset))
1034
1035
  painter.drawLine(linez.translated(-offset))
1035
- font = painter.font()
1036
- font.setPointSize(10)
1037
- font.setBold(True)
1038
- painter.setFont(font)
1039
- painter.drawText(
1040
- QRectF(0, 0, size, size * 0.6), Qt.AlignmentFlag.AlignCenter, "Z⇌E"
1041
- )
1036
+ self._draw_icon_caption(painter, size, "Z⇌E")
1037
+ elif bond_type == "rs_toggle":
1038
+ p1z, p2z = QPointF(6, size * 0.75), QPointF(size - 6, size * 0.75)
1039
+ normal = QLineF(p1z, p2z).unitVector().normalVector()
1040
+ offset = QPointF(normal.dx(), normal.dy()) * 3.0
1041
+ painter.drawPolygon(QPolygonF([p1z, p2z + offset, p2z - offset]))
1042
+ self._draw_icon_caption(painter, size, "R⇌S")
1042
1043
 
1043
1044
  painter.end()
1044
1045
  return QIcon(pixmap)
1045
1046
 
1047
+ @staticmethod
1048
+ def _draw_icon_caption(painter: QPainter, size: int, text: str) -> None:
1049
+ """Bold caption across the top of a toolbar icon (e.g. "Z⇌E")."""
1050
+ font = painter.font()
1051
+ font.setPointSize(10)
1052
+ font.setBold(True)
1053
+ painter.setFont(font)
1054
+ painter.drawText(
1055
+ QRectF(0, 0, size, size * 0.6), Qt.AlignmentFlag.AlignCenter, text
1056
+ )
1057
+
1046
1058
  def _create_chain_icon(self, size: int = 32) -> QIcon:
1047
1059
  """Generate the -(-)n- repeat-unit icon for the alkyl chain tool."""
1048
1060
  fg = self._get_icon_foreground_color()
@@ -25,6 +25,8 @@ from .bond_item import BondItem
25
25
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
26
26
 
27
27
  from ..core.mol_geometry import is_problematic_valence
28
+ from ..core.molecular_data import RS_LABELS
29
+ from ..core.stereo_check import align_wedges_to_label, drawn_chirality
28
30
  from ..utils.constants import DEFAULT_BOND_LENGTH, SUM_TOLERANCE
29
31
 
30
32
 
@@ -893,6 +895,7 @@ class KeyboardMixin:
893
895
  # Provided by the composed MoleculeScene at runtime
894
896
  _calculate_polygon_from_edge: Any
895
897
  add_molecule_fragment: Any
898
+ apply_rs_label: Any
896
899
  atom_items: Any
897
900
  clear: Any
898
901
  create_atom: Any
@@ -1201,6 +1204,20 @@ class KeyboardMixin:
1201
1204
  event.accept()
1202
1205
  return
1203
1206
 
1207
+ # --- 0c. R/S label (R cycles the hovered atom: R -> S -> none) ---
1208
+ if (
1209
+ key == Qt.Key.Key_R
1210
+ and modifiers == Qt.KeyboardModifier.NoModifier
1211
+ and isinstance(item_at_cursor, AtomItem)
1212
+ ):
1213
+ if self.apply_rs_label(
1214
+ item_at_cursor, next_rs_label(item_at_cursor.rs_label)
1215
+ ):
1216
+ self.update_all_items()
1217
+ self.window.edit_actions_manager.push_undo_state()
1218
+ event.accept()
1219
+ return
1220
+
1204
1221
  # --- 1. Atom operations (change symbol) ---
1205
1222
  if isinstance(item_at_cursor, AtomItem):
1206
1223
  new_symbol = None
@@ -1851,3 +1868,79 @@ class SceneQueryMixin:
1851
1868
  )
1852
1869
  self.window.statusBar().showMessage(f"Error: {e}", 5000)
1853
1870
  self.update_all_items()
1871
+
1872
+ def set_atom_rs_label(self, atom_item: Any, label: Optional[str]) -> None:
1873
+ """Store R/S *label* (None clears it) on the atom's data and item."""
1874
+ record = self.data.atoms.get(atom_item.atom_id)
1875
+ if record is None:
1876
+ return
1877
+ if label in RS_LABELS:
1878
+ record["rs"] = label
1879
+ else:
1880
+ record.pop("rs", None)
1881
+ label = None
1882
+ atom_item.prepareGeometryChange()
1883
+ atom_item.rs_label = label
1884
+ atom_item.update()
1885
+
1886
+ def apply_rs_label(self, atom_item: Any, label: Optional[str]) -> bool:
1887
+ """Give *atom_item* R/S *label* (None clears it); True if anything changed.
1888
+
1889
+ A label overrides the wedges at its center. Where the center already
1890
+ has wedges or hashes, they are redrawn to agree with the label; a
1891
+ center without them keeps its plain bonds. An atom that cannot be R/S
1892
+ (not a stereocenter) is left alone with a status-bar note.
1893
+ """
1894
+ if atom_item is None or atom_item.atom_id not in self.data.atoms:
1895
+ return False
1896
+ if getattr(atom_item, "rs_label", None) == label:
1897
+ return False
1898
+ atom_id = atom_item.atom_id
1899
+ if label is None:
1900
+ self.set_atom_rs_label(atom_item, None)
1901
+ return True
1902
+ record = self.data.atoms[atom_id]
1903
+ previous = record.get("rs")
1904
+ record["rs"] = label
1905
+ try:
1906
+ reachable = drawn_chirality(self.data).get(atom_id) == label
1907
+ finally:
1908
+ if previous is None:
1909
+ record.pop("rs", None)
1910
+ else:
1911
+ record["rs"] = previous
1912
+ if not reachable:
1913
+ self.window.statusBar().showMessage(
1914
+ f"{atom_item.symbol}{atom_id} is not a stereocenter; no R/S label set.",
1915
+ 4000,
1916
+ )
1917
+ return False
1918
+ bond_changes = align_wedges_to_label(self.data, atom_id, label)
1919
+ self.set_atom_rs_label(atom_item, label)
1920
+ for key, stereo in bond_changes.items():
1921
+ self._set_bond_stereo_by_key(key, stereo)
1922
+ return True
1923
+
1924
+ def _set_bond_stereo_by_key(self, key: Tuple[int, int], stereo: int) -> None:
1925
+ """Set a bond's stereo in data and scene, keeping plain-bond keys sorted."""
1926
+ bond_data = self.data.bonds.get(key)
1927
+ bond_item = self.bond_items.get(key)
1928
+ if bond_data is None:
1929
+ return
1930
+ bond_data["stereo"] = stereo
1931
+ new_key = key
1932
+ if stereo == 0 and key[0] > key[1]:
1933
+ new_key = (key[1], key[0])
1934
+ self.data.bonds[new_key] = self.data.bonds.pop(key)
1935
+ if bond_item is None:
1936
+ return
1937
+ if new_key != key:
1938
+ self.rekey_bond_item(bond_item, new_key)
1939
+ bond_item.atom1, bond_item.atom2 = bond_item.atom2, bond_item.atom1
1940
+ bond_item.update_position()
1941
+ bond_item.set_stereo(stereo)
1942
+
1943
+
1944
+ def next_rs_label(current: Optional[str]) -> Optional[str]:
1945
+ """The R/S tool's click cycle: none -> R -> S -> none."""
1946
+ return {None: "R", "R": "S"}.get(current)
@@ -25,6 +25,8 @@ from PyQt6.QtWidgets import (
25
25
  QGraphicsSceneMouseEvent,
26
26
  )
27
27
 
28
+ from ..core.molecular_data import rs_entry
29
+
28
30
  if TYPE_CHECKING:
29
31
  from ..core.molecular_data import MolecularData
30
32
  from .main_window import MainWindow
@@ -36,7 +38,12 @@ from .template_preview_item import TemplatePreviewItem
36
38
 
37
39
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
38
40
 
39
- from .molecular_scene_handler import TemplateMixin, KeyboardMixin, SceneQueryMixin
41
+ from .molecular_scene_handler import (
42
+ KeyboardMixin,
43
+ SceneQueryMixin,
44
+ TemplateMixin,
45
+ next_rs_label,
46
+ )
40
47
  from .chain_mixin import ChainMixin
41
48
 
42
49
 
@@ -126,7 +133,7 @@ class MoleculeScene(
126
133
  return
127
134
 
128
135
  # 1. Generate RDKit molecule for topology analysis
129
- mol = self.data.to_rdkit_mol(use_2d_stereo=False)
136
+ mol = self.data.to_rdkit_mol(use_2d_stereo=False, use_rs_labels=False)
130
137
  if not mol:
131
138
  # Fallback: reset all ring info if molecule generation fails
132
139
  for bond_item in self.bond_items.values():
@@ -231,7 +238,9 @@ class MoleculeScene(
231
238
  "pos": raw_pos,
232
239
  "charge": charge,
233
240
  "radical": radical,
241
+ **rs_entry(data),
234
242
  }
243
+ atom_item.rs_label = self.data.atoms[atom_id].get("rs")
235
244
  self.atom_items[atom_id] = atom_item
236
245
  self.addItem(atom_item)
237
246
 
@@ -276,7 +285,9 @@ class MoleculeScene(
276
285
  "pos": raw_pos,
277
286
  "charge": charge,
278
287
  "radical": radical,
288
+ **rs_entry(atom_data),
279
289
  }
290
+ atom_item.rs_label = self.data.atoms[atom_id].get("rs")
280
291
  self.atom_items[atom_id] = atom_item
281
292
  self.addItem(atom_item)
282
293
 
@@ -439,7 +450,9 @@ class MoleculeScene(
439
450
  if (
440
451
  len(selected_items) > 1
441
452
  and item in selected_items
442
- and not self.mode.startswith(("template", "charge", "radical"))
453
+ and not self.mode.startswith(
454
+ ("template", "charge", "radical", "rs_label")
455
+ )
443
456
  ):
444
457
  # Delete the entire rectangular selection
445
458
  data_changed = self.delete_items(set(selected_items))
@@ -484,6 +497,9 @@ class MoleculeScene(
484
497
  self.data.atoms[item.atom_id]["radical"] = 0
485
498
  item.update_style()
486
499
  data_changed = True
500
+ # Clear the R/S label if in R/S mode
501
+ elif self.mode == "rs_label" and item.rs_label is not None:
502
+ data_changed = self.apply_rs_label(item, None)
487
503
  # Set charge to 0 if in charge mode
488
504
  elif self.mode in ["charge_plus", "charge_minus"] and item.charge != 0:
489
505
  item.prepareGeometryChange()
@@ -492,11 +508,15 @@ class MoleculeScene(
492
508
  item.update_style()
493
509
  data_changed = True
494
510
  # Delete atom in modes other than those above (excluding template, charge, radical)
495
- elif not self.mode.startswith(("template", "charge", "radical")):
511
+ elif not self.mode.startswith(
512
+ ("template", "charge", "radical", "rs_label")
513
+ ):
496
514
  data_changed = self.delete_items({item})
497
515
  elif isinstance(item, BondItem):
498
516
  # Delete bond in modes other than template, charge, radical
499
- if not self.mode.startswith(("template", "charge", "radical")):
517
+ if not self.mode.startswith(
518
+ ("template", "charge", "radical", "rs_label")
519
+ ):
500
520
  data_changed = self.delete_items({item})
501
521
 
502
522
  if data_changed:
@@ -692,6 +712,19 @@ class MoleculeScene(
692
712
  self.press_pos = None
693
713
  self.window.edit_actions_manager.push_undo_state()
694
714
  return
715
+ elif (
716
+ self.mode == "rs_label" and is_click and isinstance(released_item, AtomItem)
717
+ ):
718
+ changed = self.apply_rs_label(
719
+ released_item, next_rs_label(released_item.rs_label)
720
+ )
721
+ self.start_atom = None
722
+ self.start_pos = None
723
+ self.press_pos = None
724
+ if changed:
725
+ self.update_all_items()
726
+ self.window.edit_actions_manager.push_undo_state()
727
+ return
695
728
  elif (
696
729
  (self.mode == "charge_plus" or self.mode == "charge_minus")
697
730
  and is_click
@@ -922,6 +955,13 @@ class MoleculeScene(
922
955
  """Handle double click events."""
923
956
  item = self.itemAt(event.scenePos(), self.views()[0].transform())
924
957
 
958
+ if self.mode == "rs_label" and isinstance(item, AtomItem):
959
+ # The second click of a double-click advances the cycle once more.
960
+ if self.apply_rs_label(item, next_rs_label(item.rs_label)):
961
+ self.update_all_items()
962
+ self.window.edit_actions_manager.push_undo_state()
963
+ event.accept()
964
+ return
925
965
  if self.mode in ["charge_plus", "charge_minus", "radical"] and isinstance(
926
966
  item, AtomItem
927
967
  ):
@@ -85,7 +85,7 @@ class UIManager(QObject):
85
85
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.ArrowCursor)
86
86
  elif mode_str.startswith(("atom", "bond", "template", "chain")):
87
87
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.CrossCursor)
88
- elif mode_str.startswith(("charge", "radical")):
88
+ elif mode_str.startswith(("charge", "radical", "rs_label")):
89
89
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.CrossCursor)
90
90
  else:
91
91
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.ArrowCursor)
@@ -139,6 +139,11 @@ class UIManager(QObject):
139
139
  elif mode_str == "radical":
140
140
  self.host.update_status_message("Mode: Toggle Radical (Click on Atom)")
141
141
  self.host.init_manager.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
142
+ elif mode_str == "rs_label":
143
+ self.host.update_status_message(
144
+ "Mode: Toggle R/S (Click on a stereocenter: R -> S -> none)"
145
+ )
146
+ self.host.init_manager.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
142
147
 
143
148
  else: # Select mode
144
149
  self.host.update_status_message("Mode: Select")
@@ -1935,13 +1935,18 @@ class View3DManager:
1935
1935
  tp.ItalicOff()
1936
1936
  tp.ShadowOff()
1937
1937
 
1938
- # Add orientation marker widget
1939
- self.axes_widget = vtk.vtkOrientationMarkerWidget()
1940
- self.axes_widget.SetOrientationMarker(axes)
1941
- self.axes_widget.SetInteractor(self.plotter.interactor) # type: ignore[union-attr]
1942
- self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2)
1943
- self.axes_widget.SetEnabled(True)
1944
- self.axes_widget.InteractiveOff()
1938
+ # plotter.interactor is the Qt widget, which VTK rejects;
1939
+ # iren.interactor is the underlying vtkRenderWindowInteractor.
1940
+ # Publish the widget only once fully set up, so a failure here
1941
+ # never leaves an interactor-less widget for the next call to
1942
+ # SetEnabled(False) on.
1943
+ axes_widget = vtk.vtkOrientationMarkerWidget()
1944
+ axes_widget.SetOrientationMarker(axes)
1945
+ axes_widget.SetInteractor(self.plotter.iren.interactor) # type: ignore[union-attr]
1946
+ axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2)
1947
+ axes_widget.SetEnabled(True)
1948
+ axes_widget.InteractiveOff()
1949
+ self.axes_widget = axes_widget
1945
1950
  else:
1946
1951
  self.plotter.hide_axes() # type: ignore[call-arg, union-attr]
1947
1952
 
@@ -127,7 +127,7 @@ class ZoomableView(QGraphicsView):
127
127
  self.setCursor(Qt.CursorShape.ArrowCursor)
128
128
  elif current_mode.startswith(("atom", "bond", "template", "chain")):
129
129
  self.setCursor(Qt.CursorShape.CrossCursor)
130
- elif current_mode.startswith(("charge", "radical")):
130
+ elif current_mode.startswith(("charge", "radical", "rs_label")):
131
131
  self.setCursor(Qt.CursorShape.CrossCursor)
132
132
  else:
133
133
  self.setCursor(Qt.CursorShape.ArrowCursor)