MoleditPy-linux 4.5.0__tar.gz → 4.5.2__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.
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/PKG-INFO +2 -2
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/pyproject.toml +2 -2
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/PKG-INFO +2 -2
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/requires.txt +1 -1
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/molecular_data.py +111 -34
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/main.py +2 -2
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/angle_dialog.py +22 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/atom_item.py +31 -6
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_length_dialog.py +47 -1
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/compute_logic.py +25 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dihedral_dialog.py +23 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_actions_logic.py +17 -22
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/io_logic.py +31 -18
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/main_window_init.py +27 -17
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +2 -2
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/string_importers.py +1 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/system_utils.py +18 -2
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/LICENSE +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/README.md +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/setup.cfg +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/__init__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/__main__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/file_icon.ico +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.icns +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.ico +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.png +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/__init__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/mol_geometry.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/__init__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/__init__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/about_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/analysis_window.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/app_state.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/atom_picking.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_item.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/export_logic.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/main_window.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/ui_manager.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/__init__.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/constants.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/default_settings.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
- {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/suppress_log.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy-linux
|
|
3
|
-
Version: 4.5.
|
|
3
|
+
Version: 4.5.2
|
|
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
|
|
@@ -697,7 +697,7 @@ Requires-Dist: numpy
|
|
|
697
697
|
Requires-Dist: pyqt6<6.10; python_version == "3.9"
|
|
698
698
|
Requires-Dist: pyqt6<6.12; python_version != "3.9"
|
|
699
699
|
Requires-Dist: pyvista<0.49
|
|
700
|
-
Requires-Dist: pyvistaqt<0.
|
|
700
|
+
Requires-Dist: pyvistaqt<0.13
|
|
701
701
|
Requires-Dist: rdkit<2026.4
|
|
702
702
|
Dynamic: license-file
|
|
703
703
|
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "MoleditPy-linux"
|
|
7
7
|
|
|
8
|
-
version = "4.5.
|
|
8
|
+
version = "4.5.2"
|
|
9
9
|
|
|
10
10
|
license = {file = "LICENSE"}
|
|
11
11
|
|
|
@@ -37,7 +37,7 @@ dependencies = [
|
|
|
37
37
|
"pyqt6 < 6.10; python_version == '3.9'",
|
|
38
38
|
"pyqt6 < 6.12; python_version != '3.9'",
|
|
39
39
|
"pyvista < 0.49",
|
|
40
|
-
"pyvistaqt < 0.
|
|
40
|
+
"pyvistaqt < 0.13",
|
|
41
41
|
"rdkit < 2026.4",
|
|
42
42
|
]
|
|
43
43
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy-linux
|
|
3
|
-
Version: 4.5.
|
|
3
|
+
Version: 4.5.2
|
|
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
|
|
@@ -697,7 +697,7 @@ Requires-Dist: numpy
|
|
|
697
697
|
Requires-Dist: pyqt6<6.10; python_version == "3.9"
|
|
698
698
|
Requires-Dist: pyqt6<6.12; python_version != "3.9"
|
|
699
699
|
Requires-Dist: pyvista<0.49
|
|
700
|
-
Requires-Dist: pyvistaqt<0.
|
|
700
|
+
Requires-Dist: pyvistaqt<0.13
|
|
701
701
|
Requires-Dist: rdkit<2026.4
|
|
702
702
|
Dynamic: license-file
|
|
703
703
|
|
|
@@ -30,6 +30,93 @@ class PointTuple(tuple):
|
|
|
30
30
|
return float(self[1])
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _mdl_radical_code(radical_electrons: int) -> int:
|
|
34
|
+
"""Map a count of unpaired electrons to an MDL radical code."""
|
|
35
|
+
# MDL encodes 1 = singlet, 2 = doublet, 3 = triplet.
|
|
36
|
+
if radical_electrons <= 0:
|
|
37
|
+
return 0
|
|
38
|
+
return 2 if radical_electrons == 1 else 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mdl_property_lines(atom_records: List[Dict[str, Any]]) -> List[str]:
|
|
42
|
+
"""Build the M CHG / M RAD property lines for a V2000 block."""
|
|
43
|
+
lines = []
|
|
44
|
+
for tag, key, encode in (
|
|
45
|
+
("CHG", "charge", int),
|
|
46
|
+
("RAD", "radical", _mdl_radical_code),
|
|
47
|
+
):
|
|
48
|
+
entries = [
|
|
49
|
+
(idx, encode(rec[key]))
|
|
50
|
+
for idx, rec in enumerate(atom_records, start=1)
|
|
51
|
+
if rec[key]
|
|
52
|
+
]
|
|
53
|
+
# The MDL property block allows at most 8 entries per line.
|
|
54
|
+
for start in range(0, len(entries), 8):
|
|
55
|
+
chunk = entries[start : start + 8]
|
|
56
|
+
lines.append(
|
|
57
|
+
f"M {tag}{len(chunk):3d}"
|
|
58
|
+
+ "".join(f"{idx:4d}{val:4d}" for idx, val in chunk)
|
|
59
|
+
)
|
|
60
|
+
return lines
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _to_v2000_block(
|
|
64
|
+
atom_records: List[Dict[str, Any]], bond_records: List[Dict[str, Any]]
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Render collected atom/bond records as an MDL V2000 MOL block."""
|
|
67
|
+
lines = ["", " MoleditPy", ""]
|
|
68
|
+
lines.append(
|
|
69
|
+
f"{len(atom_records):3d}{len(bond_records):3d} 0 0 0 0 0 0 0 0999 V2000"
|
|
70
|
+
)
|
|
71
|
+
for rec in atom_records:
|
|
72
|
+
# Charge and radical travel in the M CHG / M RAD blocks below, so every
|
|
73
|
+
# per-atom field here stays zero.
|
|
74
|
+
lines.append(
|
|
75
|
+
f"{rec['x']:10.4f}{rec['y']:10.4f}{0.0:10.4f} {rec['symbol']:<3}"
|
|
76
|
+
" 0 0 0 0 0 0 0 0 0 0 0 0"
|
|
77
|
+
)
|
|
78
|
+
for rec in bond_records:
|
|
79
|
+
lines.append(
|
|
80
|
+
f"{rec['a1']:3d}{rec['a2']:3d}{rec['order']:3d}{rec['stereo']:3d} 0 0 0"
|
|
81
|
+
)
|
|
82
|
+
lines.extend(_mdl_property_lines(atom_records))
|
|
83
|
+
lines.append("M END")
|
|
84
|
+
return "\n".join(lines) + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _to_v3000_block(
|
|
88
|
+
atom_records: List[Dict[str, Any]], bond_records: List[Dict[str, Any]]
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Render collected atom/bond records as an MDL V3000 MOL block."""
|
|
91
|
+
lines = ["", " MoleditPy", "", " 0 0 0 0 0 0 0 0 0 0999 V3000"]
|
|
92
|
+
lines.append("M V30 BEGIN CTAB")
|
|
93
|
+
lines.append(f"M V30 COUNTS {len(atom_records)} {len(bond_records)} 0 0 0")
|
|
94
|
+
lines.append("M V30 BEGIN ATOM")
|
|
95
|
+
for idx, rec in enumerate(atom_records, start=1):
|
|
96
|
+
extra = ""
|
|
97
|
+
if rec["charge"]:
|
|
98
|
+
extra += f" CHG={int(rec['charge'])}"
|
|
99
|
+
if rec["radical"]:
|
|
100
|
+
extra += f" RAD={_mdl_radical_code(rec['radical'])}"
|
|
101
|
+
lines.append(
|
|
102
|
+
f"M V30 {idx} {rec['symbol']} "
|
|
103
|
+
f"{rec['x']:.4f} {rec['y']:.4f} 0.0000 0{extra}"
|
|
104
|
+
)
|
|
105
|
+
lines.append("M V30 END ATOM")
|
|
106
|
+
lines.append("M V30 BEGIN BOND")
|
|
107
|
+
for idx, rec in enumerate(bond_records, start=1):
|
|
108
|
+
cfg = ""
|
|
109
|
+
if rec["stereo"] == 1:
|
|
110
|
+
cfg = " CFG=1"
|
|
111
|
+
elif rec["stereo"] == 6:
|
|
112
|
+
cfg = " CFG=3"
|
|
113
|
+
lines.append(f"M V30 {idx} {rec['order']} {rec['a1']} {rec['a2']}{cfg}")
|
|
114
|
+
lines.append("M V30 END BOND")
|
|
115
|
+
lines.append("M V30 END CTAB")
|
|
116
|
+
lines.append("M END")
|
|
117
|
+
return "\n".join(lines) + "\n"
|
|
118
|
+
|
|
119
|
+
|
|
33
120
|
class MolecularData:
|
|
34
121
|
"""In-memory graph of atoms and bonds, independent of any UI framework."""
|
|
35
122
|
|
|
@@ -348,7 +435,7 @@ class MolecularData:
|
|
|
348
435
|
|
|
349
436
|
# Counts line and bond indices must only reflect atoms actually written
|
|
350
437
|
atom_map: Dict[int, int] = {}
|
|
351
|
-
|
|
438
|
+
atom_records: List[Dict[str, Any]] = []
|
|
352
439
|
for old_id, atom in self.atoms.items():
|
|
353
440
|
# Convert scene pixel coordinates to angstroms when emitting MOL block
|
|
354
441
|
pos = atom.get("pos")
|
|
@@ -362,37 +449,23 @@ class MolecularData:
|
|
|
362
449
|
else:
|
|
363
450
|
continue
|
|
364
451
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
elif charge == 1:
|
|
375
|
-
chg_code = 3
|
|
376
|
-
elif charge == -1:
|
|
377
|
-
chg_code = 5
|
|
378
|
-
elif charge == -2:
|
|
379
|
-
chg_code = 6
|
|
380
|
-
elif charge == -3:
|
|
381
|
-
chg_code = 7
|
|
382
|
-
|
|
383
|
-
atom_map[old_id] = len(atom_lines)
|
|
384
|
-
atom_lines.append(
|
|
385
|
-
f"{x:10.4f}{y:10.4f}{z:10.4f} {symbol:<3} 0 0 0{chg_code:3d} 0 0 0 0 0 0 0\n"
|
|
452
|
+
atom_map[old_id] = len(atom_records)
|
|
453
|
+
atom_records.append(
|
|
454
|
+
{
|
|
455
|
+
"x": x_px * ANGSTROM_PER_PIXEL,
|
|
456
|
+
"y": y_px * ANGSTROM_PER_PIXEL,
|
|
457
|
+
"symbol": atom["symbol"],
|
|
458
|
+
"charge": int(atom.get("charge", 0) or 0),
|
|
459
|
+
"radical": int(atom.get("radical", 0) or 0),
|
|
460
|
+
}
|
|
386
461
|
)
|
|
387
462
|
|
|
388
|
-
|
|
463
|
+
bond_records: List[Dict[str, Any]] = []
|
|
389
464
|
for (id1, id2), bond in self.bonds.items():
|
|
390
465
|
if id1 not in atom_map or id2 not in atom_map:
|
|
391
466
|
continue
|
|
392
|
-
|
|
393
|
-
# Bond order may be a float (1.5 = aromatic); V2000 uses code 4.
|
|
467
|
+
# Bond order may be a float (1.5 = aromatic); MDL uses code 4.
|
|
394
468
|
order_val = float(bond["order"])
|
|
395
|
-
order = 4 if order_val == 1.5 else int(order_val)
|
|
396
469
|
stereo_code = 0
|
|
397
470
|
bond_stereo = bond.get("stereo", 0)
|
|
398
471
|
if bond_stereo == 1:
|
|
@@ -400,16 +473,20 @@ class MolecularData:
|
|
|
400
473
|
elif bond_stereo == 2:
|
|
401
474
|
stereo_code = 6
|
|
402
475
|
|
|
403
|
-
|
|
404
|
-
|
|
476
|
+
bond_records.append(
|
|
477
|
+
{
|
|
478
|
+
"a1": atom_map[id1] + 1,
|
|
479
|
+
"a2": atom_map[id2] + 1,
|
|
480
|
+
"order": 4 if order_val == 1.5 else int(order_val),
|
|
481
|
+
"stereo": stereo_code,
|
|
482
|
+
}
|
|
405
483
|
)
|
|
406
484
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return mol_block
|
|
485
|
+
# V2000 packs the counts into three-character fields, so anything past
|
|
486
|
+
# 999 would run the atom count into the bond count and corrupt the file.
|
|
487
|
+
if len(atom_records) > 999 or len(bond_records) > 999:
|
|
488
|
+
return _to_v3000_block(atom_records, bond_records)
|
|
489
|
+
return _to_v2000_block(atom_records, bond_records)
|
|
413
490
|
|
|
414
491
|
def to_template_dict(
|
|
415
492
|
self, name: str, version: str = "1.0", application_version: str = ""
|
|
@@ -189,7 +189,7 @@ def setup_logging() -> None:
|
|
|
189
189
|
log_dir = os.path.join(os.path.expanduser("~"), ".moleditpy")
|
|
190
190
|
try:
|
|
191
191
|
os.makedirs(log_dir, exist_ok=True)
|
|
192
|
-
log_path = os.path.join(log_dir, "
|
|
192
|
+
log_path = os.path.join(log_dir, "moleditpy.log")
|
|
193
193
|
fh = logging.handlers.RotatingFileHandler(
|
|
194
194
|
log_path, maxBytes=1_048_576, backupCount=3, encoding="utf-8"
|
|
195
195
|
)
|
|
@@ -231,7 +231,7 @@ def main() -> None:
|
|
|
231
231
|
if sys.platform == "win32":
|
|
232
232
|
# Taskbar grouping ID follows the major version
|
|
233
233
|
major = VERSION.split(".")[0] if VERSION and VERSION != "Unknown" else "0"
|
|
234
|
-
myappid = f"hyoko.
|
|
234
|
+
myappid = f"hyoko.moleditpy.{major}"
|
|
235
235
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
|
236
236
|
|
|
237
237
|
parser = argparse.ArgumentParser(
|
|
@@ -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
|
+
)
|
|
@@ -43,6 +43,34 @@ from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
|
|
|
43
43
|
if TYPE_CHECKING:
|
|
44
44
|
from .bond_item import BondItem
|
|
45
45
|
|
|
46
|
+
SUBSCRIPT_MAP = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
47
|
+
|
|
48
|
+
# Every subscript digit, so one warm-up covers any implicit-H count.
|
|
49
|
+
_WARM_UP_TEXT = "CH" + "0123456789".translate(SUBSCRIPT_MAP)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def warm_font_cache(font: Optional[QFont] = None) -> None:
|
|
53
|
+
"""Pay Qt's one-off font costs up front instead of on the first heteroatom.
|
|
54
|
+
|
|
55
|
+
Two process-wide costs hide in QFontMetricsF: the first text shaping in a
|
|
56
|
+
family (~10-120 ms, worse on a cold Windows font cache) and the first
|
|
57
|
+
lookup of a subscript digit, which Arial lacks — Qt answers that by walking
|
|
58
|
+
the whole system font database (~200-330 ms, measured).
|
|
59
|
+
|
|
60
|
+
Drawing a carbon skeleton pays neither: a bonded neutral carbon is
|
|
61
|
+
invisible, so it renders no glyph at all. The first O or N a user places
|
|
62
|
+
gets "OH₂" and pays both at once, mid-click, as a visible freeze.
|
|
63
|
+
|
|
64
|
+
Call once on the GUI thread after the window is up. QFontMetricsF is not
|
|
65
|
+
usable off the GUI thread, so this cannot move to a worker.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
QFontMetricsF(font or QFont(FONT_FAMILY, 20, FONT_WEIGHT_BOLD)).boundingRect(
|
|
69
|
+
_WARM_UP_TEXT
|
|
70
|
+
)
|
|
71
|
+
except (RuntimeError, TypeError, ValueError):
|
|
72
|
+
logging.debug("Font cache warm-up skipped", exc_info=True)
|
|
73
|
+
|
|
46
74
|
|
|
47
75
|
class AtomItem(QGraphicsItem):
|
|
48
76
|
"""2D scene item representing a single atom in the molecule editor."""
|
|
@@ -138,8 +166,7 @@ class AtomItem(QGraphicsItem):
|
|
|
138
166
|
if not is_skeletal_carbon:
|
|
139
167
|
hydrogen_part = "H"
|
|
140
168
|
if self.implicit_h_count > 1:
|
|
141
|
-
|
|
142
|
-
hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
|
|
169
|
+
hydrogen_part += str(self.implicit_h_count).translate(SUBSCRIPT_MAP)
|
|
143
170
|
|
|
144
171
|
flip_text = False
|
|
145
172
|
if hydrogen_part and self.bonds:
|
|
@@ -261,8 +288,7 @@ class AtomItem(QGraphicsItem):
|
|
|
261
288
|
if not is_skeletal_carbon:
|
|
262
289
|
hydrogen_part = "H"
|
|
263
290
|
if self.implicit_h_count > 1:
|
|
264
|
-
|
|
265
|
-
hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
|
|
291
|
+
hydrogen_part += str(self.implicit_h_count).translate(SUBSCRIPT_MAP)
|
|
266
292
|
|
|
267
293
|
flip_text = False
|
|
268
294
|
if hydrogen_part and self.bonds:
|
|
@@ -361,9 +387,8 @@ class AtomItem(QGraphicsItem):
|
|
|
361
387
|
if not is_skeletal_carbon:
|
|
362
388
|
hydrogen_part = "H"
|
|
363
389
|
if self.implicit_h_count > 1:
|
|
364
|
-
subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
365
390
|
hydrogen_part += str(self.implicit_h_count).translate(
|
|
366
|
-
|
|
391
|
+
SUBSCRIPT_MAP
|
|
367
392
|
)
|
|
368
393
|
|
|
369
394
|
# --- Determine if the text should be flipped ---
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_length_dialog.py
RENAMED
|
@@ -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
|
+
)
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_actions_logic.py
RENAMED
|
@@ -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
|
-
#
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
#
|
|
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.
|
|
954
|
+
h_count = int(atom.GetTotalNumHs())
|
|
960
955
|
except (AttributeError, RuntimeError, ValueError, TypeError):
|
|
961
956
|
try:
|
|
962
|
-
h_count = int(atom.
|
|
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
|
|
156
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
|
@@ -20,9 +20,6 @@ import os
|
|
|
20
20
|
import sys
|
|
21
21
|
from typing import Any, Dict, Optional
|
|
22
22
|
|
|
23
|
-
# RDKit imports (explicit to satisfy flake8 and used features)
|
|
24
|
-
from rdkit import Chem
|
|
25
|
-
|
|
26
23
|
# PyQt6 Modules
|
|
27
24
|
from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, QUrl
|
|
28
25
|
from PyQt6.QtGui import (
|
|
@@ -65,7 +62,8 @@ except ImportError:
|
|
|
65
62
|
winreg = None # type: ignore[assignment]
|
|
66
63
|
|
|
67
64
|
|
|
68
|
-
from ..utils.constants import DEFAULT_CPK_COLORS, NUM_DASHES, VERSION
|
|
65
|
+
from ..utils.constants import DEFAULT_CPK_COLORS, FONT_FAMILY, NUM_DASHES, VERSION
|
|
66
|
+
from .atom_item import warm_font_cache
|
|
69
67
|
from .custom_qt_interactor import CustomQtInteractor
|
|
70
68
|
from ..core.molecular_data import MolecularData
|
|
71
69
|
from .molecule_scene import MoleculeScene
|
|
@@ -74,6 +72,10 @@ from .settings_dialog import SettingsDialog
|
|
|
74
72
|
from .zoomable_view import ZoomableView
|
|
75
73
|
|
|
76
74
|
|
|
75
|
+
# Distinct from None, which is a real answer meaning "OS preference unknown".
|
|
76
|
+
_UNQUERIED = object()
|
|
77
|
+
|
|
78
|
+
|
|
77
79
|
class MainInitManager:
|
|
78
80
|
"""Feature class separated from main_window.py"""
|
|
79
81
|
|
|
@@ -82,6 +84,7 @@ class MainInitManager:
|
|
|
82
84
|
) -> None:
|
|
83
85
|
self.host = host
|
|
84
86
|
# Explicit declarations for Mypy
|
|
87
|
+
self._os_dark_pref: Any = _UNQUERIED
|
|
85
88
|
self.scene: Any = None # MoleculeScene, created during init
|
|
86
89
|
self.data: Optional[MolecularData] = None
|
|
87
90
|
self.formula_label: Optional[QLabel] = None
|
|
@@ -179,18 +182,6 @@ class MainInitManager:
|
|
|
179
182
|
# Install event filter to capture window close events (handled in UIManager)
|
|
180
183
|
self.host.installEventFilter(self.host.ui_manager)
|
|
181
184
|
|
|
182
|
-
# RDKit Warm-up (initial execution cost)
|
|
183
|
-
try:
|
|
184
|
-
# Create a molecule with a variety of common atoms to ensure
|
|
185
|
-
# the valence/H-count machinery is fully initialized.
|
|
186
|
-
warmup_smiles = "OC(N)C(S)P"
|
|
187
|
-
warmup_mol = Chem.MolFromSmiles(warmup_smiles)
|
|
188
|
-
if warmup_mol:
|
|
189
|
-
for atom in warmup_mol.GetAtoms():
|
|
190
|
-
atom.GetNumImplicitHs()
|
|
191
|
-
except (AttributeError, RuntimeError, ValueError) as e:
|
|
192
|
-
logging.warning(f"RDKit warm-up failed: {e}")
|
|
193
|
-
|
|
194
185
|
self.host.state_manager.reset_undo_stack()
|
|
195
186
|
self.scene.selectionChanged.connect( # type: ignore[attr-defined]
|
|
196
187
|
self.host.edit_actions_manager.update_edit_menu_actions
|
|
@@ -206,6 +197,8 @@ class MainInitManager:
|
|
|
206
197
|
QTimer.singleShot(0, lambda: self.load_command_line_file(initial_file))
|
|
207
198
|
|
|
208
199
|
QTimer.singleShot(0, self.apply_initial_settings)
|
|
200
|
+
# After apply_initial_settings, so the user's own font family is warmed.
|
|
201
|
+
QTimer.singleShot(0, self._warm_font_cache)
|
|
209
202
|
# Camera initialization flag (permits reset only during the first draw)
|
|
210
203
|
self._camera_initialized = False
|
|
211
204
|
|
|
@@ -226,6 +219,19 @@ class MainInitManager:
|
|
|
226
219
|
f"Suppressed exception: {e}"
|
|
227
220
|
) # Suppress non-critical UI/menu initialization errors
|
|
228
221
|
|
|
222
|
+
def _warm_font_cache(self) -> None:
|
|
223
|
+
"""Resolve the atom-label font now, in the user's configured family."""
|
|
224
|
+
scene = getattr(self, "scene", None)
|
|
225
|
+
font = None
|
|
226
|
+
if scene is not None and hasattr(scene, "get_setting"):
|
|
227
|
+
family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
|
|
228
|
+
size = scene.get_setting("atom_font_size_2d", 20)
|
|
229
|
+
bold = scene.get_setting("atom_font_bold_2d", True)
|
|
230
|
+
font = QFont(
|
|
231
|
+
family, size, QFont.Weight.Bold if bold else QFont.Weight.Normal
|
|
232
|
+
)
|
|
233
|
+
warm_font_cache(font)
|
|
234
|
+
|
|
229
235
|
def init_ui(self) -> None:
|
|
230
236
|
"""Set the window icon and initialize main layout, toolbars, and menu bar."""
|
|
231
237
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -1076,7 +1082,11 @@ class MainInitManager:
|
|
|
1076
1082
|
return QColor(fg)
|
|
1077
1083
|
|
|
1078
1084
|
with suppress_log(Exception):
|
|
1079
|
-
|
|
1085
|
+
# Cached: this forks `defaults`/`gsettings` on macOS and Linux, and
|
|
1086
|
+
# icon painting asks once per icon (15+ during startup).
|
|
1087
|
+
if self._os_dark_pref is _UNQUERIED:
|
|
1088
|
+
self._os_dark_pref = detect_system_dark_mode()
|
|
1089
|
+
os_pref = self._os_dark_pref
|
|
1080
1090
|
if os_pref is not None:
|
|
1081
1091
|
return QColor("#FFFFFF") if os_pref else QColor("#000000")
|
|
1082
1092
|
|
|
@@ -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/
|
|
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/
|
|
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()
|
|
@@ -21,6 +21,9 @@ try:
|
|
|
21
21
|
except ImportError:
|
|
22
22
|
winreg = None # type: ignore[assignment]
|
|
23
23
|
|
|
24
|
+
# A wedged `defaults`/`gsettings` must not take the app's startup with it.
|
|
25
|
+
_THEME_QUERY_TIMEOUT_S = 5.0
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
def detect_system_dark_mode() -> Optional[bool]:
|
|
26
29
|
"""Return True if the OS prefers dark app theme, False if light, or None if unknown."""
|
|
@@ -33,8 +36,18 @@ def detect_system_dark_mode() -> Optional[bool]:
|
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
def detect_system_theme() -> Optional[str]:
|
|
36
|
-
"""Return the OS's preferred theme setting as 'dark', 'light', or None.
|
|
37
|
-
|
|
39
|
+
"""Return the OS's preferred theme setting as 'dark', 'light', or None.
|
|
40
|
+
|
|
41
|
+
Forks a helper process on macOS and Linux, so callers that ask repeatedly
|
|
42
|
+
should hold on to the answer rather than re-querying.
|
|
43
|
+
"""
|
|
44
|
+
with suppress_log(
|
|
45
|
+
AttributeError,
|
|
46
|
+
RuntimeError,
|
|
47
|
+
OSError,
|
|
48
|
+
FileNotFoundError,
|
|
49
|
+
subprocess.SubprocessError,
|
|
50
|
+
):
|
|
38
51
|
# Windows
|
|
39
52
|
if platform.system() == "Windows" and winreg is not None:
|
|
40
53
|
with winreg.OpenKey(
|
|
@@ -50,6 +63,7 @@ def detect_system_theme() -> Optional[str]:
|
|
|
50
63
|
["defaults", "read", "-g", "AppleInterfaceStyle"],
|
|
51
64
|
capture_output=True,
|
|
52
65
|
text=True,
|
|
66
|
+
timeout=_THEME_QUERY_TIMEOUT_S,
|
|
53
67
|
)
|
|
54
68
|
if p.returncode == 0 and "dark" in p.stdout.strip().lower():
|
|
55
69
|
return "dark"
|
|
@@ -61,6 +75,7 @@ def detect_system_theme() -> Optional[str]:
|
|
|
61
75
|
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
|
|
62
76
|
capture_output=True,
|
|
63
77
|
text=True,
|
|
78
|
+
timeout=_THEME_QUERY_TIMEOUT_S,
|
|
64
79
|
)
|
|
65
80
|
if p.returncode == 0:
|
|
66
81
|
out = p.stdout.strip().strip("'\n ")
|
|
@@ -73,6 +88,7 @@ def detect_system_theme() -> Optional[str]:
|
|
|
73
88
|
["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"],
|
|
74
89
|
capture_output=True,
|
|
75
90
|
text=True,
|
|
91
|
+
timeout=_THEME_QUERY_TIMEOUT_S,
|
|
76
92
|
)
|
|
77
93
|
if p.returncode == 0 and "-dark" in p.stdout.lower():
|
|
78
94
|
return "dark"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_interface.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager_window.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/align_plane_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/base_picking_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/calculation_worker.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/color_settings_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_interactor_style.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_qt_interactor.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/geometry_base_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecular_scene_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/periodic_table_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/plugin_menu_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_item.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_view.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/translation_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/user_template_dialog.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/default_settings.py
RENAMED
|
File without changes
|
{moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/sip_isdeleted_safe.py
RENAMED
|
File without changes
|
|
File without changes
|