MoleditPy 3.4.0__tar.gz → 3.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {moleditpy-3.4.0 → moleditpy-3.5.0}/PKG-INFO +3 -2
- {moleditpy-3.4.0 → moleditpy-3.5.0}/README.md +1 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/pyproject.toml +2 -2
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/MoleditPy.egg-info/PKG-INFO +3 -2
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/MoleditPy.egg-info/requires.txt +1 -1
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/bond_item.py +42 -1
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/calculation_worker.py +4 -2
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/export_logic.py +0 -22
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/molecular_scene_handler.py +121 -39
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/molecule_scene.py +33 -11
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +60 -3
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/utils/default_settings.py +3 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/LICENSE +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/setup.cfg +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/__init__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/__main__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/assets/file_icon.ico +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/assets/icon.icns +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/assets/icon.ico +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/assets/icon.png +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/core/__init__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/core/mol_geometry.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/core/molecular_data.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/main.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/plugins/__init__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/plugins/plugin_interface.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/plugins/plugin_manager.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/__init__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/about_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/align_plane_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/alignment_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/analysis_window.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/angle_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/app_state.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/atom_item.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/atom_picking.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/base_picking_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/bond_length_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/color_settings_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/compute_logic.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/constrained_optimization_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/custom_interactor_style.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/dialog_logic.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/dihedral_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/edit_3d_logic.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/edit_actions_logic.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/geometry_base_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/io_logic.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/main_window.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/main_window_init.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/mirror_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/move_group_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/planarize_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/settings_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/string_importers.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/template_preview_item.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/template_preview_view.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/translation_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/ui_manager.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/user_template_dialog.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/view_3d_logic.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/ui/zoomable_view.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/utils/__init__.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/utils/constants.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
- {moleditpy-3.4.0 → moleditpy-3.5.0}/src/moleditpy/utils/system_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.0
|
|
4
4
|
Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
|
|
5
5
|
Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -698,7 +698,7 @@ Requires-Dist: pyqt6<6.10; sys_platform == "darwin"
|
|
|
698
698
|
Requires-Dist: pyqt6<6.11; sys_platform != "darwin"
|
|
699
699
|
Requires-Dist: pyvista<0.48
|
|
700
700
|
Requires-Dist: pyvistaqt<0.12
|
|
701
|
-
Requires-Dist: rdkit<
|
|
701
|
+
Requires-Dist: rdkit<2026.4
|
|
702
702
|
Requires-Dist: openbabel-wheel<3.2
|
|
703
703
|
Dynamic: license-file
|
|
704
704
|
|
|
@@ -717,6 +717,7 @@ Dynamic: license-file
|
|
|
717
717
|
[](https://pepy.tech/projects/moleditpy)
|
|
718
718
|
[](https://pepy.tech/projects/moleditpy)
|
|
719
719
|
[](https://deepwiki.com/HiroYokoyama/python_molecular_editor)
|
|
720
|
+
[](https://github.com/sponsors/HiroYokoyama)
|
|
720
721
|
|
|
721
722
|
[🇯🇵 日本語 (Japanese)](#japanese)
|
|
722
723
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
[](https://pepy.tech/projects/moleditpy)
|
|
14
14
|
[](https://pepy.tech/projects/moleditpy)
|
|
15
15
|
[](https://deepwiki.com/HiroYokoyama/python_molecular_editor)
|
|
16
|
+
[](https://github.com/sponsors/HiroYokoyama)
|
|
16
17
|
|
|
17
18
|
[🇯🇵 日本語 (Japanese)](#japanese)
|
|
18
19
|
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "MoleditPy"
|
|
7
7
|
|
|
8
|
-
version = "3.
|
|
8
|
+
version = "3.5.0"
|
|
9
9
|
|
|
10
10
|
license = {file = "LICENSE"}
|
|
11
11
|
|
|
@@ -38,7 +38,7 @@ dependencies = [
|
|
|
38
38
|
"pyqt6 < 6.11; sys_platform != 'darwin'",
|
|
39
39
|
"pyvista < 0.48",
|
|
40
40
|
"pyvistaqt < 0.12",
|
|
41
|
-
"rdkit <
|
|
41
|
+
"rdkit < 2026.4",
|
|
42
42
|
"openbabel-wheel < 3.2"
|
|
43
43
|
]
|
|
44
44
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.0
|
|
4
4
|
Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
|
|
5
5
|
Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -698,7 +698,7 @@ Requires-Dist: pyqt6<6.10; sys_platform == "darwin"
|
|
|
698
698
|
Requires-Dist: pyqt6<6.11; sys_platform != "darwin"
|
|
699
699
|
Requires-Dist: pyvista<0.48
|
|
700
700
|
Requires-Dist: pyvistaqt<0.12
|
|
701
|
-
Requires-Dist: rdkit<
|
|
701
|
+
Requires-Dist: rdkit<2026.4
|
|
702
702
|
Requires-Dist: openbabel-wheel<3.2
|
|
703
703
|
Dynamic: license-file
|
|
704
704
|
|
|
@@ -717,6 +717,7 @@ Dynamic: license-file
|
|
|
717
717
|
[](https://pepy.tech/projects/moleditpy)
|
|
718
718
|
[](https://pepy.tech/projects/moleditpy)
|
|
719
719
|
[](https://deepwiki.com/HiroYokoyama/python_molecular_editor)
|
|
720
|
+
[](https://github.com/sponsors/HiroYokoyama)
|
|
720
721
|
|
|
721
722
|
[🇯🇵 日本語 (Japanese)](#japanese)
|
|
722
723
|
|
|
@@ -11,6 +11,7 @@ DOI: 10.5281/zenodo.17268532
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
|
+
import math
|
|
14
15
|
import logging
|
|
15
16
|
from typing import Any, Optional, Tuple, Union
|
|
16
17
|
|
|
@@ -109,6 +110,44 @@ class BondItem(QGraphicsItem):
|
|
|
109
110
|
self.prepareGeometryChange()
|
|
110
111
|
self.update()
|
|
111
112
|
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _ring_inner_double_bond_shorten_factor(
|
|
115
|
+
line: QLineF, local_ring_center: QPointF
|
|
116
|
+
) -> float:
|
|
117
|
+
"""Shorten inner ring double bonds based on the ring angle at both atoms."""
|
|
118
|
+
|
|
119
|
+
def angle_between(v1: QPointF, v2: QPointF) -> Optional[float]:
|
|
120
|
+
len1 = math.hypot(v1.x(), v1.y())
|
|
121
|
+
len2 = math.hypot(v2.x(), v2.y())
|
|
122
|
+
if len1 <= 1e-6 or len2 <= 1e-6:
|
|
123
|
+
return None
|
|
124
|
+
dot = v1.x() * v2.x() + v1.y() * v2.y()
|
|
125
|
+
cos_angle = max(-1.0, min(1.0, dot / (len1 * len2)))
|
|
126
|
+
return math.acos(cos_angle)
|
|
127
|
+
|
|
128
|
+
p1 = line.p1()
|
|
129
|
+
p2 = line.p2()
|
|
130
|
+
half_angles = [
|
|
131
|
+
angle_between(p2 - p1, local_ring_center - p1),
|
|
132
|
+
angle_between(p1 - p2, local_ring_center - p2),
|
|
133
|
+
]
|
|
134
|
+
interior_angles = [2 * a for a in half_angles if a is not None]
|
|
135
|
+
if not interior_angles:
|
|
136
|
+
return 0.8
|
|
137
|
+
|
|
138
|
+
ring_angle = sum(interior_angles) / len(interior_angles)
|
|
139
|
+
benzene_angle = 2 * math.pi / 3
|
|
140
|
+
cyclopropene_angle = math.pi / 3
|
|
141
|
+
|
|
142
|
+
if ring_angle <= benzene_angle:
|
|
143
|
+
angle_range = benzene_angle - cyclopropene_angle
|
|
144
|
+
sharpness = min(1.0, max(0.0, (benzene_angle - ring_angle) / angle_range))
|
|
145
|
+
return max(0.55, 0.8 - 0.25 * sharpness)
|
|
146
|
+
|
|
147
|
+
wide_angle_range = math.pi - benzene_angle
|
|
148
|
+
openness = min(1.0, max(0.0, (ring_angle - benzene_angle) / wide_angle_range))
|
|
149
|
+
return min(0.9, 0.8 + 0.1 * openness)
|
|
150
|
+
|
|
112
151
|
def __init__(
|
|
113
152
|
self, atom1_item: Any, atom2_item: Any, order: int = 1, stereo: int = 0
|
|
114
153
|
) -> None:
|
|
@@ -473,7 +512,9 @@ class BondItem(QGraphicsItem):
|
|
|
473
512
|
|
|
474
513
|
# Draw short inner line (80% length)
|
|
475
514
|
inner_line = line.translated(inner_offset)
|
|
476
|
-
shorten_factor =
|
|
515
|
+
shorten_factor = self._ring_inner_double_bond_shorten_factor(
|
|
516
|
+
line, local_ring_center
|
|
517
|
+
)
|
|
477
518
|
p1 = inner_line.p1()
|
|
478
519
|
p2 = inner_line.p2()
|
|
479
520
|
center = QPointF((p1.x() + p2.x()) / 2, (p1.y() + p2.y()) / 2)
|
|
@@ -724,7 +724,7 @@ class CalculationWorker(QObject):
|
|
|
724
724
|
|
|
725
725
|
def __init__(self, parent: Optional[QObject] = None) -> None:
|
|
726
726
|
super().__init__(parent)
|
|
727
|
-
self.halt_ids: Optional[Set[
|
|
727
|
+
self.halt_ids: Optional[Set[Any]] = None
|
|
728
728
|
self.start_work.connect(self.run_calculation)
|
|
729
729
|
|
|
730
730
|
@pyqtSlot(str, object)
|
|
@@ -739,13 +739,15 @@ class CalculationWorker(QObject):
|
|
|
739
739
|
h_ids = getattr(self, "halt_ids", None)
|
|
740
740
|
if getattr(self, "halt_all", False):
|
|
741
741
|
return True # type: ignore[return-value]
|
|
742
|
-
if h_ids
|
|
742
|
+
if not isinstance(h_ids, set):
|
|
743
743
|
return False
|
|
744
|
+
# pylint: disable=unsupported-membership-test
|
|
744
745
|
return bool(
|
|
745
746
|
("ALL" in h_ids)
|
|
746
747
|
or (None in h_ids)
|
|
747
748
|
or (w_id is not None and w_id in h_ids)
|
|
748
749
|
)
|
|
750
|
+
# pylint: enable=unsupported-membership-test
|
|
749
751
|
|
|
750
752
|
def _safe_status(msg: str) -> None:
|
|
751
753
|
if _check_halted():
|
|
@@ -79,12 +79,6 @@ class ExportManager:
|
|
|
79
79
|
return basename
|
|
80
80
|
|
|
81
81
|
def export_stl(self) -> None:
|
|
82
|
-
if not self.host.view_3d_manager.current_mol:
|
|
83
|
-
self.host.statusBar().showMessage(
|
|
84
|
-
"Error: Please generate a 3D structure first."
|
|
85
|
-
)
|
|
86
|
-
return
|
|
87
|
-
|
|
88
82
|
default_path = self._get_default_path()
|
|
89
83
|
|
|
90
84
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
@@ -113,12 +107,6 @@ class ExportManager:
|
|
|
113
107
|
|
|
114
108
|
def export_obj_mtl(self) -> None:
|
|
115
109
|
"""Export as OBJ/MTL (with colors)."""
|
|
116
|
-
if not self.host.view_3d_manager.current_mol:
|
|
117
|
-
self.host.statusBar().showMessage(
|
|
118
|
-
"Error: Please generate a 3D structure first."
|
|
119
|
-
)
|
|
120
|
-
return
|
|
121
|
-
|
|
122
110
|
default_path = self._get_default_path()
|
|
123
111
|
|
|
124
112
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
@@ -257,12 +245,6 @@ class ExportManager:
|
|
|
257
245
|
|
|
258
246
|
def export_color_stl(self) -> None:
|
|
259
247
|
"""Export as Color STL."""
|
|
260
|
-
if not self.host.view_3d_manager.current_mol:
|
|
261
|
-
self.host.statusBar().showMessage(
|
|
262
|
-
"Error: Please generate a 3D structure first."
|
|
263
|
-
)
|
|
264
|
-
return
|
|
265
|
-
|
|
266
248
|
default_path = self._get_default_path()
|
|
267
249
|
|
|
268
250
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
@@ -954,10 +936,6 @@ class ExportManager:
|
|
|
954
936
|
|
|
955
937
|
def export_3d_png(self) -> None:
|
|
956
938
|
"""Export 3D view as PNG."""
|
|
957
|
-
if not self.host.view_3d_manager.current_mol:
|
|
958
|
-
self.host.statusBar().showMessage("No 3D molecule to export.", 2000)
|
|
959
|
-
return
|
|
960
|
-
|
|
961
939
|
# Default filename: {name}.png
|
|
962
940
|
default_path = self._get_default_path()
|
|
963
941
|
|
|
@@ -236,7 +236,7 @@ class TemplateMixin:
|
|
|
236
236
|
if i < num_points and j < num_points
|
|
237
237
|
]
|
|
238
238
|
avg_len = (sum(ref_lengths) / len(ref_lengths)) if ref_lengths else 20.0
|
|
239
|
-
|
|
239
|
+
click_map_threshold = max(0.5 * avg_len, 8.0)
|
|
240
240
|
|
|
241
241
|
for ex_item in existing_items:
|
|
242
242
|
try:
|
|
@@ -248,7 +248,7 @@ class TemplateMixin:
|
|
|
248
248
|
d = dist_pts(p, ex_pos)
|
|
249
249
|
if best_d is None or d < best_d:
|
|
250
250
|
best_d, best_idx = d, i
|
|
251
|
-
if best_idx != -1 and best_d <= max(
|
|
251
|
+
if best_idx != -1 and best_d <= max(click_map_threshold, 1.5 * avg_len):
|
|
252
252
|
atom_items[best_idx] = ex_item
|
|
253
253
|
used_indices.add(best_idx)
|
|
254
254
|
except (AttributeError, RuntimeError, ValueError, TypeError) as e:
|
|
@@ -258,27 +258,29 @@ class TemplateMixin:
|
|
|
258
258
|
|
|
259
259
|
# --- 2) Enumerate existing atoms in the scene from self.data.atoms and map them ---
|
|
260
260
|
mapped_atoms = {it for it in atom_items if it is not None}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
if self.get_setting("atom_fusing_enabled_2d", True):
|
|
262
|
+
map_threshold = self.get_setting("atom_fusing_distance_2d", 14.0)
|
|
263
|
+
for i, p in enumerate(points):
|
|
264
|
+
if atom_items[i] is not None:
|
|
265
|
+
continue
|
|
264
266
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
nearby = None
|
|
268
|
+
best_d = float("inf")
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
270
|
+
for atom_data in self.data.atoms.values():
|
|
271
|
+
a_item = atom_data.get("item")
|
|
272
|
+
if not a_item or a_item in mapped_atoms:
|
|
273
|
+
continue
|
|
274
|
+
try:
|
|
275
|
+
d = dist_pts(p, a_item.pos())
|
|
276
|
+
except (AttributeError, RuntimeError, ValueError, TypeError):
|
|
277
|
+
continue
|
|
278
|
+
if d < best_d:
|
|
279
|
+
best_d, nearby = d, a_item
|
|
278
280
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
281
|
+
if nearby and best_d <= map_threshold:
|
|
282
|
+
atom_items[i] = nearby
|
|
283
|
+
mapped_atoms.add(nearby)
|
|
282
284
|
|
|
283
285
|
# --- 3) Create missing vertices ---
|
|
284
286
|
for i, p in enumerate(points):
|
|
@@ -369,12 +371,16 @@ class TemplateMixin:
|
|
|
369
371
|
except ValueError:
|
|
370
372
|
return
|
|
371
373
|
|
|
372
|
-
items_under = self.items(pos) # top-most first
|
|
373
374
|
item = None
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
375
|
+
if pos:
|
|
376
|
+
snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
|
|
377
|
+
item = self.find_atom_near(pos, tol=snap_dist)
|
|
378
|
+
if item is None:
|
|
379
|
+
items_under = self.items(pos) # top-most first
|
|
380
|
+
for it in items_under:
|
|
381
|
+
if isinstance(it, (AtomItem, BondItem)):
|
|
382
|
+
item = it
|
|
383
|
+
break
|
|
378
384
|
|
|
379
385
|
points, bonds_info = [], []
|
|
380
386
|
l = DEFAULT_BOND_LENGTH
|
|
@@ -566,12 +572,16 @@ class TemplateMixin:
|
|
|
566
572
|
return
|
|
567
573
|
|
|
568
574
|
# Find attachment point (first atom or clicked item)
|
|
569
|
-
items_under = self.items(pos)
|
|
570
575
|
attachment_atom = None
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
576
|
+
if pos:
|
|
577
|
+
snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
|
|
578
|
+
attachment_atom = self.find_atom_near(pos, tol=snap_dist)
|
|
579
|
+
if attachment_atom is None:
|
|
580
|
+
items_under = self.items(pos)
|
|
581
|
+
for item in items_under:
|
|
582
|
+
if isinstance(item, AtomItem):
|
|
583
|
+
attachment_atom = item
|
|
584
|
+
break
|
|
575
585
|
|
|
576
586
|
# Calculate template positions
|
|
577
587
|
points = []
|
|
@@ -722,9 +732,18 @@ class KeyboardMixin:
|
|
|
722
732
|
def keyPressEvent(self, event: Any) -> None:
|
|
723
733
|
view = self.views()[0]
|
|
724
734
|
cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
|
|
725
|
-
|
|
735
|
+
transform = view.transform()
|
|
726
736
|
key = event.key()
|
|
727
737
|
modifiers = event.modifiers()
|
|
738
|
+
item_at_cursor = None
|
|
739
|
+
if key == Qt.Key.Key_4:
|
|
740
|
+
snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
|
|
741
|
+
item_at_cursor = self.find_atom_near(cursor_pos, tol=snap_dist)
|
|
742
|
+
elif self.get_setting("atom_fusing_enabled_2d", True):
|
|
743
|
+
fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
|
|
744
|
+
item_at_cursor = self.find_atom_near(cursor_pos, tol=fuse_dist)
|
|
745
|
+
if item_at_cursor is None:
|
|
746
|
+
item_at_cursor = self.itemAt(cursor_pos, transform)
|
|
728
747
|
|
|
729
748
|
if not self.window.ui_manager.is_2d_editable:
|
|
730
749
|
return
|
|
@@ -741,13 +760,69 @@ class KeyboardMixin:
|
|
|
741
760
|
if isinstance(item_at_cursor, AtomItem):
|
|
742
761
|
p0 = item_at_cursor.pos()
|
|
743
762
|
l = DEFAULT_BOND_LENGTH
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
763
|
+
|
|
764
|
+
# Check if this is a terminal atom (exactly 1 neighbor)
|
|
765
|
+
neighbor_positions = []
|
|
766
|
+
if hasattr(item_at_cursor, "bonds") and item_at_cursor.bonds:
|
|
767
|
+
for b in item_at_cursor.bonds:
|
|
768
|
+
if not sip_isdeleted_safe(b):
|
|
769
|
+
other = (
|
|
770
|
+
b.atom1
|
|
771
|
+
if b.atom2 is item_at_cursor
|
|
772
|
+
else b.atom2
|
|
773
|
+
)
|
|
774
|
+
if (
|
|
775
|
+
other
|
|
776
|
+
and not sip_isdeleted_safe(other)
|
|
777
|
+
and hasattr(other, "pos")
|
|
778
|
+
):
|
|
779
|
+
try:
|
|
780
|
+
neighbor_positions.append(other.pos())
|
|
781
|
+
except RuntimeError:
|
|
782
|
+
continue
|
|
783
|
+
|
|
784
|
+
if len(neighbor_positions) == 1:
|
|
785
|
+
v_to_neighbor = neighbor_positions[0] - p0
|
|
786
|
+
angle_to_neighbor = math.atan2(
|
|
787
|
+
v_to_neighbor.y(), v_to_neighbor.x()
|
|
788
|
+
)
|
|
789
|
+
angle_plus = angle_to_neighbor + math.radians(120)
|
|
790
|
+
angle_minus = angle_to_neighbor - math.radians(120)
|
|
791
|
+
angle_cursor = math.atan2(
|
|
792
|
+
cursor_pos.y() - p0.y(), cursor_pos.x() - p0.x()
|
|
793
|
+
)
|
|
794
|
+
diff_plus = abs(
|
|
795
|
+
math.atan2(
|
|
796
|
+
math.sin(angle_cursor - angle_plus),
|
|
797
|
+
math.cos(angle_cursor - angle_plus),
|
|
798
|
+
)
|
|
799
|
+
)
|
|
800
|
+
diff_minus = abs(
|
|
801
|
+
math.atan2(
|
|
802
|
+
math.sin(angle_cursor - angle_minus),
|
|
803
|
+
math.cos(angle_cursor - angle_minus),
|
|
804
|
+
)
|
|
805
|
+
)
|
|
806
|
+
best_angle = (
|
|
807
|
+
angle_plus if diff_plus < diff_minus else angle_minus
|
|
808
|
+
)
|
|
809
|
+
p1 = p0 + QPointF(
|
|
810
|
+
l * math.cos(best_angle), l * math.sin(best_angle)
|
|
811
|
+
)
|
|
812
|
+
bend_dir = p0 - v_to_neighbor
|
|
813
|
+
points = self._calculate_polygon_from_edge(
|
|
814
|
+
p0, p1, n, cursor_pos=bend_dir
|
|
815
|
+
)
|
|
816
|
+
else:
|
|
817
|
+
direction = QLineF(p0, cursor_pos).unitVector()
|
|
818
|
+
p1 = (
|
|
819
|
+
p0 + direction.p2() * l
|
|
820
|
+
if direction.length() > 0
|
|
821
|
+
else p0 + QPointF(l, 0)
|
|
822
|
+
)
|
|
823
|
+
points = self._calculate_polygon_from_edge(
|
|
824
|
+
p0, p1, n, cursor_pos=cursor_pos
|
|
825
|
+
)
|
|
751
826
|
existing_items = [item_at_cursor]
|
|
752
827
|
|
|
753
828
|
elif isinstance(item_at_cursor, BondItem):
|
|
@@ -1008,7 +1083,12 @@ class KeyboardMixin:
|
|
|
1008
1083
|
target_pos = start_pos + new_pos_offset
|
|
1009
1084
|
|
|
1010
1085
|
# Find nearby atom
|
|
1011
|
-
near_atom =
|
|
1086
|
+
near_atom = None
|
|
1087
|
+
if self.get_setting("atom_fusing_enabled_2d", True):
|
|
1088
|
+
fuse_dist = self.get_setting(
|
|
1089
|
+
"atom_fusing_distance_2d", SNAP_DISTANCE
|
|
1090
|
+
)
|
|
1091
|
+
near_atom = self.find_atom_near(target_pos, tol=fuse_dist)
|
|
1012
1092
|
|
|
1013
1093
|
if near_atom and near_atom is not start_atom:
|
|
1014
1094
|
# Bond if exists
|
|
@@ -1377,6 +1457,8 @@ class SceneQueryMixin:
|
|
|
1377
1457
|
return False
|
|
1378
1458
|
|
|
1379
1459
|
def find_atom_near(self, pos: Any, tol: float = 14.0) -> Any:
|
|
1460
|
+
if pos is None:
|
|
1461
|
+
return None
|
|
1380
1462
|
# Create a small search rectangle around the position
|
|
1381
1463
|
search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
|
|
1382
1464
|
nearby_items = self.items(search_rect)
|
|
@@ -107,7 +107,8 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
107
107
|
and self.window
|
|
108
108
|
and hasattr(self.window, "init_manager")
|
|
109
109
|
):
|
|
110
|
-
|
|
110
|
+
settings = self.window.init_manager.settings
|
|
111
|
+
return settings.get(key, default)
|
|
111
112
|
return default
|
|
112
113
|
|
|
113
114
|
def update_connected_bonds(self, atoms: List[AtomItem]) -> None:
|
|
@@ -210,7 +211,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
210
211
|
if isinstance(it, (AtomItem, BondItem))
|
|
211
212
|
and not sip_isdeleted_safe(it)
|
|
212
213
|
]
|
|
213
|
-
except
|
|
214
|
+
except (AttributeError, RuntimeError, TypeError, ValueError) as e:
|
|
214
215
|
# Fallback to empty selection if the scene state is inconsistent during event processing
|
|
215
216
|
logging.debug(
|
|
216
217
|
f"Failed to retrieve selected items in mousePressEvent: {e}"
|
|
@@ -304,9 +305,16 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
304
305
|
self.clearSelection()
|
|
305
306
|
event.accept()
|
|
306
307
|
|
|
307
|
-
item
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
item = None
|
|
309
|
+
if (
|
|
310
|
+
self.mode.startswith("bond")
|
|
311
|
+
and self.get_setting("atom_fusing_enabled_2d", True)
|
|
312
|
+
and self.press_pos
|
|
313
|
+
):
|
|
314
|
+
fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
|
|
315
|
+
item = self.find_atom_near(self.press_pos, tol=fuse_dist)
|
|
316
|
+
if item is None:
|
|
317
|
+
item = self.itemAt(self.press_pos, self.views()[0].transform())
|
|
310
318
|
|
|
311
319
|
if isinstance(item, AtomItem):
|
|
312
320
|
self.start_atom = item
|
|
@@ -352,10 +360,14 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
352
360
|
end_point = current_pos
|
|
353
361
|
|
|
354
362
|
target_atom = None
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
363
|
+
if self.get_setting("atom_fusing_enabled_2d", True) and current_pos:
|
|
364
|
+
fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
|
|
365
|
+
target_atom = self.find_atom_near(current_pos, tol=fuse_dist)
|
|
366
|
+
else:
|
|
367
|
+
for item in self.items(current_pos):
|
|
368
|
+
if isinstance(item, AtomItem):
|
|
369
|
+
target_atom = item
|
|
370
|
+
break
|
|
359
371
|
|
|
360
372
|
is_valid_snap_target = target_atom is not None and (
|
|
361
373
|
self.start_atom is None or target_atom is not self.start_atom
|
|
@@ -544,7 +556,12 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
544
556
|
self.mode.startswith("atom") or self.mode.startswith("bond")
|
|
545
557
|
):
|
|
546
558
|
line = QLineF(self.start_atom.pos(), end_pos)
|
|
547
|
-
end_item =
|
|
559
|
+
end_item = None
|
|
560
|
+
if self.get_setting("atom_fusing_enabled_2d", True) and end_pos:
|
|
561
|
+
fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
|
|
562
|
+
end_item = self.find_atom_near(end_pos, tol=fuse_dist)
|
|
563
|
+
if end_item is None:
|
|
564
|
+
end_item = self.itemAt(end_pos, self.views()[0].transform())
|
|
548
565
|
# Determine bond style to use
|
|
549
566
|
# In atom modes, set bond_order/stereo to None so create_bond uses defaults (1, 0)
|
|
550
567
|
# In bond_* modes, use current settings (self.bond_order/stereo)
|
|
@@ -593,7 +610,12 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
593
610
|
self.create_atom(self.current_atom_symbol, end_pos)
|
|
594
611
|
self.data_changed_in_event = True
|
|
595
612
|
else:
|
|
596
|
-
end_item =
|
|
613
|
+
end_item = None
|
|
614
|
+
if self.get_setting("atom_fusing_enabled_2d", True) and end_pos:
|
|
615
|
+
fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
|
|
616
|
+
end_item = self.find_atom_near(end_pos, tol=fuse_dist)
|
|
617
|
+
if end_item is None:
|
|
618
|
+
end_item = self.itemAt(end_pos, self.views()[0].transform())
|
|
597
619
|
if isinstance(end_item, AtomItem):
|
|
598
620
|
start_id = self.create_atom(
|
|
599
621
|
self.current_atom_symbol, self.start_pos
|
|
@@ -15,6 +15,7 @@ from typing import Any, Optional
|
|
|
15
15
|
|
|
16
16
|
from PyQt6.QtGui import QColor, QFont
|
|
17
17
|
from PyQt6.QtWidgets import (
|
|
18
|
+
QCheckBox,
|
|
18
19
|
QColorDialog,
|
|
19
20
|
QComboBox,
|
|
20
21
|
QFontComboBox,
|
|
@@ -135,16 +136,56 @@ class Settings2DTab(SettingsTabBase):
|
|
|
135
136
|
),
|
|
136
137
|
)
|
|
137
138
|
|
|
138
|
-
from PyQt6.QtWidgets import QCheckBox
|
|
139
|
-
|
|
140
139
|
self.atom_use_bond_color_2d_checkbox = QCheckBox()
|
|
141
140
|
self.atom_use_bond_color_2d_checkbox.setToolTip(
|
|
142
|
-
"If checked, atoms will use the unified Bond Color
|
|
141
|
+
"If checked, atoms will use the unified Bond Color "
|
|
142
|
+
"instead of element-specific colors (CPK)."
|
|
143
143
|
)
|
|
144
144
|
form_layout.addRow(
|
|
145
145
|
"Use Bond Color for Atoms:", self.atom_use_bond_color_2d_checkbox
|
|
146
146
|
)
|
|
147
147
|
|
|
148
|
+
form_layout.addRow(self._create_separator())
|
|
149
|
+
|
|
150
|
+
# --- Atom Fusing Settings ---
|
|
151
|
+
form_layout.addRow(QLabel("<b>Atom Fusing Settings</b>"))
|
|
152
|
+
|
|
153
|
+
self.atom_fusing_enabled_2d_checkbox = QCheckBox()
|
|
154
|
+
self.atom_fusing_enabled_2d_checkbox.setToolTip(
|
|
155
|
+
"If checked, drawing or placing templates near an existing atom "
|
|
156
|
+
"will connect to it rather than creating a new one."
|
|
157
|
+
)
|
|
158
|
+
form_layout.addRow("Enable Atom Fusing:", self.atom_fusing_enabled_2d_checkbox)
|
|
159
|
+
|
|
160
|
+
# Fusing Distance
|
|
161
|
+
self.atom_fusing_distance_2d_slider, self.atom_fusing_distance_2d_label = (
|
|
162
|
+
self._create_slider(5, 50, 1.0, is_int=True)
|
|
163
|
+
)
|
|
164
|
+
form_layout.addRow(
|
|
165
|
+
"Fusing Distance (px):",
|
|
166
|
+
self._wrap_layout(
|
|
167
|
+
self.atom_fusing_distance_2d_slider, self.atom_fusing_distance_2d_label
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
form_layout.addRow(self._create_separator())
|
|
172
|
+
|
|
173
|
+
# --- Template Snapping Settings ---
|
|
174
|
+
form_layout.addRow(QLabel("<b>Template Snapping Settings</b>"))
|
|
175
|
+
|
|
176
|
+
# Template Snapping Distance
|
|
177
|
+
(
|
|
178
|
+
self.template_snapping_distance_2d_slider,
|
|
179
|
+
self.template_snapping_distance_2d_label,
|
|
180
|
+
) = self._create_slider(5, 50, 1.0, is_int=True)
|
|
181
|
+
form_layout.addRow(
|
|
182
|
+
"Snapping Distance (px):",
|
|
183
|
+
self._wrap_layout(
|
|
184
|
+
self.template_snapping_distance_2d_slider,
|
|
185
|
+
self.template_snapping_distance_2d_label,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
148
189
|
def _pick_bg_color_2d(self) -> None:
|
|
149
190
|
color = QColorDialog.getColor(
|
|
150
191
|
QColor(self.current_bg_color_2d), self, "Select 2D Background Color"
|
|
@@ -209,6 +250,15 @@ class Settings2DTab(SettingsTabBase):
|
|
|
209
250
|
self.atom_use_bond_color_2d_checkbox.setChecked(
|
|
210
251
|
settings_dict.get("atom_use_bond_color_2d", False)
|
|
211
252
|
)
|
|
253
|
+
self.atom_fusing_enabled_2d_checkbox.setChecked(
|
|
254
|
+
settings_dict.get("atom_fusing_enabled_2d", True)
|
|
255
|
+
)
|
|
256
|
+
self.atom_fusing_distance_2d_slider.setValue(
|
|
257
|
+
int(settings_dict.get("atom_fusing_distance_2d", 14.0))
|
|
258
|
+
)
|
|
259
|
+
self.template_snapping_distance_2d_slider.setValue(
|
|
260
|
+
int(settings_dict.get("template_snapping_distance_2d", 14.0))
|
|
261
|
+
)
|
|
212
262
|
|
|
213
263
|
def get_settings(self) -> dict[str, Any]:
|
|
214
264
|
return {
|
|
@@ -223,4 +273,11 @@ class Settings2DTab(SettingsTabBase):
|
|
|
223
273
|
"atom_font_family_2d": self.atom_font_family_2d_combo.currentFont().family(),
|
|
224
274
|
"atom_font_size_2d": self.atom_font_size_2d_slider.value(),
|
|
225
275
|
"atom_use_bond_color_2d": self.atom_use_bond_color_2d_checkbox.isChecked(),
|
|
276
|
+
"atom_fusing_enabled_2d": self.atom_fusing_enabled_2d_checkbox.isChecked(),
|
|
277
|
+
"atom_fusing_distance_2d": float(
|
|
278
|
+
self.atom_fusing_distance_2d_slider.value()
|
|
279
|
+
),
|
|
280
|
+
"template_snapping_distance_2d": float(
|
|
281
|
+
self.template_snapping_distance_2d_slider.value()
|
|
282
|
+
),
|
|
226
283
|
}
|
|
@@ -76,6 +76,9 @@ DEFAULT_SETTINGS = {
|
|
|
76
76
|
"bond_wedge_width_2d": 6.0,
|
|
77
77
|
"bond_dash_count_2d": 8,
|
|
78
78
|
"atom_font_family_2d": "Arial",
|
|
79
|
+
"atom_fusing_enabled_2d": True,
|
|
80
|
+
"atom_fusing_distance_2d": 14.0,
|
|
81
|
+
"template_snapping_distance_2d": 14.0,
|
|
79
82
|
# --- Application Session / UI State ---
|
|
80
83
|
"theme": "light",
|
|
81
84
|
"window_size": [1200, 800],
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|