MoleditPy-linux 3.6.0__py3-none-any.whl → 3.6.2__py3-none-any.whl

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.
@@ -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_linux."""
12
+ Top-level package for moleditpy_linux.
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 = False
19
20
  except ImportError:
20
21
  OBABEL_AVAILABLE = False
21
-
22
- from .utils.constants import VERSION as __version__ # noqa: F401
@@ -13,6 +13,7 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
 
15
15
  from typing import Any, Optional
16
+ import logging
16
17
 
17
18
  import numpy as np
18
19
 
@@ -101,7 +102,7 @@ def _projected_radius_px(
101
102
  return radius_px
102
103
 
103
104
 
104
- def pick_atom_index_from_screen(
105
+ def pick_atom_index_from_screen_sequential(
105
106
  view_3d_manager: Any,
106
107
  click_pos: tuple[int, int],
107
108
  mol: Optional[Any] = None,
@@ -161,3 +162,149 @@ def pick_atom_index_from_screen(
161
162
  best_score = score
162
163
 
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_linux.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_linux.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_linux.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 = []
@@ -288,10 +288,10 @@ class MoveGroupDialog(BasePickingDialog):
288
288
  RuntimeError,
289
289
  ValueError,
290
290
  TypeError,
291
- ):
292
- pass
293
- except (AttributeError, RuntimeError, ValueError, TypeError):
294
- pass
291
+ ) as e:
292
+ logging.debug(f"Failed to set closed hand cursor: {e}")
293
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
294
+ logging.debug(f"Error initiating drag on move: {e}")
295
295
 
296
296
  if not self.is_dragging_group:
297
297
  return False
@@ -305,10 +305,10 @@ class MoveGroupDialog(BasePickingDialog):
305
305
  current_pos = interactor.GetEventPosition()
306
306
  dx = current_pos[0] - self.drag_start_pos[0]
307
307
  dy = current_pos[1] - self.drag_start_pos[1]
308
- if abs(dx) > 2 or abs(dy) > 2:
308
+ if abs(dx) > 5 or abs(dy) > 5:
309
309
  self.mouse_moved_during_drag = True
310
- except (AttributeError, RuntimeError, ValueError, TypeError):
311
- pass
310
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
311
+ logging.debug(f"Error tracking drag movement: {e}")
312
312
  return True
313
313
 
314
314
  # Hover handling
@@ -329,8 +329,8 @@ class MoveGroupDialog(BasePickingDialog):
329
329
  plotter_ref.setCursor(Qt.CursorShape.OpenHandCursor)
330
330
  else:
331
331
  plotter_ref.setCursor(Qt.CursorShape.ArrowCursor)
332
- except (AttributeError, RuntimeError, ValueError, TypeError):
333
- pass
332
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
333
+ logging.debug(f"Error updating hover cursor: {e}")
334
334
 
335
335
  return False
336
336
 
@@ -373,16 +373,18 @@ class MoveGroupDialog(BasePickingDialog):
373
373
  RuntimeError,
374
374
  ValueError,
375
375
  TypeError,
376
- ):
377
- pass
376
+ ) as e:
377
+ logging.debug(
378
+ f"Failed to reset cursor to arrow: {e}"
379
+ )
378
380
  return True
379
381
  else:
380
382
  logging.error(
381
383
  "REPORT ERROR: Missing attribute 'clicked_atom_for_toggle' on self"
382
384
  )
383
385
 
384
- except (AttributeError, RuntimeError, ValueError, TypeError):
385
- pass
386
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
387
+ logging.debug(f"Error in mouse release handling: {e}")
386
388
  finally:
387
389
  self.is_dragging_group = False
388
390
  self.drag_start_pos = None
@@ -392,8 +394,15 @@ class MoveGroupDialog(BasePickingDialog):
392
394
  plotter_ptr = self.main_window.view_3d_manager.plotter
393
395
  if plotter_ptr is not None:
394
396
  plotter_ptr.setCursor(Qt.CursorShape.ArrowCursor)
395
- except (AttributeError, RuntimeError, ValueError, TypeError):
396
- pass
397
+ except (
398
+ AttributeError,
399
+ RuntimeError,
400
+ ValueError,
401
+ TypeError,
402
+ ) as e:
403
+ logging.debug(
404
+ f"Failed to reset cursor in release finally: {e}"
405
+ )
397
406
 
398
407
  return True
399
408
 
@@ -428,10 +437,11 @@ class MoveGroupDialog(BasePickingDialog):
428
437
  # Toggle group
429
438
  if visited.issubset(self.group_atoms):
430
439
  self.group_atoms -= visited
440
+ if atom_idx in self.selected_atoms:
441
+ self.selected_atoms.remove(atom_idx)
431
442
  else:
432
443
  self.group_atoms |= visited
433
-
434
- self.selected_atoms.add(atom_idx)
444
+ self.selected_atoms.add(atom_idx)
435
445
  self.show_atom_labels()
436
446
  self.update_display()
437
447
 
@@ -450,13 +460,18 @@ class MoveGroupDialog(BasePickingDialog):
450
460
 
451
461
  def show_atom_labels(self) -> None:
452
462
  """Highlight atoms in the selected group."""
463
+ plotter = self.main_window.view_3d_manager.plotter
464
+ try:
465
+ cam = plotter.camera_position if plotter else None
466
+ except (AttributeError, RuntimeError, TypeError):
467
+ cam = None
468
+
453
469
  self.clear_atom_labels()
454
470
 
455
471
  if not self.group_atoms:
456
472
  return
457
473
 
458
474
  selected_indices = list(self.group_atoms)
459
- plotter = self.main_window.view_3d_manager.plotter
460
475
  if self.main_window.view_3d_manager.atom_positions_3d is None:
461
476
  logging.error("atom_positions_3d is None in update_atom_labels")
462
477
  return
@@ -486,8 +501,15 @@ class MoveGroupDialog(BasePickingDialog):
486
501
  opacity=0.3,
487
502
  name="move_group_highlight",
488
503
  pickable=False,
504
+ reset_camera=False,
489
505
  )
490
506
 
507
+ if cam is not None:
508
+ try:
509
+ plotter.camera_position = cam
510
+ except (AttributeError, RuntimeError, TypeError):
511
+ pass
512
+
491
513
  plotter.render()
492
514
 
493
515
  def clear_atom_labels(self) -> None:
@@ -53,17 +53,15 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
53
53
  self.selected_atoms.update(preselected_atoms)
54
54
 
55
55
  self.clicked_atom_for_toggle: Optional[int] = None
56
+ # State for group movement (used by dialog's own event filter)
57
+ self.drag_atom_idx: Optional[int] = None
58
+ self.potential_drag: bool = False
59
+ self.is_dragging_group: bool = False
60
+ self.drag_start_pos: Optional[Any] = None
61
+ self.mouse_moved_during_drag: bool = False
62
+ self._consume_next_left_release: bool = False
56
63
  self.highlight_actor: Optional[pv.Actor] = None
57
64
 
58
- # Grouped states to comply with Pylint instance attribute limit
59
- self.drag_state: dict[str, Any] = {
60
- "drag_atom_idx": None,
61
- "potential_drag": False,
62
- "is_dragging_group": False,
63
- "drag_start_pos": (0, 0),
64
- "mouse_moved_during_drag": False,
65
- "consume_next_left_release": False,
66
- }
67
65
  self.widgets: dict[str, Any] = {}
68
66
 
69
67
  self.init_ui()
@@ -231,180 +229,6 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
231
229
 
232
230
  layout.addLayout(button_layout)
233
231
 
234
- def _handle_double_click(self) -> bool:
235
- """Handle MouseButtonDblClick event."""
236
- self.drag_state["is_dragging_group"] = False
237
- self.drag_state["drag_start_pos"] = (0, 0)
238
- self.drag_state["mouse_moved_during_drag"] = False
239
- self.drag_state["potential_drag"] = False
240
- self.clicked_atom_for_toggle = None
241
- return False
242
-
243
- def _handle_mouse_press(self, plotter: Any) -> bool:
244
- """Handle MouseButtonPress event for LeftButton."""
245
- self.drag_state["is_dragging_group"] = False
246
- self.drag_state["potential_drag"] = False
247
- self.clicked_atom_for_toggle = None
248
-
249
- if self.selected_atoms:
250
- return False
251
-
252
- try:
253
- interactor = plotter.interactor
254
- if interactor is None:
255
- return False
256
- click_pos = interactor.GetEventPosition()
257
-
258
- clicked_atom_idx = pick_atom_index_from_screen(
259
- self.main_window.view_3d_manager,
260
- (int(click_pos[0]), int(click_pos[1])),
261
- self.mol,
262
- )
263
-
264
- if clicked_atom_idx is not None:
265
- if self.selected_atoms and clicked_atom_idx in self.selected_atoms:
266
- self.drag_state["is_dragging_group"] = False
267
- self.drag_state["drag_start_pos"] = click_pos
268
- self.drag_state["drag_atom_idx"] = clicked_atom_idx
269
- self.drag_state["mouse_moved_during_drag"] = False
270
- self.drag_state["potential_drag"] = True
271
- self.clicked_atom_for_toggle = clicked_atom_idx
272
- return False
273
-
274
- self.on_atom_picked(clicked_atom_idx)
275
- self.drag_state["consume_next_left_release"] = True
276
- return True
277
-
278
- return False
279
-
280
- except (AttributeError, RuntimeError, ValueError) as e:
281
- logging.debug("Error in mouse press: %s", e)
282
- return False
283
-
284
- def _handle_potential_drag(self, plotter: Any) -> bool:
285
- """Handle potential drag checking and threshold transition."""
286
- start_pos = self.drag_state["drag_start_pos"]
287
- try:
288
- interactor = plotter.interactor
289
- if interactor is None:
290
- return False
291
- current_pos = interactor.GetEventPosition()
292
- dx = current_pos[0] - start_pos[0]
293
- dy = current_pos[1] - start_pos[1]
294
-
295
- if abs(dx) > 5 or abs(dy) > 5:
296
- self.drag_state["is_dragging_group"] = True
297
- self.drag_state["potential_drag"] = False
298
- try:
299
- plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
300
- except (AttributeError, RuntimeError, ValueError, TypeError):
301
- pass
302
- except (AttributeError, RuntimeError, ValueError, TypeError):
303
- pass
304
- return self.drag_state["is_dragging_group"]
305
-
306
- def _handle_actual_drag(self, plotter: Any) -> bool:
307
- """Update drag movement flags during active dragging."""
308
- start_pos = self.drag_state["drag_start_pos"]
309
- try:
310
- interactor = plotter.interactor
311
- if interactor is None:
312
- return False
313
- current_pos = interactor.GetEventPosition()
314
- dx = current_pos[0] - start_pos[0]
315
- dy = current_pos[1] - start_pos[1]
316
- if abs(dx) > 2 or abs(dy) > 2:
317
- self.drag_state["mouse_moved_during_drag"] = True
318
- except (AttributeError, RuntimeError, ValueError, TypeError):
319
- pass
320
- return True
321
-
322
- def _handle_hover_cursor(self, plotter: Any) -> bool:
323
- """Set appropriate hover cursor style over selected atoms."""
324
- try:
325
- interactor = plotter.interactor
326
- if interactor is None:
327
- return False
328
- current_pos = interactor.GetEventPosition()
329
- closest_atom_idx = pick_atom_index_from_screen(
330
- self.main_window.view_3d_manager,
331
- (int(current_pos[0]), int(current_pos[1])),
332
- self.mol,
333
- )
334
-
335
- if closest_atom_idx in self.selected_atoms:
336
- plotter.setCursor(Qt.CursorShape.OpenHandCursor)
337
- else:
338
- plotter.setCursor(Qt.CursorShape.ArrowCursor)
339
- except (AttributeError, RuntimeError, ValueError, TypeError):
340
- pass
341
- return False
342
-
343
- def _handle_mouse_move(self, plotter: Any) -> bool:
344
- """Handle MouseMove event."""
345
- if (
346
- self.drag_state["potential_drag"]
347
- and not self.drag_state["is_dragging_group"]
348
- ):
349
- if not self._handle_potential_drag(plotter):
350
- return False
351
-
352
- if self.drag_state["is_dragging_group"]:
353
- return self._handle_actual_drag(plotter)
354
-
355
- if self.selected_atoms:
356
- return self._handle_hover_cursor(plotter)
357
-
358
- return False
359
-
360
- def _reset_drag_state(self) -> None:
361
- """Reset internal drag flags."""
362
- self.drag_state["is_dragging_group"] = False
363
- self.drag_state["drag_start_pos"] = (0, 0)
364
- self.drag_state["mouse_moved_during_drag"] = False
365
- self.drag_state["potential_drag"] = False
366
-
367
- def _restore_arrow_cursor(self) -> None:
368
- """Restore standard arrow cursor in the 3D viewer."""
369
- try:
370
- plotter_ptr = self.main_window.view_3d_manager.plotter
371
- if plotter_ptr is not None:
372
- plotter_ptr.setCursor(Qt.CursorShape.ArrowCursor)
373
- except (AttributeError, RuntimeError, ValueError, TypeError):
374
- pass
375
-
376
- def _handle_mouse_release(self) -> bool:
377
- """Handle MouseButtonRelease event for LeftButton."""
378
- if self.drag_state["consume_next_left_release"]:
379
- self.drag_state["consume_next_left_release"] = False
380
- return True
381
-
382
- is_drag_active = (
383
- self.drag_state["potential_drag"] or self.drag_state["is_dragging_group"]
384
- )
385
- if not is_drag_active:
386
- return False
387
-
388
- has_moved = (
389
- self.drag_state["is_dragging_group"]
390
- and self.drag_state["mouse_moved_during_drag"]
391
- )
392
- clicked_atom = self.clicked_atom_for_toggle
393
-
394
- if not has_moved and clicked_atom is not None:
395
- self.clicked_atom_for_toggle = None
396
- self._reset_drag_state()
397
- try:
398
- self.on_atom_picked(clicked_atom)
399
- except (AttributeError, RuntimeError, ValueError, TypeError):
400
- pass
401
- self._restore_arrow_cursor()
402
- return True
403
-
404
- self._reset_drag_state()
405
- self._restore_arrow_cursor()
406
- return True
407
-
408
232
  def eventFilter(self, obj: Any, event: Any) -> bool:
409
233
  """Mouse event handling in 3D view.
410
234
 
@@ -417,31 +241,207 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
417
241
  if obj != plotter.interactor:
418
242
  return super().eventFilter(obj, event)
419
243
 
420
- result = False
421
244
  e_type = event.type()
422
245
 
423
246
  if e_type == QEvent.Type.MouseButtonDblClick:
424
- result = self._handle_double_click()
425
- elif (
247
+ # Ignore double clicks and reset state
248
+ self.is_dragging_group = False
249
+ self.drag_start_pos = None
250
+ self.mouse_moved_during_drag = False
251
+ self.potential_drag = False
252
+ self.clicked_atom_for_toggle = None
253
+ return False
254
+
255
+ if (
426
256
  e_type == QEvent.Type.MouseButtonPress
427
257
  and isinstance(event, QMouseEvent)
428
258
  and event.button() == Qt.MouseButton.LeftButton
429
259
  ):
430
- result = self._handle_mouse_press(plotter)
260
+ # Clean up previous state (triple-click countermeasure)
261
+ self.is_dragging_group = False
262
+ self.potential_drag = False
263
+ self.clicked_atom_for_toggle = None
264
+ # Delegate to CustomInteractorStyle if atoms are already selected
265
+ if self.selected_atoms:
266
+ return False
267
+
268
+ # Mouse press handling
269
+ try:
270
+ interactor = plotter.interactor
271
+ if interactor is None:
272
+ return False
273
+ click_pos = interactor.GetEventPosition()
274
+
275
+ clicked_atom_idx = pick_atom_index_from_screen(
276
+ self.main_window.view_3d_manager,
277
+ (int(click_pos[0]), int(click_pos[1])),
278
+ self.mol,
279
+ )
280
+
281
+ # Handle clicked atom
282
+ if clicked_atom_idx is not None:
283
+ if self.selected_atoms and clicked_atom_idx in self.selected_atoms:
284
+ # Atom within existing group - prepare for drag
285
+ self.is_dragging_group = False
286
+ self.drag_start_pos = click_pos
287
+ self.drag_atom_idx = clicked_atom_idx
288
+ self.mouse_moved_during_drag = False
289
+ self.potential_drag = True
290
+ self.clicked_atom_for_toggle = clicked_atom_idx
291
+ return False
292
+ else:
293
+ # Atom outside group - select
294
+ self.on_atom_picked(clicked_atom_idx)
295
+ self._consume_next_left_release = True
296
+ return True
297
+ else:
298
+ # Clicked outside atoms
299
+ return False
300
+
301
+ except (AttributeError, RuntimeError, ValueError) as e:
302
+ logging.debug(f"Error in mouse press: {e}")
303
+ return False
304
+
431
305
  elif e_type == QEvent.Type.MouseMove and isinstance(event, QMouseEvent):
432
- result = self._handle_mouse_move(plotter)
306
+ # Mouse move handling
307
+ if (
308
+ getattr(self, "potential_drag", False)
309
+ and self.drag_start_pos
310
+ and not self.is_dragging_group
311
+ ):
312
+ try:
313
+ plotter_ref = self.main_window.view_3d_manager.plotter
314
+ if plotter_ref is None or plotter_ref.interactor is None:
315
+ return False
316
+ interactor = plotter_ref.interactor
317
+ current_pos = interactor.GetEventPosition()
318
+ dx = current_pos[0] - self.drag_start_pos[0]
319
+ dy = current_pos[1] - self.drag_start_pos[1]
320
+
321
+ # Start drag if threshold is exceeded
322
+ drag_threshold = 5 # pixels
323
+ if abs(dx) > drag_threshold or abs(dy) > drag_threshold:
324
+ self.is_dragging_group = True
325
+ self.potential_drag = False
326
+ try:
327
+ plotter_ptr = self.main_window.view_3d_manager.plotter
328
+ if plotter_ptr is not None:
329
+ plotter_ptr.setCursor(Qt.CursorShape.ClosedHandCursor)
330
+ except (
331
+ AttributeError,
332
+ RuntimeError,
333
+ ValueError,
334
+ TypeError,
335
+ ) as e:
336
+ logging.debug(f"Failed to set closed hand cursor: {e}")
337
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
338
+ logging.debug(f"Error initiating drag on move: {e}")
339
+
340
+ if not self.is_dragging_group:
341
+ return False
342
+
343
+ if self.is_dragging_group and self.drag_start_pos:
344
+ try:
345
+ plotter_ref = self.main_window.view_3d_manager.plotter
346
+ if plotter_ref is None or plotter_ref.interactor is None:
347
+ return False
348
+ interactor = plotter_ref.interactor
349
+ current_pos = interactor.GetEventPosition()
350
+ dx = current_pos[0] - self.drag_start_pos[0]
351
+ dy = current_pos[1] - self.drag_start_pos[1]
352
+ if abs(dx) > 5 or abs(dy) > 5:
353
+ self.mouse_moved_during_drag = True
354
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
355
+ logging.debug(f"Error tracking drag movement: {e}")
356
+ return True
357
+
358
+ # Hover handling
359
+ if self.selected_atoms:
360
+ try:
361
+ plotter_ref = self.main_window.view_3d_manager.plotter
362
+ if plotter_ref is None or plotter_ref.interactor is None:
363
+ return False
364
+ interactor = plotter_ref.interactor
365
+ current_pos = interactor.GetEventPosition()
366
+ closest_atom_idx = pick_atom_index_from_screen(
367
+ self.main_window.view_3d_manager,
368
+ (int(current_pos[0]), int(current_pos[1])),
369
+ self.mol,
370
+ )
371
+
372
+ if closest_atom_idx in self.selected_atoms:
373
+ plotter_ref.setCursor(Qt.CursorShape.OpenHandCursor)
374
+ else:
375
+ plotter_ref.setCursor(Qt.CursorShape.ArrowCursor)
376
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
377
+ logging.debug(f"Error updating hover cursor: {e}")
378
+
379
+ return False
380
+
433
381
  elif (
434
382
  e_type == QEvent.Type.MouseButtonRelease
435
383
  and isinstance(event, QMouseEvent)
436
384
  and event.button() == Qt.MouseButton.LeftButton
437
385
  ):
438
- result = self._handle_mouse_release()
386
+ if self._consume_next_left_release:
387
+ self._consume_next_left_release = False
388
+ return True
389
+
390
+ if getattr(self, "potential_drag", False) or (
391
+ self.is_dragging_group and self.drag_start_pos
392
+ ):
393
+ try:
394
+ if not (self.is_dragging_group and self.mouse_moved_during_drag):
395
+ # Mouse move below threshold = simple click (toggle)
396
+ if self.clicked_atom_for_toggle is not None:
397
+ clicked_atom = self.clicked_atom_for_toggle
398
+ self.clicked_atom_for_toggle = None
399
+ self.is_dragging_group = False
400
+ self.drag_start_pos = None
401
+ self.mouse_moved_during_drag = False
402
+ self.potential_drag = False
403
+ if clicked_atom is not None:
404
+ self.on_atom_picked(clicked_atom)
405
+ try:
406
+ plotter_ptr = self.main_window.view_3d_manager.plotter
407
+ if plotter_ptr is not None:
408
+ plotter_ptr.setCursor(Qt.CursorShape.ArrowCursor)
409
+ except (
410
+ AttributeError,
411
+ RuntimeError,
412
+ ValueError,
413
+ TypeError,
414
+ ) as e:
415
+ logging.debug(f"Failed to reset cursor to arrow: {e}")
416
+ return True
417
+ else:
418
+ logging.error(
419
+ "REPORT ERROR: Missing attribute 'clicked_atom_for_toggle' on self"
420
+ )
421
+
422
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
423
+ logging.debug(f"Error in mouse release handling: {e}")
424
+ finally:
425
+ self.is_dragging_group = False
426
+ self.drag_start_pos = None
427
+ self.mouse_moved_during_drag = False
428
+ self.potential_drag = False
429
+ try:
430
+ plotter_ptr = self.main_window.view_3d_manager.plotter
431
+ if plotter_ptr is not None:
432
+ plotter_ptr.setCursor(Qt.CursorShape.ArrowCursor)
433
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
434
+ logging.debug(f"Failed to reset cursor in release finally: {e}")
439
435
 
440
- return result
436
+ return True
437
+
438
+ return False
439
+
440
+ return super().eventFilter(obj, event)
441
441
 
442
442
  def on_atom_picked(self, atom_idx: int) -> None:
443
443
  """Select or deselect the clicked atom."""
444
- if self.drag_state["is_dragging_group"]:
444
+ if getattr(self, "is_dragging_group", False):
445
445
  return
446
446
 
447
447
  if atom_idx in self.selected_atoms:
@@ -471,13 +471,18 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
471
471
 
472
472
  def show_atom_labels(self) -> None:
473
473
  """Highlight selected atoms."""
474
+ plotter = self.main_window.view_3d_manager.plotter
475
+ try:
476
+ cam = plotter.camera_position if plotter else None
477
+ except (AttributeError, RuntimeError, TypeError):
478
+ cam = None
479
+
474
480
  self.clear_atom_labels()
475
481
 
476
482
  if not self.selected_atoms:
477
483
  return
478
484
 
479
485
  selected_indices = list(self.selected_atoms)
480
- plotter = self.main_window.view_3d_manager.plotter
481
486
  if self.main_window.view_3d_manager.atom_positions_3d is None:
482
487
  logging.error("atom_positions_3d is None in update_atom_labels")
483
488
  return
@@ -507,8 +512,15 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
507
512
  opacity=0.3,
508
513
  name="move_selected_atoms_highlight",
509
514
  pickable=False,
515
+ reset_camera=False,
510
516
  )
511
517
 
518
+ if cam is not None:
519
+ try:
520
+ plotter.camera_position = cam
521
+ except (AttributeError, RuntimeError, TypeError):
522
+ pass
523
+
512
524
  plotter.render()
513
525
 
514
526
  def clear_atom_labels(self) -> None:
@@ -636,5 +648,5 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
636
648
  self.selected_atoms.clear()
637
649
  self.clear_atom_labels()
638
650
  self.update_display()
639
- self.drag_state["is_dragging_group"] = False
640
- self.drag_state["drag_start_pos"] = (0, 0)
651
+ self.is_dragging_group = False
652
+ self.drag_start_pos = None
@@ -147,6 +147,26 @@ class Settings2DTab(SettingsTabBase):
147
147
 
148
148
  form_layout.addRow(self._create_separator())
149
149
 
150
+ # --- Bond Snapping Settings ---
151
+ form_layout.addRow(QLabel("<b>Bond Snapping Settings</b>"))
152
+
153
+ # Bond Snapping Distance
154
+ self.bond_snapping_distance_2d_slider, self.bond_snapping_distance_2d_label = (
155
+ self._create_slider(5, 50, 1.0, is_int=True)
156
+ )
157
+ self.bond_snapping_distance_2d_slider.setToolTip(
158
+ "The distance in pixels within which drawing a bond will snap to an existing atom."
159
+ )
160
+ form_layout.addRow(
161
+ "Bond Snapping Distance (px):",
162
+ self._wrap_layout(
163
+ self.bond_snapping_distance_2d_slider,
164
+ self.bond_snapping_distance_2d_label,
165
+ ),
166
+ )
167
+
168
+ form_layout.addRow(self._create_separator())
169
+
150
170
  # --- Template Settings ---
151
171
  form_layout.addRow(QLabel("<b>Template Settings</b>"))
152
172
 
@@ -270,6 +290,10 @@ class Settings2DTab(SettingsTabBase):
270
290
  self.template_fusing_distance_2d_slider.setEnabled(fusing_enabled)
271
291
  self.template_fusing_distance_2d_label.setEnabled(fusing_enabled)
272
292
 
293
+ self.bond_snapping_distance_2d_slider.setValue(
294
+ int(settings_dict.get("bond_snapping_distance_2d", 14.0))
295
+ )
296
+
273
297
  self.template_fusing_distance_2d_slider.setValue(
274
298
  int(settings_dict.get("template_fusing_distance_2d", 14.0))
275
299
  )
@@ -290,6 +314,9 @@ class Settings2DTab(SettingsTabBase):
290
314
  "atom_font_family_2d": self.atom_font_family_2d_combo.currentFont().family(),
291
315
  "atom_font_size_2d": self.atom_font_size_2d_slider.value(),
292
316
  "atom_use_bond_color_2d": self.atom_use_bond_color_2d_checkbox.isChecked(),
317
+ "bond_snapping_distance_2d": float(
318
+ self.bond_snapping_distance_2d_slider.value()
319
+ ),
293
320
  "template_fusing_enabled_2d": self.template_fusing_enabled_2d_checkbox.isChecked(),
294
321
  "template_fusing_distance_2d": float(
295
322
  self.template_fusing_distance_2d_slider.value()
@@ -19,7 +19,6 @@ from PyQt6.QtWidgets import (
19
19
  QFormLayout,
20
20
  QFrame,
21
21
  QHBoxLayout,
22
- QLabel,
23
22
  QSlider,
24
23
  QWidget,
25
24
  QDoubleSpinBox,
@@ -17,7 +17,6 @@ from PyQt6.QtCore import Qt
17
17
  from PyQt6.QtWidgets import (
18
18
  QFrame,
19
19
  QHBoxLayout,
20
- QLabel,
21
20
  QSlider,
22
21
  QWidget,
23
22
  QSpinBox,
@@ -74,4 +74,5 @@ DEFAULT_SETTINGS = {
74
74
  "template_fusing_enabled_2d": True,
75
75
  "template_fusing_distance_2d": 7.0,
76
76
  "template_snapping_distance_2d": 14.0,
77
+ "bond_snapping_distance_2d": 14.0,
77
78
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 3.6.0
3
+ Version: 3.6.2
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1,4 +1,4 @@
1
- moleditpy_linux/__init__.py,sha256=D-XFLIZekmDUAxD9B6ntq6CFggaD_IHAhJ_BDocNKDQ,498
1
+ moleditpy_linux/__init__.py,sha256=SBIBlrNkWvxt6fWR02XOA9A0Bokj_a5xhv7OZIaxhZ4,490
2
2
  moleditpy_linux/__main__.py,sha256=VHCD-CCX6iKUbmUyRv2BGVXCjHWEaISpmjO7_yh9AaU,654
3
3
  moleditpy_linux/main.py,sha256=jkew0v747koc4MTTlH6S3OfqkDI05cgsn9vWFZlJDQA,5444
4
4
  moleditpy_linux/assets/file_icon.ico,sha256=yyVj084A7HuMNbV073cE_Ag3Ne405qgOP3Mia1ZqLpE,101632
@@ -20,31 +20,31 @@ moleditpy_linux/ui/analysis_window.py,sha256=iVYzxLpL78XS46lzRC0DpT3hwD6FS60su_K
20
20
  moleditpy_linux/ui/angle_dialog.py,sha256=jEtq0BcMmaIq7iZA26j5w7n6DBwPZswcHuGhsj2nRUA,17803
21
21
  moleditpy_linux/ui/app_state.py,sha256=aNU2dy2lbW17pkZOurORFYmIimu715DioSH33UZn2QQ,40162
22
22
  moleditpy_linux/ui/atom_item.py,sha256=tN7paZGVVOLR2ppuI_qcYajiQ7YLH4mOhjzBYHeEN3w,21144
23
- moleditpy_linux/ui/atom_picking.py,sha256=seSG2XIFndYul2Axa9cnfUGV51CPO5PFzxddMHgwh9c,5190
23
+ moleditpy_linux/ui/atom_picking.py,sha256=HhJ8kH1zVF3DIzMG1Qymk44d8MwUqbg9xr4pQ6H0lHA,10391
24
24
  moleditpy_linux/ui/base_picking_dialog.py,sha256=t4kdyKXgHj6IGbjL_bOJbFbIlaYkERPcxKeir1u-roA,5682
25
25
  moleditpy_linux/ui/bond_item.py,sha256=sn3Vk1XblmjfDP1zzPS_mn_5byFA4xPR_-qoYWjKTe8,26588
26
26
  moleditpy_linux/ui/bond_length_dialog.py,sha256=U7YQ6r75TueSA7lV_B9dUEnaEjOLEgkT1aW8KUItbtI,14962
27
27
  moleditpy_linux/ui/calculation_worker.py,sha256=HJz5NkXfNS66OODxKkR-IfGUtv7hRG-mp4Wfapw6D7M,41125
28
28
  moleditpy_linux/ui/color_settings_dialog.py,sha256=sV9w-fRh8dcRLBPwyNGkvATmOJXlvm8Em2yGpGHHb_k,16642
29
29
  moleditpy_linux/ui/compute_logic.py,sha256=qxUXBKb3-AvE0FdtrgeyyO9Dc93sEVpPP5sAviewZ78,28425
30
- moleditpy_linux/ui/constrained_optimization_dialog.py,sha256=vuGOS8X4MWU1mnk4jE9UWZucltjgcrB_LSjKB7ZXfv8,35070
31
- moleditpy_linux/ui/custom_interactor_style.py,sha256=TIi4bptNL_63vslyjav5DDMl2ZSDVAYfYDYKmq4F4GI,42135
30
+ moleditpy_linux/ui/constrained_optimization_dialog.py,sha256=PNQN2d0nufI9s-N-60zG2HeKUuYzWFzNudYHb5y4KOQ,35042
31
+ moleditpy_linux/ui/custom_interactor_style.py,sha256=poOgprQeffKtdL7cvoxWVPgRefO0OAOkAja_NOag4Ms,42564
32
32
  moleditpy_linux/ui/custom_qt_interactor.py,sha256=oAfCHIDVOJmLTdowrBiGsaCoQMJ-ZnQhpSkDLIHMe9U,3735
33
33
  moleditpy_linux/ui/dialog_3d_picking_mixin.py,sha256=iJg9bZDj2wvOG178mwOus5Y-6HmSmIMCX0KyvOJ-l0U,9901
34
34
  moleditpy_linux/ui/dialog_logic.py,sha256=nsQ7nhQrOH-DmM49rhu-S4dvmbQPDyXh17efJzjuC3k,21356
35
35
  moleditpy_linux/ui/dihedral_dialog.py,sha256=ZDihYt32_3V_uJXsJ4u3qGxn3pASWPcl8BIY3nTBQ0Q,18103
36
- moleditpy_linux/ui/edit_3d_logic.py,sha256=eskqxtcOnWADu4MrskQSG9RGUIHqR_XHoqMFm9uiJNw,19753
37
- moleditpy_linux/ui/edit_actions_logic.py,sha256=Y3BZjeS8sVvJO9CzJUfRYMwbjkShMFQjSarlZWS_Fpo,67665
36
+ moleditpy_linux/ui/edit_3d_logic.py,sha256=y-6GabTZzlLA0u3j8ugy7yHV3jA4HvWTVCAmgFEKwh4,19740
37
+ moleditpy_linux/ui/edit_actions_logic.py,sha256=KZZJOnAcN_wHwXX48z5j5zZPTO5v5f0hgikJXogbVvs,67671
38
38
  moleditpy_linux/ui/export_logic.py,sha256=4irp1z_7PRAAME-2gJUB8zVnM8eXdmaiM3vMLpLqSYg,43422
39
39
  moleditpy_linux/ui/geometry_base_dialog.py,sha256=eAlmZOPPXMKKBDWFiBN68-Zbp-EeqWrgXBf8q3ioxGY,4734
40
40
  moleditpy_linux/ui/io_logic.py,sha256=aHjZdTmBY2e35NpIgvawdOAXmDRg-DcJf9025AdSDqg,43839
41
41
  moleditpy_linux/ui/main_window.py,sha256=MIGs8M72OnT3TZG7RM8NeBfyInYysDlKzZqEoKUnSiE,4773
42
42
  moleditpy_linux/ui/main_window_init.py,sha256=lSfW6EDtcVptS37OUEPidz5WNcq7FSzmG86G8ftfH-4,94196
43
43
  moleditpy_linux/ui/mirror_dialog.py,sha256=iJriiS5U61tyw_7M_PUf3x1qAHh8ciBh751GH8O_BpQ,5075
44
- moleditpy_linux/ui/molecular_scene_handler.py,sha256=8FKOwT5p0iuOew_Z-sUNd-iBK1kFskUQhclygFMhCSM,69099
45
- moleditpy_linux/ui/molecule_scene.py,sha256=yB30JQMukQQpb_FIKq12wmCzgdr5sUtkMR-rz9w2Z5A,37914
46
- moleditpy_linux/ui/move_group_dialog.py,sha256=xR0NyDgKtoXfdgODp3hwpjez_SuA2bwCzOXQhcD52PE,25102
47
- moleditpy_linux/ui/move_selected_atoms_dialog.py,sha256=aub37TmapbYe35nx9u8LyZskf1Iyxjd5yWim7ubg84g,23214
44
+ moleditpy_linux/ui/molecular_scene_handler.py,sha256=TFmvgs10xuEI0PiRxWYvUlh_gBunUfQb1BPrIDF5CtA,69602
45
+ moleditpy_linux/ui/molecule_scene.py,sha256=qa-nWA653oEiJFJx7Gn-jBSHaPtJw4kq4KfK6pqJcQs,38524
46
+ moleditpy_linux/ui/move_group_dialog.py,sha256=1T8vxhPcSp5r71WE90bINe4lm0Q8XEvCUBlUcXhHF7w,26259
47
+ moleditpy_linux/ui/move_selected_atoms_dialog.py,sha256=samPLggErPkNQeZJ_zNbWKpbh3MFGVB6aHxg0waF5HI,25268
48
48
  moleditpy_linux/ui/periodic_table_dialog.py,sha256=2fgAILjcjYNt-x9UCqq2VFgwDY8oX7aE4jMQvyyCMho,5847
49
49
  moleditpy_linux/ui/planarize_dialog.py,sha256=F_xMk6Jcmq7PECKJfiKtQuRm1tRXLS7Se2h5wwpnD0w,7359
50
50
  moleditpy_linux/ui/settings_dialog.py,sha256=WTexmB8HOmdA-CjHK5M6TcS7ZrJtywyq7qMM2SZzu-k,7673
@@ -57,18 +57,18 @@ moleditpy_linux/ui/user_template_dialog.py,sha256=lH3gf5XtmOMxdMHWFWNhxh-c9vQcb4
57
57
  moleditpy_linux/ui/view_3d_logic.py,sha256=rO6svIUu7XurU6Vj3C7avAV5EvIAdrogevbOHXgBxIE,94668
58
58
  moleditpy_linux/ui/zoomable_view.py,sha256=eCHAgIMRaNoz7qjnZjrJBdxAmDt2bESII_Kc6l-Mzjg,5803
59
59
  moleditpy_linux/ui/settings_tabs/__init__.py,sha256=BEOaCz93HzgTe0FhBzSj6Sfk8MAWxcY9vCDGmOriJx4,267
60
- moleditpy_linux/ui/settings_tabs/settings_2d_tab.py,sha256=DntkpoTA6qpgqachiEh4tpuRnYTA0c0JwRx0hxV6YTY,11743
60
+ moleditpy_linux/ui/settings_tabs/settings_2d_tab.py,sha256=7AsEz9mIZA4DYg-eiLt1UeYX9zw-8_I12qO8GCCQQsE,12786
61
61
  moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py,sha256=S2ydaYjtc9AGZu86mDaLDi1DC6hEI5mFtPvjwVpBVaY,11341
62
- moleditpy_linux/ui/settings_tabs/settings_other_tab.py,sha256=0X-S_P0hfjcemz8rcmWVfTImpWZmQPDlqIVWvIr1j9A,5462
63
- moleditpy_linux/ui/settings_tabs/settings_tab_base.py,sha256=S356x3VnEk9tXV8agrqurizNG6sFkVTXBbbhAm6U8ew,3529
62
+ moleditpy_linux/ui/settings_tabs/settings_other_tab.py,sha256=NLJiuFi4fTA7YVjt7wdnx1qPOoL_Ur76veGFjAfexfI,5449
63
+ moleditpy_linux/ui/settings_tabs/settings_tab_base.py,sha256=pV9WdT16ZJTQGv74YIkFSQ79Iq1rerjM6G9svbYmIeY,3516
64
64
  moleditpy_linux/utils/__init__.py,sha256=BEOaCz93HzgTe0FhBzSj6Sfk8MAWxcY9vCDGmOriJx4,267
65
65
  moleditpy_linux/utils/constants.py,sha256=HsOqb9sAzRNujvBQwAeTWXS7fHfho46shmnSa3TuBDk,6633
66
- moleditpy_linux/utils/default_settings.py,sha256=fU1t9_dIKqc3ea19p1UfNPEq4ybq2aI4LfTXJhh6i08,2691
66
+ moleditpy_linux/utils/default_settings.py,sha256=OxgIrdk-LCXBdhUPF_qzsbdsiM18kDWgP3seELyUgJU,2731
67
67
  moleditpy_linux/utils/sip_isdeleted_safe.py,sha256=My6IJqDewbYY6SoRYNk6XwFUQ9_yihaR3Ym7EOETuAw,1189
68
68
  moleditpy_linux/utils/system_utils.py,sha256=K5c9cJRgMFqtUtLefSu3w2hy2drgxNqfT200bSIFy2k,2325
69
- moleditpy_linux-3.6.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
70
- moleditpy_linux-3.6.0.dist-info/METADATA,sha256=07BAWpysjHLdxDaQkKHOEsOaxNNjO8V27CWiAfoIdic,62978
71
- moleditpy_linux-3.6.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
72
- moleditpy_linux-3.6.0.dist-info/entry_points.txt,sha256=-OzipSi__yVwlimNtu3eiRP5t5UMg55Cs0udyhXYiyw,60
73
- moleditpy_linux-3.6.0.dist-info/top_level.txt,sha256=qyqe-hDYL6CXyin9E5Me5rVl3PG84VqiOjf9bQvfJLs,16
74
- moleditpy_linux-3.6.0.dist-info/RECORD,,
69
+ moleditpy_linux-3.6.2.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
70
+ moleditpy_linux-3.6.2.dist-info/METADATA,sha256=Hvax71UUKc-KHdTtT9hmn0R_dF-kUWfEdmZqqQ0T4io,62978
71
+ moleditpy_linux-3.6.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
72
+ moleditpy_linux-3.6.2.dist-info/entry_points.txt,sha256=-OzipSi__yVwlimNtu3eiRP5t5UMg55Cs0udyhXYiyw,60
73
+ moleditpy_linux-3.6.2.dist-info/top_level.txt,sha256=qyqe-hDYL6CXyin9E5Me5rVl3PG84VqiOjf9bQvfJLs,16
74
+ moleditpy_linux-3.6.2.dist-info/RECORD,,