MoleditPy-linux 4.11.5__tar.gz → 4.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/core/molecular_data.py +242 -0
  5. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/core/stereo_check.py +69 -0
  6. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/chirality_warning_dialog.py +37 -10
  7. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/compute_logic.py +4 -1
  8. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/view_3d_logic.py +23 -15
  9. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/LICENSE +0 -0
  10. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/README.md +0 -0
  11. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/setup.cfg +0 -0
  12. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  13. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  14. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  15. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  16. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  17. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/__init__.py +0 -0
  18. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/__main__.py +0 -0
  19. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  20. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/assets/icon.icns +0 -0
  21. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/assets/icon.ico +0 -0
  22. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/assets/icon.png +0 -0
  23. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/core/__init__.py +0 -0
  24. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  25. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/main.py +0 -0
  26. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/plugins/__init__.py +0 -0
  27. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  28. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  29. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  30. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/__init__.py +0 -0
  31. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  32. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  33. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  34. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  35. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  36. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/app_state.py +0 -0
  37. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/atom_item.py +0 -0
  38. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  39. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  40. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/bond_item.py +0 -0
  41. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  42. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  43. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  44. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  45. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  46. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  47. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  48. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  49. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  50. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  51. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  52. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  53. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/export_logic.py +0 -0
  54. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  55. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/io_logic.py +0 -0
  56. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/main_window.py +0 -0
  57. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  58. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  59. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  60. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  61. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/move_dialog_mixin.py +0 -0
  62. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  63. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  64. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  66. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  67. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
  68. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/sdf_record_dialog.py +0 -0
  69. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  70. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  71. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
  72. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  73. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  74. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/settings_labels_tab.py +0 -0
  75. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  76. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  77. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/string_importers.py +0 -0
  78. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  79. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  80. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  81. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  82. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  83. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  84. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/__init__.py +0 -0
  85. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/constants.py +0 -0
  86. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/default_settings.py +0 -0
  87. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/finite_float.py +0 -0
  88. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  89. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/label_style.py +0 -0
  90. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/sdf_records.py +0 -0
  91. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  92. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  93. {moleditpy_linux-4.11.5 → moleditpy_linux-4.12.1}/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.5
3
+ Version: 4.12.1
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.11.5"
8
+ version = "4.12.1"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.11.5
3
+ Version: 4.12.1
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
@@ -12,6 +12,7 @@ DOI: 10.5281/zenodo.17268532
12
12
 
13
13
  from __future__ import annotations
14
14
  import logging
15
+ import math
15
16
  from typing import Any, Dict, List, Optional, Tuple, Union
16
17
  from rdkit import Chem
17
18
  from rdkit.Chem import rdCIPLabeler
@@ -150,6 +151,245 @@ def align_ez_to_cip(mol: Chem.Mol, wanted: Dict[int, str]) -> None:
150
151
  bond.SetStereo(flipped[bond.GetStereo()])
151
152
 
152
153
 
154
+ _WEDGE_Z = {Chem.BondDir.BEGINWEDGE: 1.0, Chem.BondDir.BEGINDASH: -1.0}
155
+
156
+ # How far past a straight line the two bonds without a wedge may open and
157
+ # still count as a T-shape rather than a fan the wedge sits inside.
158
+ _T_SHAPE_TOLERANCE_DEG = 5.0
159
+
160
+
161
+ def _tag_from_points(
162
+ mol: Chem.Mol,
163
+ center: int,
164
+ points: Dict[int, Tuple[float, float, float]],
165
+ virtual: Optional[Tuple[float, float, float]] = None,
166
+ ) -> Chem.ChiralType:
167
+ """Chiral tag of *center* with its neighbors at *points* (center-relative).
168
+
169
+ *virtual* is the position of an implicit H or lone pair. The center is
170
+ placed at the centroid of the four positions, inside the tetrahedron, so
171
+ any three neighbor vectors are independent.
172
+ """
173
+ corners = list(points.values()) + ([virtual] if virtual is not None else [])
174
+ centroid = tuple(sum(p[k] for p in corners) / len(corners) for k in range(3))
175
+ probe = Chem.Mol(mol)
176
+ conf = Chem.Conformer(probe.GetNumAtoms())
177
+ conf.Set3D(True)
178
+ for idx, pos in points.items():
179
+ conf.SetAtomPosition(idx, pos)
180
+ conf.SetAtomPosition(center, centroid)
181
+ probe.RemoveAllConformers()
182
+ conf_id = probe.AddConformer(conf, assignId=True)
183
+ Chem.AssignAtomChiralTagsFromStructure(probe, confId=conf_id)
184
+ return probe.GetAtomWithIdx(center).GetChiralTag()
185
+
186
+
187
+ def _largest_gap_deg(directions: List[Tuple[float, float]]) -> float:
188
+ """Largest angle between angularly adjacent 2D directions, in degrees."""
189
+ angles = sorted(math.degrees(math.atan2(dy, dx)) % 360.0 for dx, dy in directions)
190
+ gaps = [b - a for a, b in zip(angles, angles[1:])]
191
+ gaps.append(360.0 - angles[-1] + angles[0])
192
+ return max(gaps)
193
+
194
+
195
+ def wedge_reading(mol: Chem.Mol, center: int) -> Optional[Chem.ChiralType]:
196
+ """Chiral tag the wedges/hashes starting at *center* specify, or None.
197
+
198
+ Reads the drawing the way a chemist does: plain bonds in the paper plane,
199
+ a wedge toward the viewer, a hash away. With three drawn neighbors the
200
+ implicit H (or lone pair) points opposite the wedges. With four, each
201
+ stereo bond is read in turn as the one out of the plane; the reading
202
+ counts only when they all agree.
203
+
204
+ None means the drawing does not decide: no stereo bond starts here, a
205
+ wedge and a hash cancel on a three-neighbor center, the wedge sits inside
206
+ a fan of under 180 degrees, or the stereo bonds of a four-neighbor center
207
+ contradict each other (a wedge and a hash on opposite bonds).
208
+ """
209
+ atom = mol.GetAtomWithIdx(center)
210
+ if atom.GetDegree() not in (3, 4) or mol.GetNumConformers() == 0:
211
+ return None
212
+ conf = mol.GetConformer()
213
+ origin = conf.GetAtomPosition(center)
214
+ directions: Dict[int, Tuple[float, float]] = {}
215
+ lift: Dict[int, float] = {}
216
+ for bond in atom.GetBonds():
217
+ nbr = bond.GetOtherAtomIdx(center)
218
+ pos = conf.GetAtomPosition(nbr)
219
+ dx, dy = pos.x - origin.x, pos.y - origin.y
220
+ length = math.hypot(dx, dy)
221
+ if length < 1e-6:
222
+ return None
223
+ directions[nbr] = (dx / length, dy / length)
224
+ lift[nbr] = (
225
+ _WEDGE_Z.get(bond.GetBondDir(), 0.0)
226
+ if bond.GetBeginAtomIdx() == center
227
+ else 0.0
228
+ )
229
+ stereo_nbrs = [nbr for nbr, z in lift.items() if z]
230
+ if not stereo_nbrs:
231
+ return None
232
+ flat = {nbr: (dx, dy, 0.0) for nbr, (dx, dy) in directions.items()}
233
+
234
+ if len(directions) == 3:
235
+ net = sum(lift.values())
236
+ if not net:
237
+ return None
238
+ if _largest_gap_deg(list(directions.values())) > 180.0 + _T_SHAPE_TOLERANCE_DEG:
239
+ return None
240
+ return _tag_from_points(mol, center, flat, (0.0, 0.0, -math.copysign(1.0, net)))
241
+
242
+ readings = set()
243
+ for nbr in stereo_nbrs:
244
+ points = dict(flat)
245
+ points[nbr] = (0.0, 0.0, lift[nbr])
246
+ readings.add(_tag_from_points(mol, center, points))
247
+ return readings.pop() if len(readings) == 1 else None
248
+
249
+
250
+ def _bond_states(mol: Chem.Mol, indices: List[int]) -> List[Tuple[int, int, Any]]:
251
+ """Begin, end and direction of the bonds at *indices*."""
252
+ return [
253
+ (
254
+ mol.GetBondWithIdx(i).GetBeginAtomIdx(),
255
+ mol.GetBondWithIdx(i).GetEndAtomIdx(),
256
+ mol.GetBondWithIdx(i).GetBondDir(),
257
+ )
258
+ for i in indices
259
+ ]
260
+
261
+
262
+ def _rdkit_tags(mol: Chem.Mol) -> List[Chem.ChiralType]:
263
+ """Chiral tag RDKit's wedge perception gives each atom of *mol*."""
264
+ probe = Chem.Mol(mol)
265
+ for atom in probe.GetAtoms():
266
+ atom.SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED)
267
+ Chem.AssignChiralTypesFromBondDirs(probe)
268
+ return [atom.GetChiralTag() for atom in probe.GetAtoms()]
269
+
270
+
271
+ def _clear_own_wedges(mol: Chem.Mol, centers: Dict[int, Chem.ChiralType]) -> None:
272
+ """Remove the wedges/hashes that start at *centers*."""
273
+ for center in centers:
274
+ for bond in mol.GetAtomWithIdx(center).GetBonds():
275
+ if bond.GetBeginAtomIdx() == center and bond.GetBondDir() in _WEDGE_Z:
276
+ bond.SetBondDir(Chem.BondDir.NONE)
277
+
278
+
279
+ def _rdkit_rewedge(mol: Chem.Mol, fixes: Dict[int, Chem.ChiralType]) -> None:
280
+ """Let RDKit wedge each fixed center for its wanted tag (may reverse bonds)."""
281
+ _clear_own_wedges(mol, fixes)
282
+ for center, tag in fixes.items():
283
+ mol.GetAtomWithIdx(center).SetChiralTag(tag)
284
+ Chem.WedgeMolBonds(mol, mol.GetConformer())
285
+ for center in fixes:
286
+ mol.GetAtomWithIdx(center).SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED)
287
+
288
+
289
+ def _pick_wedges(mol: Chem.Mol, fixes: Dict[int, Chem.ChiralType]) -> None:
290
+ """Wedge or hash one free bond per fixed center so it reads its wanted tag.
291
+
292
+ A candidate counts only when RDKit and wedge_reading() agree on it.
293
+ """
294
+ for center, tag in fixes.items():
295
+ for bond in mol.GetAtomWithIdx(center).GetBonds():
296
+ if (
297
+ bond.GetBeginAtomIdx() != center
298
+ or bond.GetBondDir() != Chem.BondDir.NONE
299
+ or bond.GetBondType() != Chem.BondType.SINGLE
300
+ ):
301
+ continue
302
+ for direction in _WEDGE_Z:
303
+ bond.SetBondDir(direction)
304
+ if (
305
+ _rdkit_tags(mol)[center] == tag
306
+ and wedge_reading(mol, center) == tag
307
+ ):
308
+ break
309
+ bond.SetBondDir(Chem.BondDir.NONE)
310
+ else:
311
+ continue
312
+ break
313
+
314
+
315
+ def resolve_wedge_chirality(mol: Chem.Mol) -> None:
316
+ """Re-wedge stereocenters whose drawn wedges RDKit reads differently.
317
+
318
+ RDKit's wedge perception is unstable on some unambiguous drawings: with
319
+ the two plain bonds of a three-neighbor center drawn in a straight line
320
+ (a T-shape, which the editor's angle snapping produces), it reports
321
+ "ambiguous stereochemistry" or inverts the center, while the same drawing
322
+ bent by a few degrees reads correctly. Where wedge_reading() gives a
323
+ definite answer that RDKit's perception does not reproduce, the wedges at
324
+ that center are replaced by ones RDKit picks for the intended
325
+ configuration on the same coordinates (or, where RDKit declines, a wedge
326
+ or hash on a free bond of the center that both readings agree on), so
327
+ every later reader (MOL block, 3D embedding, stereo check) gets the drawn
328
+ isomer. Nothing is changed unless every other stereocenter and every
329
+ bond away from the fixed centers stays exactly as drawn.
330
+ """
331
+ if mol.GetNumConformers() == 0:
332
+ return
333
+ centers = sorted(
334
+ {b.GetBeginAtomIdx() for b in mol.GetBonds() if b.GetBondDir() in _WEDGE_Z}
335
+ )
336
+ if not centers:
337
+ return
338
+ try:
339
+ read = _rdkit_tags(mol)
340
+ fixes: Dict[int, Chem.ChiralType] = {}
341
+ for center in centers:
342
+ wanted = wedge_reading(mol, center)
343
+ if wanted is not None and wanted != read[center]:
344
+ fixes[center] = wanted
345
+ if not fixes:
346
+ return
347
+
348
+ # Bonds the re-wedge must not touch: everything away from the fixed
349
+ # centers, and any wedge/hash an adjacent center draws onto a bond it
350
+ # shares with a fixed center.
351
+ protected = [
352
+ b.GetIdx()
353
+ for b in mol.GetBonds()
354
+ if b.GetBeginAtomIdx() not in fixes
355
+ and (b.GetEndAtomIdx() not in fixes or b.GetBondDir() in _WEDGE_Z)
356
+ ]
357
+ before = _bond_states(mol, protected)
358
+ expected = [fixes.get(i, tag) for i, tag in enumerate(read)]
359
+
360
+ def holds(trial: Chem.Mol) -> bool:
361
+ """Fixed centers read right; every other center and bond is as drawn."""
362
+ return (
363
+ _bond_states(trial, protected) == before
364
+ and _rdkit_tags(trial) == expected
365
+ )
366
+
367
+ # WedgeMolBonds may reverse bonds, which cannot be undone, so try it
368
+ # on a copy and touch the real molecule only once it holds.
369
+ trial = Chem.Mol(mol)
370
+ _rdkit_rewedge(trial, fixes)
371
+ if holds(trial):
372
+ _rdkit_rewedge(mol, fixes)
373
+ return
374
+ # WedgeMolBonds declines some centers, e.g. a T-shape whose one
375
+ # unambiguous bond already carries a neighbor's hash. Try each free
376
+ # bond of the center instead; this never reverses a bond.
377
+ trial = Chem.Mol(mol)
378
+ _clear_own_wedges(trial, fixes)
379
+ _pick_wedges(trial, fixes)
380
+ if holds(trial):
381
+ for center in fixes:
382
+ for bond in mol.GetAtomWithIdx(center).GetBonds():
383
+ if bond.GetBeginAtomIdx() == center:
384
+ bond.SetBondDir(
385
+ trial.GetBondWithIdx(bond.GetIdx()).GetBondDir()
386
+ )
387
+ return
388
+ logging.debug("Re-wedging stereocenters %s did not hold", sorted(fixes))
389
+ except (RuntimeError, ValueError) as e:
390
+ logging.warning("Resolving wedge stereochemistry failed: %s", e)
391
+
392
+
153
393
  class MolecularData:
154
394
  """In-memory graph of atoms and bonds, independent of any UI framework."""
155
395
 
@@ -449,6 +689,8 @@ class MolecularData:
449
689
  ).get("type") not in (1, 2):
450
690
  adjacent.SetBondDir(Chem.BondDir.NONE)
451
691
 
692
+ resolve_wedge_chirality(final_mol)
693
+
452
694
  # The labels are CIP E/Z; the neighbors picked above are not always
453
695
  # the CIP-higher ones.
454
696
  align_ez_to_cip(final_mol, ez_wanted)
@@ -113,6 +113,75 @@ def find_chirality_mismatches(
113
113
  ]
114
114
 
115
115
 
116
+ def ambiguous_wedge_centers(data: MolecularData) -> List[int]:
117
+ """Atom IDs of stereocenters whose wedges/hashes specify no configuration.
118
+
119
+ These are drawings RDKit rejects as ambiguous (for example a wedge and a
120
+ hash on opposite bonds), so the 3D structure gets an arbitrary
121
+ configuration there. Atoms that are not stereocenters are not reported.
122
+ """
123
+ if not any(bond.get("stereo") in (1, 2) for bond in data.bonds.values()):
124
+ return []
125
+ mol = data.to_rdkit_mol(use_2d_stereo=False)
126
+ if mol is None:
127
+ return []
128
+ probe = Chem.Mol(mol)
129
+ Chem.AssignChiralTypesFromBondDirs(probe)
130
+ centers = {
131
+ bond.GetBeginAtomIdx()
132
+ for bond in mol.GetBonds()
133
+ if bond.GetBondDir() in (Chem.BondDir.BEGINWEDGE, Chem.BondDir.BEGINDASH)
134
+ }
135
+ found: List[int] = []
136
+ for idx in sorted(centers):
137
+ atom = probe.GetAtomWithIdx(idx)
138
+ if atom.GetChiralTag() != Chem.ChiralType.CHI_UNSPECIFIED:
139
+ continue
140
+ # A stereocenter keeps an arbitrary tag through perception.
141
+ trial = Chem.Mol(mol)
142
+ trial.GetAtomWithIdx(idx).SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CW)
143
+ Chem.AssignStereochemistry(trial, cleanIt=True, force=True)
144
+ trial_atom = trial.GetAtomWithIdx(idx)
145
+ if trial_atom.GetChiralTag() != Chem.ChiralType.CHI_UNSPECIFIED and (
146
+ trial_atom.HasProp("_original_atom_id")
147
+ ):
148
+ found.append(trial_atom.GetIntProp("_original_atom_id"))
149
+ return found
150
+
151
+
152
+ def find_ambiguous_centers(
153
+ data: MolecularData, mol_3d: Chem.Mol
154
+ ) -> List[ChiralityMismatch]:
155
+ """Ambiguously wedged stereocenters, with ``drawn`` set to "ambiguous".
156
+
157
+ ``actual`` is the configuration the 3D structure happened to get.
158
+ """
159
+ try:
160
+ atom_ids = ambiguous_wedge_centers(data)
161
+ if not atom_ids:
162
+ return []
163
+ actual = actual_chirality(mol_3d)
164
+ except (RuntimeError, ValueError) as e:
165
+ logging.warning("Ambiguous wedge check failed: %s", e)
166
+ return []
167
+ index_of = {
168
+ atom.GetIntProp("_original_atom_id"): atom.GetIdx()
169
+ for atom in mol_3d.GetAtoms()
170
+ if atom.HasProp("_original_atom_id")
171
+ }
172
+ return [
173
+ ChiralityMismatch(
174
+ atom_id=atom_id,
175
+ symbol=str(data.atoms[atom_id]["symbol"]),
176
+ rdkit_index=index_of.get(atom_id),
177
+ drawn="ambiguous",
178
+ actual=actual.get(atom_id),
179
+ )
180
+ for atom_id in atom_ids
181
+ if atom_id in data.atoms
182
+ ]
183
+
184
+
116
185
  def _bond_cip_by_atom_ids(
117
186
  mol: Chem.Mol,
118
187
  ) -> Dict[FrozenSet[int], Tuple[int, str]]:
@@ -88,16 +88,47 @@ class ChiralityWarningDialog(QDialog):
88
88
  )
89
89
  return rows
90
90
 
91
+ def _summary_text(self, row_count: int, total: int) -> str:
92
+ """Rich-text summary: wrong elements, then ambiguously drawn centers."""
93
+ ambiguous = sum(1 for m in self.mismatches if m.drawn == "ambiguous")
94
+ wrong = row_count - ambiguous
95
+ parts = []
96
+ if wrong:
97
+ parts.append(
98
+ f"<b>The 3D structure is wrong.</b><br>"
99
+ f"{wrong} of {total} stereo element(s) drawn in 2D (R/S centers, "
100
+ f"E/Z double bonds) have a different configuration in 3D."
101
+ )
102
+ if ambiguous:
103
+ parts.append(
104
+ f"<b>{ambiguous} stereocenter(s) are drawn ambiguously</b> "
105
+ f"(for example a wedge and a hash on opposite bonds), so the "
106
+ f"wedges do not specify a configuration and the 3D one shown is "
107
+ f"arbitrary. Redraw them with one wedge or hash, or with the "
108
+ f"wedge and hash on adjacent bonds."
109
+ )
110
+ return "<br><br>".join(parts)
111
+
112
+ def _hint_text(self, row_count: int) -> str:
113
+ """What to do next; flipping applies only to wrong, not ambiguous, rows."""
114
+ ambiguous = sum(1 for m in self.mismatches if m.drawn == "ambiguous")
115
+ labels = (
116
+ "While this window is open, the 3D view shows chiral and E/Z labels: "
117
+ "listed ones in red, the others in the usual color."
118
+ )
119
+ if row_count == ambiguous:
120
+ return labels
121
+ flip = "Please flip the wrong ones manually in the 3D structure."
122
+ if not ambiguous:
123
+ flip = "Please flip these manually in the 3D structure."
124
+ return f"{flip} {labels}"
125
+
91
126
  def _build_ui(self, total: int) -> None:
92
127
  """Lay out the summary, the per-item table and the close button."""
93
128
  layout = QVBoxLayout(self)
94
129
 
95
130
  rows = self._rows()
96
- summary = QLabel(
97
- f"<b>The 3D structure is wrong.</b><br>"
98
- f"{len(rows)} of {total} stereo element(s) drawn in 2D (R/S centers, "
99
- f"E/Z double bonds) have a different configuration in 3D."
100
- )
131
+ summary = QLabel(self._summary_text(len(rows), total))
101
132
  summary.setWordWrap(True)
102
133
  layout.addWidget(summary)
103
134
 
@@ -115,11 +146,7 @@ class ChiralityWarningDialog(QDialog):
115
146
  header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
116
147
  layout.addWidget(table)
117
148
 
118
- hint = QLabel(
119
- "Please flip these manually in the 3D structure. While this window "
120
- "is open, the 3D view shows chiral and E/Z labels: wrong ones in "
121
- "red, correct ones in the usual color."
122
- )
149
+ hint = QLabel(self._hint_text(len(rows)))
123
150
  hint.setWordWrap(True)
124
151
  layout.addWidget(hint)
125
152
 
@@ -41,6 +41,7 @@ from ..core.mol_geometry import (
41
41
  from ..core.stereo_check import (
42
42
  drawn_chirality,
43
43
  drawn_ez,
44
+ find_ambiguous_centers,
44
45
  find_chirality_mismatches,
45
46
  find_ez_mismatches,
46
47
  )
@@ -703,6 +704,7 @@ class ComputeManager:
703
704
  return
704
705
 
705
706
  mismatches = find_chirality_mismatches(data, mol)
707
+ mismatches += find_ambiguous_centers(data, mol)
706
708
  ez_mismatches = find_ez_mismatches(data, mol)
707
709
  if not mismatches and not ez_mismatches:
708
710
  return
@@ -718,7 +720,8 @@ class ComputeManager:
718
720
  count = len(mismatches) + len(ez_mismatches)
719
721
  if report_status:
720
722
  self.host.update_status_message(
721
- f"Warning: {count} stereo element(s) differ from the 2D drawing."
723
+ f"Warning: {count} stereo element(s) differ from the 2D drawing "
724
+ "or are drawn ambiguously."
722
725
  )
723
726
 
724
727
  def on_calculation_error(self, message: Union[str, Tuple[int, str]]) -> None:
@@ -1798,13 +1798,22 @@ class View3DManager:
1798
1798
 
1799
1799
  def fit_to_view(self) -> None:
1800
1800
  """Fit all items in the scene into the view"""
1801
- if not self.host.init_manager.scene.items():
1801
+ try:
1802
+ old_ta = self.host.init_manager.view_2d.transformationAnchor()
1803
+ old_ra = self.host.init_manager.view_2d.resizeAnchor()
1804
+ items = self.host.init_manager.scene.items()
1805
+ except (AttributeError, RuntimeError, TypeError) as e:
1806
+ # Deferred calls (QTimer.singleShot after a load) can land after
1807
+ # the window is torn down; a deleted view cannot be fitted or reset.
1808
+ logging.debug("fit_to_view skipped, 2D view unavailable: %s", e)
1809
+ return
1810
+ if not items:
1802
1811
  self.reset_zoom()
1803
1812
  return
1804
1813
 
1805
1814
  # Calculate total bounding rect of visible items
1806
1815
  visible_items_rect = QRectF()
1807
- for item in self.host.init_manager.scene.items():
1816
+ for item in items:
1808
1817
  if item.isVisible() and not isinstance(item, TemplatePreviewItem):
1809
1818
  if visible_items_rect.isEmpty():
1810
1819
  visible_items_rect = item.sceneBoundingRect()
@@ -1826,12 +1835,6 @@ class View3DManager:
1826
1835
  padded = QRectF(cx - w / 2.0, cy - h / 2.0, w, h)
1827
1836
 
1828
1837
  # Temporarily set anchor to center before calling fitInView
1829
- try:
1830
- old_ta = self.host.init_manager.view_2d.transformationAnchor()
1831
- old_ra = self.host.init_manager.view_2d.resizeAnchor()
1832
- except (AttributeError, RuntimeError, TypeError):
1833
- old_ta = old_ra = None
1834
-
1835
1838
  try:
1836
1839
  self.host.init_manager.view_2d.setTransformationAnchor(
1837
1840
  QGraphicsView.ViewportAnchor.AnchorViewCenter
@@ -1932,13 +1935,18 @@ class View3DManager:
1932
1935
  tp.ItalicOff()
1933
1936
  tp.ShadowOff()
1934
1937
 
1935
- # Add orientation marker widget
1936
- self.axes_widget = vtk.vtkOrientationMarkerWidget()
1937
- self.axes_widget.SetOrientationMarker(axes)
1938
- self.axes_widget.SetInteractor(self.plotter.interactor) # type: ignore[union-attr]
1939
- self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2)
1940
- self.axes_widget.SetEnabled(True)
1941
- 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
1942
1950
  else:
1943
1951
  self.plotter.hide_axes() # type: ignore[call-arg, union-attr]
1944
1952