MoleditPy-linux 4.5.0__tar.gz → 4.5.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 (81) hide show
  1. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/core/molecular_data.py +111 -34
  5. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/main.py +2 -2
  6. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/angle_dialog.py +22 -0
  7. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/bond_length_dialog.py +47 -1
  8. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/compute_logic.py +25 -0
  9. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/dihedral_dialog.py +23 -0
  10. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/edit_actions_logic.py +17 -22
  11. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/io_logic.py +31 -18
  12. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +2 -2
  13. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/string_importers.py +1 -0
  14. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/LICENSE +0 -0
  15. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/README.md +0 -0
  16. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/setup.cfg +0 -0
  17. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  18. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  19. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  20. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  21. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  22. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/__init__.py +0 -0
  23. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/__main__.py +0 -0
  24. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  25. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/assets/icon.icns +0 -0
  26. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/assets/icon.ico +0 -0
  27. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/assets/icon.png +0 -0
  28. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/core/__init__.py +0 -0
  29. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  30. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/plugins/__init__.py +0 -0
  31. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  32. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  33. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  34. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/__init__.py +0 -0
  35. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  36. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  37. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  38. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  39. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/app_state.py +0 -0
  40. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/atom_item.py +0 -0
  41. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  42. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  43. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/bond_item.py +0 -0
  44. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  45. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  46. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  47. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  48. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  49. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  50. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  51. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  52. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/export_logic.py +0 -0
  53. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  54. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/main_window.py +0 -0
  55. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  56. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  57. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  58. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  59. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  60. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  61. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  62. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  63. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  64. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  65. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  66. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  67. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  68. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  69. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  70. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  71. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  72. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  73. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  74. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  75. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  76. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/utils/__init__.py +0 -0
  77. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/utils/constants.py +0 -0
  78. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/utils/default_settings.py +0 -0
  79. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  80. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.1}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  81. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.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.5.0
3
+ Version: 4.5.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.5.0"
8
+ version = "4.5.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.5.0
3
+ Version: 4.5.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
@@ -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 = ""
@@ -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(
@@ -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
+ )
@@ -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)
@@ -35,6 +35,7 @@ from ..core.mol_geometry import (
35
35
  calculate_dihedral,
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
@@ -469,5 +470,27 @@ class DihedralDialog(GeometryBaseDialog):
469
470
  positions, idx1, idx2, idx3, idx4, new_dihedral_deg, atoms_to_move
470
471
  )
471
472
 
473
+ achieved = calculate_dihedral(positions, idx1, idx2, idx3, idx4)
474
+
472
475
  # Write updated positions back using inherited helper
473
476
  self._update_molecule_geometry(positions)
477
+
478
+ self._warn_if_torsion_not_applied(new_dihedral_deg, achieved)
479
+
480
+ def _warn_if_torsion_not_applied(self, requested: float, achieved: float) -> None:
481
+ """Tell the user when the requested torsion could not be reached.
482
+
483
+ A torsion inside a ring cannot rotate: the connected group reached from
484
+ one side comes back around and contains all four atoms, so they turn
485
+ together and the angle is unchanged. Silently leaving the old value
486
+ looks like the edit was applied.
487
+ """
488
+ difference = abs(achieved - requested) % 360.0
489
+ if min(difference, 360.0 - difference) < 0.5:
490
+ return
491
+ with suppress_log(AttributeError, RuntimeError, TypeError):
492
+ self.main_window.statusBar().showMessage(
493
+ f"Dihedral unchanged ({achieved:.2f}°): the four atoms rotate "
494
+ "together, which happens for a bond inside a ring.",
495
+ 5000,
496
+ )
@@ -583,26 +583,17 @@ class EditActionsManager:
583
583
  if orig_id not in self.host.state_manager.data.atoms:
584
584
  continue
585
585
 
586
- # Get implicit hydrogens; fallback to total - explicit
587
- implicit_h = (
588
- int(rd_atom.GetNumImplicitHs())
589
- if hasattr(rd_atom, "GetNumImplicitHs")
590
- else 0
591
- )
592
- if implicit_h is None or implicit_h < 0:
586
+ # Hydrogens still needing an atom of their own. GetTotalNumHs()
587
+ # excludes H already drawn as atoms but includes the
588
+ # property-explicit H that sanitization assigns to an aromatic
589
+ # N-H; subtracting GetNumExplicitHs() here would cancel exactly
590
+ # that term and leave luminol's two N-H without hydrogens.
591
+ try:
592
+ implicit_h = int(rd_atom.GetTotalNumHs())
593
+ except (AttributeError, RuntimeError, ValueError, TypeError):
594
+ implicit_h = 0
595
+ if implicit_h < 0:
593
596
  implicit_h = 0
594
- if implicit_h == 0:
595
- # Fallback
596
- try:
597
- total_h = int(rd_atom.GetTotalNumHs())
598
- explicit_h = (
599
- int(rd_atom.GetNumExplicitHs())
600
- if hasattr(rd_atom, "GetNumExplicitHs")
601
- else 0
602
- )
603
- implicit_h = max(0, total_h - explicit_h)
604
- except (AttributeError, RuntimeError, ValueError, TypeError):
605
- implicit_h = 0
606
597
 
607
598
  if implicit_h <= 0:
608
599
  continue
@@ -954,12 +945,16 @@ class EditActionsManager:
954
945
  continue
955
946
  original_id = atom.GetIntProp("_original_atom_id")
956
947
 
957
- # Robust retrieval of H counts: prefer implicit, fallback to total or 0
948
+ # GetTotalNumHs() counts implicit plus property-explicit H but
949
+ # not H drawn as its own atom, which is exactly what the label
950
+ # should show. Sanitization files an aromatic N-H (luminol,
951
+ # pyrrole) under the property, where GetNumImplicitHs() alone
952
+ # reports 0 and the H silently vanishes from the label.
958
953
  try:
959
- h_count = int(atom.GetNumImplicitHs())
954
+ h_count = int(atom.GetTotalNumHs())
960
955
  except (AttributeError, RuntimeError, ValueError, TypeError):
961
956
  try:
962
- h_count = int(atom.GetTotalNumHs())
957
+ h_count = int(atom.GetNumImplicitHs())
963
958
  except (AttributeError, RuntimeError, ValueError, TypeError):
964
959
  h_count = 0
965
960
 
@@ -152,8 +152,11 @@ class IOManager:
152
152
 
153
153
  def _mol_from_xyz_lines(self, raw_lines: list[str]) -> Any:
154
154
  """Create an RDKit molecule from XYZ text lines."""
155
- lines = [ln.strip() for ln in raw_lines if not ln.strip().startswith("#")]
156
- while lines and not lines[0]:
155
+ lines = [ln.strip() for ln in raw_lines]
156
+ # Only leading comments are metadata. In a headed XYZ the second line is
157
+ # the title and may itself begin with '#', so discarding every '#' line
158
+ # would shift the atom rows up one and silently drop the first atom.
159
+ while lines and (not lines[0] or lines[0].startswith("#")):
157
160
  lines.pop(0)
158
161
 
159
162
  if not lines:
@@ -167,9 +170,11 @@ class IOManager:
167
170
  if num_atoms == 0:
168
171
  raise ValueError("XYZ file has zero atoms")
169
172
  except ValueError as exc:
170
- # Not a standard headed XYZ — treat all lines as atom rows
173
+ # Not a standard headed XYZ — treat all lines as atom rows. There is
174
+ # no title line here, so interleaved comments are safe to drop.
171
175
  if "zero atoms" in str(exc) or "too few" in str(exc):
172
176
  raise
177
+ lines = [ln for ln in lines if ln and not ln.startswith("#")]
173
178
  num_atoms = len(lines)
174
179
  atom_start = 0
175
180
 
@@ -442,6 +447,7 @@ class IOManager:
442
447
  num_atoms = mol.GetNumAtoms()
443
448
  bonds_added = []
444
449
 
450
+ candidates = []
445
451
  for i in range(num_atoms):
446
452
  for j in range(i + 1, num_atoms):
447
453
  atom_i = mol.GetAtomWithIdx(i)
@@ -456,21 +462,27 @@ class IOManager:
456
462
  expected = radius_i + radius_j
457
463
  tolerance = 1.2 if (symbol_i == "H" or symbol_j == "H") else 1.3
458
464
  if expected * 0.5 <= distance <= expected * tolerance:
459
- if mol.GetBondBetweenAtoms(i, j) is None:
460
- if (
461
- symbol_i == "H" and mol.GetAtomWithIdx(i).GetDegree() >= 1
462
- ) or (
463
- symbol_j == "H" and mol.GetAtomWithIdx(j).GetDegree() >= 1
464
- ):
465
- continue
466
- try:
467
- mol.AddBond(i, j, Chem.BondType.SINGLE)
468
- bonds_added.append((i, j, distance))
469
- except (RuntimeError, ValueError, TypeError):
470
- # Safe defensive fallback catching RuntimeError, ValueError, TypeError
471
- logging.debug(
472
- "Suppressed non-critical error", exc_info=True
473
- )
465
+ candidates.append((distance, i, j, symbol_i, symbol_j))
466
+
467
+ # Nearest pairs first. The monovalent-H rule below accepts whichever
468
+ # partner it sees first, so index order would otherwise decide the
469
+ # connectivity and the same geometry could bond an H differently
470
+ # depending only on how the file happened to list its atoms.
471
+ candidates.sort(key=lambda c: c[0])
472
+
473
+ for distance, i, j, symbol_i, symbol_j in candidates:
474
+ if mol.GetBondBetweenAtoms(i, j) is not None:
475
+ continue
476
+ if (symbol_i == "H" and mol.GetAtomWithIdx(i).GetDegree() >= 1) or (
477
+ symbol_j == "H" and mol.GetAtomWithIdx(j).GetDegree() >= 1
478
+ ):
479
+ continue
480
+ try:
481
+ mol.AddBond(i, j, Chem.BondType.SINGLE)
482
+ bonds_added.append((i, j, distance))
483
+ except (RuntimeError, ValueError, TypeError):
484
+ # Safe defensive fallback catching RuntimeError, ValueError, TypeError
485
+ logging.debug("Suppressed non-critical error", exc_info=True)
474
486
 
475
487
  return len(bonds_added)
476
488
 
@@ -816,6 +828,7 @@ class IOManager:
816
828
  atom.GetSymbol(),
817
829
  QPointF(scene_x, scene_y),
818
830
  charge=atom.GetFormalCharge(),
831
+ radical=atom.GetNumRadicalElectrons(),
819
832
  )
820
833
  rdkit_idx_to_my_id[i] = atom_id
821
834
 
@@ -117,10 +117,10 @@ class SettingsOtherTab(SettingsTabBase):
117
117
 
118
118
  self.log_to_file_checkbox = QCheckBox()
119
119
  self.log_to_file_checkbox.setToolTip(
120
- "Save application log to ~/.moleditpy/moleditpy_linux.log (rotated, max 1 MB × 3)."
120
+ "Save application log to ~/.moleditpy/moleditpy.log (rotated, max 1 MB × 3)."
121
121
  )
122
122
  form_layout.addRow(
123
- "Save log to file (~/.moleditpy/moleditpy_linux.log):", self.log_to_file_checkbox
123
+ "Save log to file (~/.moleditpy/moleditpy.log):", self.log_to_file_checkbox
124
124
  )
125
125
 
126
126
  self.log_level_debug_checkbox = QCheckBox()
@@ -119,6 +119,7 @@ class StringImporterManager:
119
119
  atom.GetSymbol(),
120
120
  QPointF(scene_x, scene_y),
121
121
  charge=atom.GetFormalCharge(),
122
+ radical=atom.GetNumRadicalElectrons(),
122
123
  )
123
124
  rdkit_idx_to_my_id[i] = atom_id
124
125
  return rdkit_idx_to_my_id
File without changes