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.
Files changed (80) hide show
  1. {moleditpy-3.6.0 → moleditpy-3.6.1}/PKG-INFO +1 -1
  2. {moleditpy-3.6.0 → moleditpy-3.6.1}/pyproject.toml +1 -1
  3. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/PKG-INFO +1 -1
  4. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/__init__.py +3 -4
  5. moleditpy-3.6.1/src/moleditpy/ui/atom_picking.py +310 -0
  6. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/constrained_optimization_dialog.py +0 -3
  7. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/custom_interactor_style.py +9 -2
  8. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/edit_3d_logic.py +16 -20
  9. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/edit_actions_logic.py +3 -3
  10. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/molecular_scene_handler.py +23 -14
  11. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/molecule_scene.py +38 -26
  12. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/move_group_dialog.py +27 -17
  13. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/move_selected_atoms_dialog.py +193 -193
  14. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +27 -0
  15. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -1
  16. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -1
  17. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/default_settings.py +1 -0
  18. moleditpy-3.6.0/src/moleditpy/ui/atom_picking.py +0 -163
  19. {moleditpy-3.6.0 → moleditpy-3.6.1}/LICENSE +0 -0
  20. {moleditpy-3.6.0 → moleditpy-3.6.1}/README.md +0 -0
  21. {moleditpy-3.6.0 → moleditpy-3.6.1}/setup.cfg +0 -0
  22. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
  23. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  24. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  25. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/requires.txt +0 -0
  26. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/MoleditPy.egg-info/top_level.txt +0 -0
  27. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/__main__.py +0 -0
  28. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/file_icon.ico +0 -0
  29. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/icon.icns +0 -0
  30. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/icon.ico +0 -0
  31. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/assets/icon.png +0 -0
  32. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/core/__init__.py +0 -0
  33. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/core/mol_geometry.py +0 -0
  34. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/core/molecular_data.py +0 -0
  35. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/main.py +0 -0
  36. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/__init__.py +0 -0
  37. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/plugin_interface.py +0 -0
  38. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/plugin_manager.py +0 -0
  39. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
  40. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/__init__.py +0 -0
  41. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/about_dialog.py +0 -0
  42. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/align_plane_dialog.py +0 -0
  43. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/alignment_dialog.py +0 -0
  44. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/analysis_window.py +0 -0
  45. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/angle_dialog.py +0 -0
  46. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/app_state.py +0 -0
  47. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/atom_item.py +0 -0
  48. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/base_picking_dialog.py +0 -0
  49. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/bond_item.py +0 -0
  50. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/bond_length_dialog.py +0 -0
  51. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/calculation_worker.py +0 -0
  52. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/color_settings_dialog.py +0 -0
  53. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/compute_logic.py +0 -0
  54. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
  55. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
  56. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/dialog_logic.py +0 -0
  57. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/dihedral_dialog.py +0 -0
  58. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/export_logic.py +0 -0
  59. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/geometry_base_dialog.py +0 -0
  60. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/io_logic.py +0 -0
  61. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/main_window.py +0 -0
  62. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/main_window_init.py +0 -0
  63. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/mirror_dialog.py +0 -0
  64. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/planarize_dialog.py +0 -0
  66. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_dialog.py +0 -0
  67. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
  68. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
  69. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/string_importers.py +0 -0
  70. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/template_preview_item.py +0 -0
  71. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/template_preview_view.py +0 -0
  72. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/translation_dialog.py +0 -0
  73. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/ui_manager.py +0 -0
  74. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/user_template_dialog.py +0 -0
  75. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/view_3d_logic.py +0 -0
  76. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/ui/zoomable_view.py +0 -0
  77. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/__init__.py +0 -0
  78. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/constants.py +0 -0
  79. {moleditpy-3.6.0 → moleditpy-3.6.1}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
  80. {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.0
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
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy"
7
7
 
8
- version = "3.6.0"
8
+ version = "3.6.1"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 3.6.0
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
- """Top-level package for moleditpy."""
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
+ )
@@ -877,6 +877,3 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
877
877
 
878
878
  # Default processing for other keys
879
879
  QDialog.keyPressEvent(self, event)
880
-
881
-
882
- from typing import Any
@@ -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) > 2 or abs(dy) > 2:
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) > 2 or abs(dy) > 2:
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
- main_window_edit_3d.py
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 = [l[0] for l in self.measurement_labels]
173
- labels = [l[1] for l in self.measurement_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 l, _ in enumerate(frag_j["indices"]):
1447
- pos_j = positions_j[l]
1448
- vdw_j = vdw_j_all[l]
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, SNAP_DISTANCE, SUM_TOLERANCE
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(self, start_atom: Any, bond_length: Any) -> Any:
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
- angle_rad = math.radians(60)
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
- elif self.get_setting("template_fusing_enabled_2d", True):
824
- fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
825
- item_at_cursor = self.find_atom_near(cursor_pos, tol=fuse_dist)
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
- if self.get_setting("template_fusing_enabled_2d", True):
1172
- fuse_dist = self.get_setting(
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 (id1, id2), bdata in self.data.bonds.items():
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.mode.startswith("bond")
311
- and self.get_setting("template_fusing_enabled_2d", True)
312
- and self.press_pos
313
- ):
314
- fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
315
- item = self.find_atom_near(self.press_pos, tol=fuse_dist)
316
- if item is None:
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 self.get_setting("template_fusing_enabled_2d", True) and current_pos:
364
- fuse_dist = self.get_setting("template_fusing_distance_2d", 7.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
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 self.get_setting("template_fusing_enabled_2d", True) and end_pos:
561
- fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
562
- end_item = self.find_atom_near(end_pos, tol=fuse_dist)
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
- end_item = self.itemAt(end_pos, self.views()[0].transform())
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 self.get_setting("template_fusing_enabled_2d", True) and end_pos:
615
- fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
616
- end_item = self.find_atom_near(end_pos, tol=fuse_dist)
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
- end_item = self.itemAt(end_pos, self.views()[0].transform())
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 = []