MoleditPy 3.6.0__tar.gz → 3.6.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.
- {moleditpy-3.6.0 → moleditpy-3.6.1}/PKG-INFO +1 -1
- {moleditpy-3.6.0 → moleditpy-3.6.1}/pyproject.toml +1 -1
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/PKG-INFO +1 -1
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/__init__.py +3 -4
- moleditpy-3.6.1/src/moleditpy/ui/atom_picking.py +310 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/constrained_optimization_dialog.py +0 -3
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/custom_interactor_style.py +9 -2
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/edit_3d_logic.py +16 -20
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/edit_actions_logic.py +3 -3
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/molecular_scene_handler.py +23 -14
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/molecule_scene.py +38 -26
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/move_group_dialog.py +27 -17
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/move_selected_atoms_dialog.py +193 -193
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +27 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -1
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -1
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/default_settings.py +1 -0
- moleditpy-3.6.0/src/moleditpy/ui/atom_picking.py +0 -163
- {moleditpy-3.6.0 → moleditpy-3.6.1}/LICENSE +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/README.md +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/setup.cfg +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/entry_points.txt +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/requires.txt +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/top_level.txt +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/__main__.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/file_icon.ico +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/icon.icns +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/icon.ico +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/icon.png +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/core/__init__.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/core/mol_geometry.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/core/molecular_data.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/main.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/__init__.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/plugin_interface.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/plugin_manager.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/__init__.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/about_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/align_plane_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/alignment_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/analysis_window.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/angle_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/app_state.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/atom_item.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/base_picking_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/bond_item.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/bond_length_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/calculation_worker.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/color_settings_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/compute_logic.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/dialog_logic.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/dihedral_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/export_logic.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/geometry_base_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/io_logic.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/main_window.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/main_window_init.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/mirror_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/planarize_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/string_importers.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/template_preview_item.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/template_preview_view.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/translation_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/ui_manager.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/user_template_dialog.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/view_3d_logic.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/zoomable_view.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/__init__.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/constants.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
- {moleditpy-3.6.0 → moleditpy-3.6.1}/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.6.
|
|
3
|
+
Version: 3.6.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 3.6.
|
|
3
|
+
Version: 3.6.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
|
|
@@ -8,15 +8,14 @@ Author: Hiromichi Yokoyama
|
|
|
8
8
|
License: GPL-3.0 license
|
|
9
9
|
Repo: https://github.com/HiroYokoyama/python_molecular_editor
|
|
10
10
|
DOI: 10.5281/zenodo.17268532
|
|
11
|
-
"""
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
Top-level package for moleditpy.
|
|
13
|
+
"""
|
|
14
14
|
|
|
15
15
|
import importlib.util
|
|
16
|
+
from .utils.constants import VERSION as __version__ # noqa: F401
|
|
16
17
|
|
|
17
18
|
try:
|
|
18
19
|
OBABEL_AVAILABLE = importlib.util.find_spec("openbabel") is not None
|
|
19
20
|
except ImportError:
|
|
20
21
|
OBABEL_AVAILABLE = False
|
|
21
|
-
|
|
22
|
-
from .utils.constants import VERSION as __version__ # noqa: F401
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
MoleditPy — A Python-based molecular editing software
|
|
6
|
+
|
|
7
|
+
Author: Hiromichi Yokoyama
|
|
8
|
+
License: GPL-3.0 license
|
|
9
|
+
Repo: https://github.com/HiroYokoyama/python_molecular_editor
|
|
10
|
+
DOI: 10.5281/zenodo.17268532
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from ..utils.constants import VDW_RADII, pt
|
|
22
|
+
except ImportError:
|
|
23
|
+
from moleditpy.utils.constants import VDW_RADII, pt
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _world_to_display(renderer: Any, pos: Any) -> Optional[tuple[float, float, float]]:
|
|
27
|
+
try:
|
|
28
|
+
renderer.SetWorldPoint(float(pos[0]), float(pos[1]), float(pos[2]), 1.0)
|
|
29
|
+
renderer.WorldToDisplay()
|
|
30
|
+
display = renderer.GetDisplayPoint()
|
|
31
|
+
return (float(display[0]), float(display[1]), float(display[2]))
|
|
32
|
+
except (AttributeError, RuntimeError, TypeError, ValueError, IndexError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _atom_world_radius(view_3d_manager: Any, mol: Any, atom_idx: int) -> float:
|
|
37
|
+
try:
|
|
38
|
+
atom = mol.GetAtomWithIdx(int(atom_idx))
|
|
39
|
+
symbol = atom.GetSymbol()
|
|
40
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
41
|
+
symbol = "C"
|
|
42
|
+
|
|
43
|
+
settings = {}
|
|
44
|
+
try:
|
|
45
|
+
settings = view_3d_manager.host.init_manager.settings
|
|
46
|
+
except (AttributeError, RuntimeError, TypeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
style = str(getattr(view_3d_manager, "current_3d_style", "ball_and_stick"))
|
|
50
|
+
style = style.lower().replace(" ", "_")
|
|
51
|
+
|
|
52
|
+
if style == "cpk":
|
|
53
|
+
scale = settings.get("cpk_atom_scale", 1.0)
|
|
54
|
+
try:
|
|
55
|
+
radius = pt.GetRvdw(pt.GetAtomicNumber(symbol))
|
|
56
|
+
return float(radius if radius > 0.1 else 1.5) * float(scale)
|
|
57
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
58
|
+
return 1.5 * float(scale)
|
|
59
|
+
|
|
60
|
+
if style == "stick":
|
|
61
|
+
return float(settings.get("stick_bond_radius", 0.15))
|
|
62
|
+
|
|
63
|
+
if style == "wireframe":
|
|
64
|
+
return 0.01
|
|
65
|
+
|
|
66
|
+
scale = settings.get("ball_stick_atom_scale", 1.0)
|
|
67
|
+
return float(VDW_RADII.get(symbol, 0.4)) * float(scale)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _projected_radius_px(
|
|
71
|
+
renderer: Any, center: Any, world_radius: float
|
|
72
|
+
) -> Optional[float]:
|
|
73
|
+
center_display = _world_to_display(renderer, center)
|
|
74
|
+
if center_display is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
offsets = (
|
|
78
|
+
(world_radius, 0.0, 0.0),
|
|
79
|
+
(0.0, world_radius, 0.0),
|
|
80
|
+
(0.0, 0.0, world_radius),
|
|
81
|
+
)
|
|
82
|
+
radius_px = 0.0
|
|
83
|
+
for offset in offsets:
|
|
84
|
+
edge = (
|
|
85
|
+
float(center[0]) + offset[0],
|
|
86
|
+
float(center[1]) + offset[1],
|
|
87
|
+
float(center[2]) + offset[2],
|
|
88
|
+
)
|
|
89
|
+
edge_display = _world_to_display(renderer, edge)
|
|
90
|
+
if edge_display is None:
|
|
91
|
+
continue
|
|
92
|
+
radius_px = max(
|
|
93
|
+
radius_px,
|
|
94
|
+
float(
|
|
95
|
+
np.hypot(
|
|
96
|
+
edge_display[0] - center_display[0],
|
|
97
|
+
edge_display[1] - center_display[1],
|
|
98
|
+
)
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return radius_px
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pick_atom_index_from_screen_sequential(
|
|
106
|
+
view_3d_manager: Any,
|
|
107
|
+
click_pos: tuple[int, int],
|
|
108
|
+
mol: Optional[Any] = None,
|
|
109
|
+
padding_px: float = 8.0,
|
|
110
|
+
min_radius_px: float = 14.0,
|
|
111
|
+
max_radius_px: float = 96.0,
|
|
112
|
+
) -> Optional[int]:
|
|
113
|
+
"""Return the atom nearest a screen click without invoking VTK cell picking."""
|
|
114
|
+
try:
|
|
115
|
+
plotter = view_3d_manager.plotter
|
|
116
|
+
renderer = plotter.renderer
|
|
117
|
+
positions = view_3d_manager.atom_positions_3d
|
|
118
|
+
except (AttributeError, RuntimeError, TypeError):
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
if positions is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
positions_array = np.asarray(positions, dtype=float)
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if positions_array.ndim != 2 or positions_array.shape[1] < 3:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
if mol is None:
|
|
133
|
+
mol = getattr(view_3d_manager, "current_mol", None)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
atom_count = int(mol.GetNumAtoms()) if mol is not None else len(positions_array)
|
|
137
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
138
|
+
atom_count = len(positions_array)
|
|
139
|
+
|
|
140
|
+
best_idx: Optional[int] = None
|
|
141
|
+
best_score: Optional[tuple[float, float]] = None
|
|
142
|
+
|
|
143
|
+
for atom_idx in range(min(atom_count, len(positions_array))):
|
|
144
|
+
center = positions_array[atom_idx]
|
|
145
|
+
display = _world_to_display(renderer, center)
|
|
146
|
+
if display is None or not np.all(np.isfinite(display[:2])):
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
world_radius = _atom_world_radius(view_3d_manager, mol, atom_idx)
|
|
150
|
+
projected_radius = _projected_radius_px(renderer, center, world_radius)
|
|
151
|
+
hit_radius = max(
|
|
152
|
+
float(min_radius_px),
|
|
153
|
+
min(float(max_radius_px), float(projected_radius or 0.0) + padding_px),
|
|
154
|
+
)
|
|
155
|
+
distance = float(np.hypot(display[0] - click_pos[0], display[1] - click_pos[1]))
|
|
156
|
+
if distance > hit_radius:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
score = (distance / hit_radius, distance)
|
|
160
|
+
if best_score is None or score < best_score:
|
|
161
|
+
best_idx = atom_idx
|
|
162
|
+
best_score = score
|
|
163
|
+
|
|
164
|
+
return best_idx
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pick_atom_index_from_screen_vectorized(
|
|
168
|
+
view_3d_manager: Any,
|
|
169
|
+
click_pos: tuple[int, int],
|
|
170
|
+
mol: Optional[Any] = None,
|
|
171
|
+
padding_px: float = 8.0,
|
|
172
|
+
min_radius_px: float = 14.0,
|
|
173
|
+
max_radius_px: float = 96.0,
|
|
174
|
+
) -> Optional[int]:
|
|
175
|
+
"""
|
|
176
|
+
Vectorized atom picking using the camera's projection matrix.
|
|
177
|
+
Eliminates the O(N) Python loop and VTK C++ boundary calls.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
plotter = view_3d_manager.plotter
|
|
181
|
+
renderer = plotter.renderer
|
|
182
|
+
positions = view_3d_manager.atom_positions_3d
|
|
183
|
+
except (AttributeError, RuntimeError, TypeError):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
if positions is None or len(positions) == 0:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
positions_array = np.asarray(positions, dtype=float) # Shape: (N, 3)
|
|
191
|
+
except (TypeError, ValueError):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
if positions_array.ndim != 2 or positions_array.shape[1] < 3:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
if mol is None:
|
|
198
|
+
mol = getattr(view_3d_manager, "current_mol", None)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
atom_count = int(mol.GetNumAtoms()) if mol is not None else len(positions_array)
|
|
202
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
203
|
+
atom_count = len(positions_array)
|
|
204
|
+
|
|
205
|
+
active_atoms = min(atom_count, len(positions_array))
|
|
206
|
+
if active_atoms == 0:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
positions_array = positions_array[:active_atoms]
|
|
210
|
+
|
|
211
|
+
# 1. Retrieve the View-Projection (Composite) Matrix from the active camera
|
|
212
|
+
try:
|
|
213
|
+
camera = renderer.GetActiveCamera()
|
|
214
|
+
aspect_ratio = renderer.GetTiledAspectRatio()
|
|
215
|
+
# Get the 4x4 composite projection matrix (vtkMatrix4x4)
|
|
216
|
+
vtk_matrix = camera.GetCompositeProjectionTransformMatrix(aspect_ratio, -1, 1)
|
|
217
|
+
|
|
218
|
+
# Convert vtkMatrix4x4 to a NumPy 4x4 array
|
|
219
|
+
matrix = np.zeros((4, 4))
|
|
220
|
+
for i in range(4):
|
|
221
|
+
for j in range(4):
|
|
222
|
+
matrix[i, j] = vtk_matrix.GetElement(i, j)
|
|
223
|
+
except Exception:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# 2. Convert all N world coordinates to homogeneous coordinates (N, 4)
|
|
227
|
+
homogeneous_coords = np.hstack([positions_array, np.ones((active_atoms, 1))])
|
|
228
|
+
|
|
229
|
+
# 3. Apply Matrix Multiplication: (N, 4) x (4, 4).T -> Clip Space Coordinates
|
|
230
|
+
clip_coords = homogeneous_coords @ matrix.T
|
|
231
|
+
|
|
232
|
+
# 4. Perform Perspective Divide -> Normalized Device Coordinates (NDC)
|
|
233
|
+
w = clip_coords[:, 3:4]
|
|
234
|
+
w_copy = np.copy(w)
|
|
235
|
+
w_copy[np.abs(w_copy) < 1e-5] = 1.0
|
|
236
|
+
ndc_coords = clip_coords[:, :3] / w_copy
|
|
237
|
+
|
|
238
|
+
# 5. Transform NDC to Screen/Display Coordinates
|
|
239
|
+
try:
|
|
240
|
+
size = renderer.GetSize() # (width, height)
|
|
241
|
+
except Exception:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
# VTK display space coordinates: X: [0, W], Y: [0, H]
|
|
245
|
+
display_coords = np.zeros((active_atoms, 2))
|
|
246
|
+
display_coords[:, 0] = (ndc_coords[:, 0] + 1.0) * 0.5 * size[0]
|
|
247
|
+
display_coords[:, 1] = (ndc_coords[:, 1] + 1.0) * 0.5 * size[1]
|
|
248
|
+
|
|
249
|
+
# 6. Vectorized Distance and Hit Radius Calculation
|
|
250
|
+
dx = display_coords[:, 0] - click_pos[0]
|
|
251
|
+
dy = display_coords[:, 1] - click_pos[1]
|
|
252
|
+
distances = np.hypot(dx, dy)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
if camera.GetParallelProjection():
|
|
256
|
+
pixel_scale = size[1] / (2.0 * camera.GetParallelScale())
|
|
257
|
+
else:
|
|
258
|
+
view_angle_rad = np.radians(camera.GetViewAngle())
|
|
259
|
+
pixel_scale = size[1] / (
|
|
260
|
+
2.0 * np.abs(w.flatten()) * np.tan(view_angle_rad / 2.0)
|
|
261
|
+
)
|
|
262
|
+
except Exception:
|
|
263
|
+
pixel_scale = 20.0 # Safe fallback scale
|
|
264
|
+
|
|
265
|
+
# Pre-calculate world radii for all atoms
|
|
266
|
+
world_radii = np.array(
|
|
267
|
+
[_atom_world_radius(view_3d_manager, mol, idx) for idx in range(active_atoms)]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
projected_radii = world_radii * pixel_scale
|
|
271
|
+
hit_radii = np.maximum(
|
|
272
|
+
min_radius_px, np.minimum(max_radius_px, projected_radii + padding_px)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# 7. Mask out atoms that are further than their hit radius
|
|
276
|
+
valid_mask = distances <= hit_radii
|
|
277
|
+
if not np.any(valid_mask):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
# 8. Score calculation and find the best index
|
|
281
|
+
# Tie-breaking logic: (ratio) + (distances * 1e-8)
|
|
282
|
+
scores = (distances / hit_radii) + (distances * 1e-8)
|
|
283
|
+
|
|
284
|
+
scores[~valid_mask] = np.inf
|
|
285
|
+
best_idx = int(np.argmin(scores))
|
|
286
|
+
|
|
287
|
+
return best_idx if scores[best_idx] != np.inf else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def pick_atom_index_from_screen(
|
|
291
|
+
view_3d_manager: Any,
|
|
292
|
+
click_pos: tuple[int, int],
|
|
293
|
+
mol: Optional[Any] = None,
|
|
294
|
+
padding_px: float = 8.0,
|
|
295
|
+
min_radius_px: float = 14.0,
|
|
296
|
+
max_radius_px: float = 96.0,
|
|
297
|
+
) -> Optional[int]:
|
|
298
|
+
"""Return the atom nearest a screen click, trying vectorized first, falling back to sequential."""
|
|
299
|
+
try:
|
|
300
|
+
best_idx = pick_atom_index_from_screen_vectorized(
|
|
301
|
+
view_3d_manager, click_pos, mol, padding_px, min_radius_px, max_radius_px
|
|
302
|
+
)
|
|
303
|
+
if best_idx is not None:
|
|
304
|
+
return best_idx
|
|
305
|
+
except Exception as e:
|
|
306
|
+
logging.debug("Vectorized picking failed, falling back to sequential: %s", e)
|
|
307
|
+
|
|
308
|
+
return pick_atom_index_from_screen_sequential(
|
|
309
|
+
view_3d_manager, click_pos, mol, padding_px, min_radius_px, max_radius_px
|
|
310
|
+
)
|
|
@@ -405,7 +405,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
405
405
|
dx = current_pos[0] - move_group_dialog._drag_start_pos[0]
|
|
406
406
|
dy = current_pos[1] - move_group_dialog._drag_start_pos[1]
|
|
407
407
|
|
|
408
|
-
if abs(dx) >
|
|
408
|
+
if abs(dx) > 5 or abs(dy) > 5:
|
|
409
409
|
move_group_dialog._mouse_moved = True
|
|
410
410
|
|
|
411
411
|
return # Disable camera rotation
|
|
@@ -423,7 +423,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
423
423
|
dx = current_pos[0] - move_group_dialog._rotation_start_pos[0]
|
|
424
424
|
dy = current_pos[1] - move_group_dialog._rotation_start_pos[1]
|
|
425
425
|
|
|
426
|
-
if abs(dx) >
|
|
426
|
+
if abs(dx) > 5 or abs(dy) > 5:
|
|
427
427
|
move_group_dialog._rotation_mouse_moved = True
|
|
428
428
|
|
|
429
429
|
return # Disable camera rotation
|
|
@@ -493,6 +493,13 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
493
493
|
if getattr(
|
|
494
494
|
move_group_dialog, "_is_dragging_group_vtk", False
|
|
495
495
|
) and not getattr(move_group_dialog, "_mouse_moved", False):
|
|
496
|
+
# No drag = click only -> toggle
|
|
497
|
+
clicked_atom = getattr(move_group_dialog, "_drag_atom_idx", None)
|
|
498
|
+
if clicked_atom is not None:
|
|
499
|
+
try:
|
|
500
|
+
move_group_dialog.on_atom_picked(clicked_atom)
|
|
501
|
+
except (AttributeError, RuntimeError, TypeError, ValueError) as e:
|
|
502
|
+
logging.error(f"Error in toggle: {e}")
|
|
496
503
|
# Reset if multi-clicked without drag
|
|
497
504
|
move_group_dialog._is_dragging_group_vtk = False
|
|
498
505
|
move_group_dialog._drag_start_pos = None
|
|
@@ -12,15 +12,25 @@ DOI: 10.5281/zenodo.17268532
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Mixin class separated from main_window.py
|
|
18
|
-
"""
|
|
15
|
+
# main_window_edit_3d.py
|
|
16
|
+
# Mixin class separated from main_window.py
|
|
19
17
|
|
|
20
18
|
import logging
|
|
21
19
|
from typing import Any, List, Optional
|
|
22
20
|
|
|
23
21
|
import numpy as np
|
|
22
|
+
import pyvista as pv
|
|
23
|
+
from PyQt6.QtCore import QPointF
|
|
24
|
+
from PyQt6.QtGui import QColor, QFont
|
|
25
|
+
from PyQt6.QtWidgets import QGraphicsTextItem
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from PyQt6 import sip as _sip # type: ignore
|
|
29
|
+
|
|
30
|
+
_sip_isdeleted = getattr(_sip, "isdeleted", None)
|
|
31
|
+
except ImportError:
|
|
32
|
+
_sip = None # type: ignore[assignment]
|
|
33
|
+
_sip_isdeleted = None
|
|
24
34
|
|
|
25
35
|
try:
|
|
26
36
|
from .mol_geometry import (
|
|
@@ -41,20 +51,6 @@ try:
|
|
|
41
51
|
except ImportError:
|
|
42
52
|
from moleditpy.utils.sip_isdeleted_safe import sip_isdeleted_safe
|
|
43
53
|
|
|
44
|
-
# PyQt6 Modules
|
|
45
|
-
import pyvista as pv
|
|
46
|
-
from PyQt6.QtCore import QPointF
|
|
47
|
-
from PyQt6.QtGui import QColor, QFont
|
|
48
|
-
from PyQt6.QtWidgets import QGraphicsTextItem
|
|
49
|
-
|
|
50
|
-
try:
|
|
51
|
-
from PyQt6 import sip as _sip # type: ignore
|
|
52
|
-
|
|
53
|
-
_sip_isdeleted = getattr(_sip, "isdeleted", None)
|
|
54
|
-
except ImportError:
|
|
55
|
-
_sip = None # type: ignore[assignment]
|
|
56
|
-
_sip_isdeleted = None
|
|
57
|
-
|
|
58
54
|
try:
|
|
59
55
|
# package relative imports (preferred when running as `python -m moleditpy`)
|
|
60
56
|
from .constants import VDW_RADII
|
|
@@ -169,8 +165,8 @@ class Edit3DManager:
|
|
|
169
165
|
return
|
|
170
166
|
|
|
171
167
|
# Prepare label positions and text
|
|
172
|
-
atom_indices = [
|
|
173
|
-
labels = [
|
|
168
|
+
atom_indices = [item[0] for item in self.measurement_labels]
|
|
169
|
+
labels = [item[1] for item in self.measurement_labels]
|
|
174
170
|
positions = []
|
|
175
171
|
texts = []
|
|
176
172
|
positions_3d = self.host.view_3d_manager.atom_positions_3d
|
|
@@ -1443,9 +1443,9 @@ class EditActionsManager:
|
|
|
1443
1443
|
pos_i = positions_i[k]
|
|
1444
1444
|
vdw_i = vdw_i_all[k]
|
|
1445
1445
|
|
|
1446
|
-
for
|
|
1447
|
-
pos_j = positions_j[
|
|
1448
|
-
vdw_j = vdw_j_all[
|
|
1446
|
+
for idx, _ in enumerate(frag_j["indices"]):
|
|
1447
|
+
pos_j = positions_j[idx]
|
|
1448
|
+
vdw_j = vdw_j_all[idx]
|
|
1449
1449
|
|
|
1450
1450
|
distance_vec = pos_i - pos_j
|
|
1451
1451
|
distance_sq = np.dot(
|
|
@@ -32,11 +32,10 @@ except ImportError:
|
|
|
32
32
|
from moleditpy.utils.sip_isdeleted_safe import sip_isdeleted_safe
|
|
33
33
|
|
|
34
34
|
try:
|
|
35
|
-
from ..utils.constants import DEFAULT_BOND_LENGTH,
|
|
35
|
+
from ..utils.constants import DEFAULT_BOND_LENGTH, SUM_TOLERANCE
|
|
36
36
|
except ImportError:
|
|
37
37
|
from moleditpy.utils.constants import (
|
|
38
38
|
DEFAULT_BOND_LENGTH,
|
|
39
|
-
SNAP_DISTANCE,
|
|
40
39
|
SUM_TOLERANCE,
|
|
41
40
|
)
|
|
42
41
|
|
|
@@ -724,7 +723,9 @@ class KeyboardMixin:
|
|
|
724
723
|
|
|
725
724
|
temp_line: Optional[QGraphicsLineItem]
|
|
726
725
|
|
|
727
|
-
def _calculate_new_atom_position(
|
|
726
|
+
def _calculate_new_atom_position(
|
|
727
|
+
self, start_atom: Any, bond_length: Any, target_order: int = 1
|
|
728
|
+
) -> Any:
|
|
728
729
|
"""
|
|
729
730
|
Calculate the position for a new atom based on the surroundings of start_atom.
|
|
730
731
|
Returns the offset QPointF.
|
|
@@ -752,8 +753,12 @@ class KeyboardMixin:
|
|
|
752
753
|
other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
|
|
753
754
|
existing_bond_vector = start_pos - other_atom.pos()
|
|
754
755
|
|
|
755
|
-
# Rotate 60° clockwise from existing bond
|
|
756
|
-
|
|
756
|
+
# Rotate 60° clockwise/anticlockwise from existing bond (or 0°/180° straight continuation for alkyne)
|
|
757
|
+
is_clockwise = getattr(self, "placement_direction_clockwise", True)
|
|
758
|
+
angle_deg = 60 if is_clockwise else -60
|
|
759
|
+
if target_order == 3 or getattr(bond, "order", 1) == 3:
|
|
760
|
+
angle_deg = 0
|
|
761
|
+
angle_rad = math.radians(angle_deg)
|
|
757
762
|
cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
|
|
758
763
|
vx, vy = existing_bond_vector.x(), existing_bond_vector.y()
|
|
759
764
|
new_vx, new_vy = vx * cos_a - vy * sin_a, vx * sin_a + vy * cos_a
|
|
@@ -820,9 +825,9 @@ class KeyboardMixin:
|
|
|
820
825
|
if key == Qt.Key.Key_4:
|
|
821
826
|
snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
|
|
822
827
|
item_at_cursor = self.find_atom_near(cursor_pos, tol=snap_dist)
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
item_at_cursor = self.find_atom_near(cursor_pos, tol=
|
|
828
|
+
else:
|
|
829
|
+
snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
|
|
830
|
+
item_at_cursor = self.find_atom_near(cursor_pos, tol=snap_dist)
|
|
826
831
|
if item_at_cursor is None:
|
|
827
832
|
item_at_cursor = self.itemAt(cursor_pos, transform)
|
|
828
833
|
|
|
@@ -1160,7 +1165,7 @@ class KeyboardMixin:
|
|
|
1160
1165
|
start_pos = start_atom.pos()
|
|
1161
1166
|
bond_len = DEFAULT_BOND_LENGTH
|
|
1162
1167
|
new_pos_offset = self._calculate_new_atom_position(
|
|
1163
|
-
start_atom, bond_len
|
|
1168
|
+
start_atom, bond_len, target_order
|
|
1164
1169
|
)
|
|
1165
1170
|
|
|
1166
1171
|
# SNAP_DISTANCE is a module-level constant
|
|
@@ -1168,11 +1173,8 @@ class KeyboardMixin:
|
|
|
1168
1173
|
|
|
1169
1174
|
# Find nearby atom
|
|
1170
1175
|
near_atom = None
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
"template_fusing_distance_2d", SNAP_DISTANCE
|
|
1174
|
-
)
|
|
1175
|
-
near_atom = self.find_atom_near(target_pos, tol=fuse_dist)
|
|
1176
|
+
snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
|
|
1177
|
+
near_atom = self.find_atom_near(target_pos, tol=snap_dist)
|
|
1176
1178
|
|
|
1177
1179
|
if near_atom and near_atom is not start_atom:
|
|
1178
1180
|
# Bond if exists
|
|
@@ -1193,6 +1195,13 @@ class KeyboardMixin:
|
|
|
1193
1195
|
bond_stereo=0,
|
|
1194
1196
|
)
|
|
1195
1197
|
|
|
1198
|
+
if target_order != 3:
|
|
1199
|
+
if hasattr(self, "placement_direction_clockwise"):
|
|
1200
|
+
self.placement_direction_clockwise = (
|
|
1201
|
+
not self.placement_direction_clockwise
|
|
1202
|
+
)
|
|
1203
|
+
else:
|
|
1204
|
+
self.placement_direction_clockwise = False
|
|
1196
1205
|
self.clearSelection()
|
|
1197
1206
|
self.update_all_items()
|
|
1198
1207
|
self.window.edit_actions_manager.push_undo_state()
|
|
@@ -177,6 +177,11 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
177
177
|
|
|
178
178
|
def mousePressEvent(self, event: Any) -> None:
|
|
179
179
|
self.press_pos = event.scenePos()
|
|
180
|
+
self.was_selected_on_press = False
|
|
181
|
+
if self.mode == "select" and event.button() == Qt.MouseButton.LeftButton:
|
|
182
|
+
item = self.itemAt(self.press_pos, self.views()[0].transform())
|
|
183
|
+
if isinstance(item, AtomItem) and item.isSelected():
|
|
184
|
+
self.was_selected_on_press = True
|
|
180
185
|
self.mouse_moved_since_press = False
|
|
181
186
|
self.data_changed_in_event = False
|
|
182
187
|
|
|
@@ -239,7 +244,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
239
244
|
if hasattr(item, "stereo") and item.stereo in [3, 4]:
|
|
240
245
|
item.set_stereo(0)
|
|
241
246
|
# Also update the data model
|
|
242
|
-
for
|
|
247
|
+
for bdata in self.data.bonds.values():
|
|
243
248
|
if bdata.get("item") is item:
|
|
244
249
|
bdata["stereo"] = 0
|
|
245
250
|
break
|
|
@@ -306,14 +311,14 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
306
311
|
event.accept()
|
|
307
312
|
|
|
308
313
|
item = None
|
|
309
|
-
if (
|
|
310
|
-
self.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
if self.mode.startswith("bond") and self.press_pos:
|
|
315
|
+
snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
|
|
316
|
+
item = self.find_atom_near(self.press_pos, tol=snap_dist)
|
|
317
|
+
if item is None:
|
|
318
|
+
candidate = self.itemAt(self.press_pos, self.views()[0].transform())
|
|
319
|
+
if not isinstance(candidate, AtomItem):
|
|
320
|
+
item = candidate
|
|
321
|
+
else:
|
|
317
322
|
item = self.itemAt(self.press_pos, self.views()[0].transform())
|
|
318
323
|
|
|
319
324
|
if isinstance(item, AtomItem):
|
|
@@ -360,14 +365,9 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
360
365
|
end_point = current_pos
|
|
361
366
|
|
|
362
367
|
target_atom = None
|
|
363
|
-
if
|
|
364
|
-
|
|
365
|
-
target_atom = self.find_atom_near(current_pos, tol=
|
|
366
|
-
else:
|
|
367
|
-
for item in self.items(current_pos):
|
|
368
|
-
if isinstance(item, AtomItem):
|
|
369
|
-
target_atom = item
|
|
370
|
-
break
|
|
368
|
+
if current_pos:
|
|
369
|
+
snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
|
|
370
|
+
target_atom = self.find_atom_near(current_pos, tol=snap_dist)
|
|
371
371
|
|
|
372
372
|
is_valid_snap_target = target_atom is not None and (
|
|
373
373
|
self.start_atom is None or target_atom is not self.start_atom
|
|
@@ -387,7 +387,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
387
387
|
|
|
388
388
|
end_pos = event.scenePos()
|
|
389
389
|
is_click = (
|
|
390
|
-
self.press_pos
|
|
390
|
+
self.press_pos is not None
|
|
391
391
|
and (end_pos - self.press_pos).manhattanLength()
|
|
392
392
|
< QApplication.startDragDistance()
|
|
393
393
|
)
|
|
@@ -557,11 +557,13 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
557
557
|
):
|
|
558
558
|
line = QLineF(self.start_atom.pos(), end_pos)
|
|
559
559
|
end_item = None
|
|
560
|
-
if
|
|
561
|
-
|
|
562
|
-
end_item = self.find_atom_near(end_pos, tol=
|
|
560
|
+
if end_pos:
|
|
561
|
+
snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
|
|
562
|
+
end_item = self.find_atom_near(end_pos, tol=snap_dist)
|
|
563
563
|
if end_item is None:
|
|
564
|
-
|
|
564
|
+
candidate = self.itemAt(end_pos, self.views()[0].transform())
|
|
565
|
+
if not isinstance(candidate, AtomItem):
|
|
566
|
+
end_item = candidate
|
|
565
567
|
# Determine bond style to use
|
|
566
568
|
# In atom modes, set bond_order/stereo to None so create_bond uses defaults (1, 0)
|
|
567
569
|
# In bond_* modes, use current settings (self.bond_order/stereo)
|
|
@@ -611,11 +613,13 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
611
613
|
self.data_changed_in_event = True
|
|
612
614
|
else:
|
|
613
615
|
end_item = None
|
|
614
|
-
if
|
|
615
|
-
|
|
616
|
-
end_item = self.find_atom_near(end_pos, tol=
|
|
616
|
+
if end_pos:
|
|
617
|
+
snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
|
|
618
|
+
end_item = self.find_atom_near(end_pos, tol=snap_dist)
|
|
617
619
|
if end_item is None:
|
|
618
|
-
|
|
620
|
+
candidate = self.itemAt(end_pos, self.views()[0].transform())
|
|
621
|
+
if not isinstance(candidate, AtomItem):
|
|
622
|
+
end_item = candidate
|
|
619
623
|
if isinstance(end_item, AtomItem):
|
|
620
624
|
start_id = self.create_atom(
|
|
621
625
|
self.current_atom_symbol, self.start_pos
|
|
@@ -642,6 +646,14 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
642
646
|
# 5. Other processing (Select mode, etc.)
|
|
643
647
|
else:
|
|
644
648
|
super().mouseReleaseEvent(event)
|
|
649
|
+
if (
|
|
650
|
+
self.mode == "select"
|
|
651
|
+
and is_click
|
|
652
|
+
and getattr(self, "was_selected_on_press", False)
|
|
653
|
+
):
|
|
654
|
+
released_item = self.itemAt(end_pos, self.views()[0].transform())
|
|
655
|
+
if isinstance(released_item, AtomItem):
|
|
656
|
+
released_item.setSelected(False)
|
|
645
657
|
|
|
646
658
|
# Safely check for moved objects
|
|
647
659
|
moved_atoms = []
|