MoleditPy-linux 4.4.1__py3-none-any.whl → 4.5.0__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.
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import logging
14
- from typing import Any, Callable, List, Optional, Union
14
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
15
15
 
16
16
 
17
17
  class PluginContext:
@@ -353,6 +353,28 @@ class PluginContext:
353
353
  """
354
354
  self._manager.register_document_reset_handler(self._plugin_name, callback)
355
355
 
356
+ def register_atom_drag_handler(
357
+ self,
358
+ callback: Callable[
359
+ [str, List[int], Dict[int, Tuple[float, float, float]]], None
360
+ ],
361
+ ) -> None:
362
+ """Register a handler called during 3D atom or group dragging.
363
+
364
+ The callback receives:
365
+ event_type (str): ``"start"``, ``"move"``, or ``"end"``.
366
+ atom_indices (List[int]): RDKit atom indices being dragged.
367
+ positions (dict[int, tuple[float, float, float]]):
368
+ Current 3D positions of the dragged atoms.
369
+ Empty dict on ``"start"``.
370
+ """
371
+ self._manager.register_atom_drag_handler(self._plugin_name, callback)
372
+
373
+ @property
374
+ def is_dragging_atom(self) -> bool:
375
+ """Return True if an atom or group is currently being dragged in 3D."""
376
+ return self._manager.is_dragging_atom() # type: ignore[no-any-return]
377
+
356
378
  def get_setting(self, key: str, default: Any = None) -> Any:
357
379
  """
358
380
  Get a plugin-specific persistent setting.
@@ -23,8 +23,9 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
23
23
 
24
24
  from PyQt6.QtCore import QUrl
25
25
  from PyQt6.QtGui import QDesktopServices
26
- from PyQt6.QtWidgets import QMessageBox
26
+ from PyQt6.QtWidgets import QApplication, QMessageBox
27
27
 
28
+ from ..utils.constants import MOVE_DIALOG_TYPES
28
29
  from .plugin_interface import PluginContext
29
30
 
30
31
 
@@ -95,6 +96,7 @@ class PluginManager:
95
96
  self.load_handlers: Dict[str, Callable] = {}
96
97
  self.custom_3d_styles: Dict[str, Dict[str, Any]] = {}
97
98
  self.document_reset_handlers: List[Dict[str, Any]] = []
99
+ self.atom_drag_handlers: List[Dict[str, Any]] = []
98
100
  self.plugin_windows: Dict[
99
101
  str, Dict[str, Any]
100
102
  ] = {} # Map of plugin_name -> {window_id -> window}
@@ -247,6 +249,7 @@ class PluginManager:
247
249
  self.load_handlers = {}
248
250
  self.custom_3d_styles = {}
249
251
  self.document_reset_handlers = []
252
+ self.atom_drag_handlers = []
250
253
 
251
254
  if not os.path.exists(self.plugin_dir):
252
255
  return []
@@ -558,6 +561,10 @@ class PluginManager:
558
561
  {"plugin": plugin_name, "callback": callback}
559
562
  )
560
563
 
564
+ def register_atom_drag_handler(self, plugin_name: str, callback: Callable) -> None:
565
+ """Register a handler called during 3D atom/group dragging."""
566
+ self.atom_drag_handlers.append({"plugin": plugin_name, "callback": callback})
567
+
561
568
  # --- New API Implementation ---
562
569
  def show_status_message(self, message: str, timeout: int = 3000) -> None:
563
570
  """Display a message in the MainWindow status bar."""
@@ -653,6 +660,48 @@ class PluginManager:
653
660
  exc_info=True,
654
661
  )
655
662
 
663
+ def invoke_atom_drag_handlers(
664
+ self,
665
+ event_type: str,
666
+ atom_indices: List[int],
667
+ positions: Dict[int, tuple],
668
+ ) -> None:
669
+ """Call all registered atom drag handlers."""
670
+ for handler in self.atom_drag_handlers:
671
+ try:
672
+ handler["callback"](event_type, atom_indices, positions)
673
+ except Exception as e: # plugins have full app access; catch everything to keep the drag alive
674
+ logging.warning(
675
+ "Error in atom drag handler for %s: %s",
676
+ handler["plugin"],
677
+ e,
678
+ exc_info=True,
679
+ )
680
+
681
+ def is_dragging_atom(self) -> bool:
682
+ """Return True if an atom or group is currently being dragged in 3D."""
683
+ mw = self.main_window
684
+ if mw is None:
685
+ return False
686
+ if getattr(mw, "dragged_atom_info", None) is not None:
687
+ return True
688
+ return self._is_dragging_group()
689
+
690
+ @staticmethod
691
+ def _is_dragging_group() -> bool:
692
+ """Return True while a Move Group / Move Selected Atoms gesture is active."""
693
+ try:
694
+ for widget in QApplication.topLevelWidgets():
695
+ if type(widget).__name__ not in MOVE_DIALOG_TYPES:
696
+ continue
697
+ if getattr(widget, "is_dragging_group_vtk", False) or getattr(
698
+ widget, "is_rotating_group_vtk", False
699
+ ):
700
+ return True
701
+ except (AttributeError, RuntimeError, TypeError):
702
+ logging.debug("Suppressed non-critical error", exc_info=True)
703
+ return False
704
+
656
705
  def get_plugin_info_safe(self, file_path: str) -> Dict[str, str]:
657
706
  """Extracts plugin metadata using AST parsing (safe, no execution)."""
658
707
  info = {
@@ -63,6 +63,8 @@ class AtomItem(QGraphicsItem):
63
63
  self.setFlags(
64
64
  QGraphicsItem.GraphicsItemFlag.ItemIsMovable
65
65
  | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
66
+ # Without this Qt never calls itemChange for moves, so bonds lag.
67
+ | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
66
68
  )
67
69
  self.setZValue(1)
68
70
  self.update_style()
@@ -13,7 +13,8 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
 
15
15
  import logging
16
- from typing import Any, Optional
16
+ import time
17
+ from typing import Any, Dict, List, Optional, Tuple
17
18
 
18
19
  import numpy as np
19
20
  from PyQt6.QtCore import Qt, QTimer
@@ -22,6 +23,7 @@ from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera #
22
23
 
23
24
 
24
25
  from .atom_picking import pick_atom_index_from_screen
26
+ from ..utils.constants import MOVE_DIALOG_TYPES
25
27
 
26
28
  from rdkit import Geometry
27
29
 
@@ -35,6 +37,32 @@ _VTKIS_ROTATE = 1
35
37
  # window-size independent and matches the feel at the default window size.
36
38
  _ROTATION_REFERENCE_SIZE = 640.0
37
39
 
40
+ # Minimum seconds between real-time drag redraws (~30 fps).
41
+ _DRAG_REDRAW_INTERVAL = 0.033
42
+
43
+ # Largest molecule that still gets real-time drag feedback (each frame is a
44
+ # full scene rebuild).
45
+ _REALTIME_DRAG_MAX_ATOMS = 300
46
+
47
+ # Radians of group rotation per pixel of mouse travel, before sensitivity.
48
+ _GROUP_ROTATION_RADIANS_PER_PIXEL = 0.008
49
+
50
+
51
+ def _rodrigues_matrix(axis: np.ndarray, angle: float) -> np.ndarray:
52
+ """Return the rotation matrix for `angle` radians about `axis` (Rodrigues)."""
53
+ norm = float(np.linalg.norm(axis))
54
+ if norm < 1e-6 or abs(angle) < 1e-6:
55
+ return np.eye(3)
56
+ unit = axis / norm
57
+ k_mat = np.array(
58
+ [
59
+ [0.0, -unit[2], unit[1]],
60
+ [unit[2], 0.0, -unit[0]],
61
+ [-unit[1], unit[0], 0.0],
62
+ ]
63
+ )
64
+ return np.eye(3) + np.sin(angle) * k_mat + (1.0 - np.cos(angle)) * (k_mat @ k_mat)
65
+
38
66
 
39
67
  class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
40
68
  """VTK interactor style extending trackball-camera with 3D atom drag and measurement."""
@@ -48,6 +76,10 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
48
76
  self._mouse_moved_during_drag = False
49
77
  self._mouse_press_pos: Optional[tuple[int, int]] = None
50
78
  self._suppress_next_left_button_up = False
79
+ # Throttle for real-time drag redraws
80
+ self._last_drag_redraw_time: float = 0.0
81
+ self._drag_redraw_pending = False
82
+ self._active_drag_atoms: Optional[List[int]] = None
51
83
 
52
84
  self.AddObserver("LeftButtonPressEvent", self.on_left_button_down) # type: ignore[arg-type]
53
85
  # self.AddObserver("LeftButtonDoubleClickEvent", self.on_left_button_down)
@@ -70,6 +102,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
70
102
  # Safe defensive fallback catching AttributeError, RuntimeError
71
103
  logging.debug("Suppressed non-critical error", exc_info=True)
72
104
 
105
+ self._end_drag_event()
106
+
73
107
  # Reset all custom flags
74
108
  self._is_dragging_atom = False
75
109
  self.is_dragging = False
@@ -91,6 +125,198 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
91
125
  # Safe defensive fallback catching AttributeError, RuntimeError
92
126
  logging.debug("Suppressed non-critical error", exc_info=True)
93
127
 
128
+ def _find_move_dialog(self) -> Any:
129
+ """Return the visible Move Group / Move Selected Atoms dialog, if any."""
130
+ try:
131
+ for widget in QApplication.topLevelWidgets():
132
+ if type(widget).__name__ in MOVE_DIALOG_TYPES and widget.isVisible():
133
+ return widget
134
+ except (AttributeError, RuntimeError, TypeError):
135
+ logging.debug("Suppressed non-critical error", exc_info=True)
136
+ return None
137
+
138
+ def _display_to_world(
139
+ self,
140
+ renderer: Any,
141
+ screen_x: float,
142
+ screen_y: float,
143
+ depth_z: float,
144
+ ) -> Optional[Tuple[float, float, float]]:
145
+ """Convert screen (display) coordinates to 3D world coordinates.
146
+
147
+ Uses the given depth_z (already in display-space) so the result
148
+ lies on the same depth plane as the reference atom.
149
+ """
150
+ try:
151
+ renderer.SetDisplayPoint(screen_x, screen_y, depth_z)
152
+ renderer.DisplayToWorld()
153
+ wp = renderer.GetWorldPoint()
154
+ return (wp[0], wp[1], wp[2])
155
+ except (AttributeError, RuntimeError, TypeError, ValueError):
156
+ logging.debug("Suppressed non-critical error", exc_info=True)
157
+ return None
158
+
159
+ def _world_to_display_depth(
160
+ self, renderer: Any, x: float, y: float, z: float
161
+ ) -> Optional[float]:
162
+ """Return the display-space Z depth for a world-space point."""
163
+ try:
164
+ renderer.SetWorldPoint(x, y, z, 1.0)
165
+ renderer.WorldToDisplay()
166
+ return float(renderer.GetDisplayPoint()[2])
167
+ except (AttributeError, RuntimeError, TypeError, ValueError):
168
+ logging.debug("Suppressed non-critical error", exc_info=True)
169
+ return None
170
+
171
+ def _invoke_drag_handlers(
172
+ self,
173
+ event_type: str,
174
+ atom_indices: List[int],
175
+ positions: Dict[int, Tuple[float, float, float]],
176
+ ) -> None:
177
+ """Forward a drag event to all registered plugin handlers."""
178
+ mw = self.main_window
179
+ if mw is None:
180
+ return
181
+ pm = getattr(mw, "plugin_manager", None)
182
+ if pm is None:
183
+ return
184
+ invoke = getattr(pm, "invoke_atom_drag_handlers", None)
185
+ if invoke is not None:
186
+ try:
187
+ invoke(event_type, atom_indices, positions)
188
+ # Plugin faults are already isolated per handler by the manager.
189
+ except (AttributeError, RuntimeError, TypeError, ValueError):
190
+ logging.warning("invoke_atom_drag_handlers raised", exc_info=True)
191
+
192
+ def _current_positions(
193
+ self, atom_indices: List[int]
194
+ ) -> Dict[int, Tuple[float, float, float]]:
195
+ """Read the current conformer coordinates of the given atoms."""
196
+ positions: Dict[int, Tuple[float, float, float]] = {}
197
+ try:
198
+ mol = self.main_window.view_3d_manager.current_mol
199
+ if mol is None or mol.GetNumConformers() == 0:
200
+ return positions
201
+ conf = mol.GetConformer()
202
+ for idx in atom_indices:
203
+ pos = conf.GetAtomPosition(idx)
204
+ positions[idx] = (float(pos.x), float(pos.y), float(pos.z))
205
+ except (AttributeError, RuntimeError, TypeError, ValueError):
206
+ logging.debug("Suppressed non-critical error", exc_info=True)
207
+ return positions
208
+
209
+ def _begin_drag_event(self, atom_indices: Any) -> None:
210
+ """Report the start of a drag gesture and arm the redraw throttle.
211
+
212
+ A right-button press never passes through reset_interactor_state, so
213
+ close any gesture whose end was missed before opening a new one.
214
+ """
215
+ self._end_drag_event()
216
+ atoms = [int(idx) for idx in atom_indices]
217
+ self._active_drag_atoms = atoms
218
+ self._last_drag_redraw_time = 0.0
219
+ self._drag_redraw_pending = False
220
+ self._invoke_drag_handlers("start", list(atoms), {})
221
+
222
+ def _end_drag_event(self) -> None:
223
+ """Report the end of the active drag gesture exactly once.
224
+
225
+ Every exit path (normal release, aborted gesture, forced reset) routes
226
+ through here so a plugin can never be left believing a drag is still
227
+ in progress.
228
+ """
229
+ atoms = self._active_drag_atoms
230
+ self._active_drag_atoms = None
231
+ self._drag_redraw_pending = False
232
+ if atoms is None:
233
+ return
234
+ self._invoke_drag_handlers("end", list(atoms), self._current_positions(atoms))
235
+
236
+ def _realtime_drag_active(self) -> bool:
237
+ """Return True when real-time drag is both enabled and affordable here.
238
+
239
+ Every frame rebuilds the whole 3D scene, so past a few hundred atoms
240
+ the rebuild costs more than the throttle interval and dragging feels
241
+ worse with it than without; the move is then applied on release only.
242
+ """
243
+ if not self._realtime_drag_enabled():
244
+ return False
245
+ return not self._molecule_too_large_for_realtime()
246
+
247
+ def _molecule_too_large_for_realtime(self) -> bool:
248
+ """Return True when the molecule exceeds the real-time drag size limit."""
249
+ try:
250
+ mol = self.main_window.view_3d_manager.current_mol
251
+ if mol is None:
252
+ return False
253
+ return int(mol.GetNumAtoms()) > _REALTIME_DRAG_MAX_ATOMS
254
+ except (AttributeError, RuntimeError, TypeError, ValueError):
255
+ logging.debug("Suppressed non-critical error", exc_info=True)
256
+ return False
257
+
258
+ def _should_drag_redraw(self) -> bool:
259
+ """Return True when a real-time drag frame may be rendered now.
260
+
261
+ Skips while a previous deferred redraw is still queued so a scene that
262
+ rebuilds slower than the throttle interval cannot pile up frames.
263
+ """
264
+ if not self._realtime_drag_active():
265
+ return False
266
+ if self._drag_redraw_pending:
267
+ return False
268
+ return time.monotonic() - self._last_drag_redraw_time >= _DRAG_REDRAW_INTERVAL
269
+
270
+ def _finish_drag_redraw(self) -> None:
271
+ """Release the redraw gate once a deferred drag frame has rendered."""
272
+ self._drag_redraw_pending = False
273
+ self._last_drag_redraw_time = time.monotonic()
274
+
275
+ def _abort_active_drag(self, moved: bool) -> None:
276
+ """Close out a gesture whose release event was lost.
277
+
278
+ Real-time dragging has already written the new geometry, so a moved
279
+ gesture still needs an undo entry even though it never reached the
280
+ release handler.
281
+ """
282
+ if self._active_drag_atoms is None:
283
+ return
284
+ if moved and self._realtime_drag_active():
285
+ self._push_undo_after_aborted_drag()
286
+ self._end_drag_event()
287
+
288
+ def _push_undo_after_aborted_drag(self) -> None:
289
+ """Record geometry left behind by a gesture whose release was lost.
290
+
291
+ Real-time dragging mutates the conformer on every frame, so an aborted
292
+ gesture would otherwise leave the molecule moved with nothing on the
293
+ undo stack.
294
+ """
295
+ try:
296
+ self.main_window.edit_actions_manager.push_undo_state()
297
+ except (AttributeError, RuntimeError, TypeError, ValueError):
298
+ logging.debug("Suppressed non-critical error", exc_info=True)
299
+
300
+ def _realtime_drag_enabled(self) -> bool:
301
+ """Return True if real-time drag redraw is enabled in settings."""
302
+ mw = self.main_window
303
+ if mw is None:
304
+ return True
305
+ try:
306
+ return bool(mw.get_settings().get("realtime_3d_drag", True))
307
+ except (AttributeError, RuntimeError, TypeError):
308
+ return True
309
+
310
+ def _rotation_sensitivity(self) -> float:
311
+ """Return the user's rotation-speed multiplier, shared by camera and group rotation."""
312
+ mw = self.main_window
313
+ if mw is None:
314
+ return 1.0
315
+ try:
316
+ return float(mw.get_settings().get("mouse_rotation_sensitivity", 1.0))
317
+ except (AttributeError, TypeError, ValueError):
318
+ return 1.0
319
+
94
320
  def _rotate_size_independent(self) -> None:
95
321
  """Rotate the camera at a speed that does not depend on canvas size.
96
322
 
@@ -106,17 +332,12 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
106
332
  dx = interactor.GetEventPosition()[0] - interactor.GetLastEventPosition()[0]
107
333
  dy = interactor.GetEventPosition()[1] - interactor.GetLastEventPosition()[1]
108
334
 
109
- sensitivity = 1.0
110
- mw = self.main_window
111
- if mw is not None:
112
- try:
113
- sensitivity = float(
114
- mw.get_settings().get("mouse_rotation_sensitivity", 1.0)
115
- )
116
- except (AttributeError, TypeError, ValueError):
117
- sensitivity = 1.0
118
-
119
- delta = -20.0 / _ROTATION_REFERENCE_SIZE * self.GetMotionFactor() * sensitivity
335
+ delta = (
336
+ -20.0
337
+ / _ROTATION_REFERENCE_SIZE
338
+ * self.GetMotionFactor()
339
+ * self._rotation_sensitivity()
340
+ )
120
341
  camera = renderer.GetActiveCamera()
121
342
  camera.Azimuth(dx * delta)
122
343
  camera.Elevation(dy * delta)
@@ -154,20 +375,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
154
375
  self._mouse_moved_during_drag = False
155
376
  self._mouse_press_pos = None
156
377
 
157
- # Check Move Group dialog
158
- # Check Move Group or Move Selected Atoms dialog
159
- move_group_dialog: Any = None
160
- for widget in QApplication.topLevelWidgets():
161
- try:
162
- if (
163
- type(widget).__name__
164
- in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
165
- and widget.isVisible()
166
- ):
167
- move_group_dialog = widget
168
- break
169
- except (AttributeError, RuntimeError, TypeError):
170
- logging.warning("Caught exception in " + __file__, exc_info=True)
378
+ move_group_dialog = self._find_move_dialog()
171
379
 
172
380
  if move_group_dialog and move_group_dialog.group_atoms:
173
381
  # Group drag if selected
@@ -197,6 +405,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
197
405
  mw.view_3d_manager.plotter.setCursor(
198
406
  Qt.CursorShape.ClosedHandCursor
199
407
  )
408
+ self._begin_drag_event(move_group_dialog.group_atoms)
200
409
  self._suppress_next_left_button_up = True
201
410
  return # Disable camera rotation
202
411
  else:
@@ -358,6 +567,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
358
567
  mw.view_3d_manager.plotter.setCursor(
359
568
  Qt.CursorShape.ClosedHandCursor
360
569
  )
570
+ self._begin_drag_event([int(closest_atom_idx)])
361
571
  self._suppress_next_left_button_up = True
362
572
  return # Prevent camera rotation
363
573
 
@@ -373,21 +583,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
373
583
  """
374
584
  mw = self.main_window
375
585
 
376
- # Check if Move Group dialog or Move Selected Atoms dialog is open
377
- move_group_dialog: Any = None
378
- try:
379
- for widget in QApplication.topLevelWidgets():
380
- if (
381
- type(widget).__name__
382
- in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
383
- and widget.isVisible()
384
- ):
385
- move_group_dialog = widget
386
- break
387
- except (AttributeError, RuntimeError, TypeError) as e:
388
- logging.debug(
389
- f"Suppressed exception: {e}"
390
- ) # Suppress non-critical widget search noise
586
+ move_group_dialog = self._find_move_dialog()
391
587
 
392
588
  if move_group_dialog and move_group_dialog.group_atoms:
393
589
  # Start rotation drag if group selected
@@ -398,17 +594,29 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
398
594
  mw.view_3d_manager.current_mol,
399
595
  )
400
596
 
401
- # Start rotation drag if atom inside group clicked
402
- if (
597
+ follow_mouse = False
598
+ try:
599
+ follow_mouse = bool(
600
+ mw.get_settings().get("rotate_group_follow_mouse", False)
601
+ )
602
+ except (AttributeError, RuntimeError, TypeError):
603
+ follow_mouse = False
604
+
605
+ # If follow_mouse is True, require clicking directly on an atom in the group.
606
+ # If follow_mouse is False (default), allow right-clicking anywhere on display.
607
+ if not follow_mouse or (
403
608
  clicked_atom_idx is not None
404
609
  and clicked_atom_idx in move_group_dialog.group_atoms
405
610
  ):
406
611
  move_group_dialog.is_rotating_group_vtk = True
407
612
  move_group_dialog.rotation_start_pos = click_pos
408
613
  move_group_dialog.rotation_mouse_moved = False
409
- move_group_dialog.rotation_atom_idx = (
410
- clicked_atom_idx # Record grabbed atom
411
- )
614
+ if clicked_atom_idx is not None:
615
+ move_group_dialog.rotation_atom_idx = (
616
+ clicked_atom_idx # Record grabbed atom
617
+ )
618
+ elif hasattr(move_group_dialog, "rotation_atom_idx"):
619
+ delattr(move_group_dialog, "rotation_atom_idx")
412
620
 
413
621
  # Save initial positions and centroid
414
622
  move_group_dialog.initial_positions = {}
@@ -423,6 +631,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
423
631
  move_group_dialog.group_centroid = centroid
424
632
 
425
633
  mw.view_3d_manager.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
634
+ self._begin_drag_event(move_group_dialog.group_atoms)
426
635
  return # Disable camera rotation
427
636
 
428
637
  # Standard right-click
@@ -446,6 +655,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
446
655
  return
447
656
 
448
657
  if self._is_dragging_atom and not left_held:
658
+ self._abort_active_drag(self.is_dragging)
449
659
  self.reset_interactor_state()
450
660
 
451
661
  if not any_held:
@@ -462,16 +672,20 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
462
672
  getattr(move_group_dialog, "is_dragging_group_vtk", False)
463
673
  and not left_held
464
674
  ):
675
+ moved = getattr(move_group_dialog, "mouse_moved_vtk", False)
465
676
  move_group_dialog.is_dragging_group_vtk = False
466
677
  move_group_dialog.drag_start_pos_vtk = None
467
678
  move_group_dialog.mouse_moved_vtk = False
679
+ self._abort_active_drag(moved)
468
680
  if (
469
681
  getattr(move_group_dialog, "is_rotating_group_vtk", False)
470
682
  and not right_held
471
683
  ):
684
+ moved = getattr(move_group_dialog, "rotation_mouse_moved", False)
472
685
  move_group_dialog.is_rotating_group_vtk = False
473
686
  move_group_dialog.rotation_start_pos = None
474
687
  move_group_dialog.rotation_mouse_moved = False
688
+ self._abort_active_drag(moved)
475
689
  except (AttributeError, RuntimeError, TypeError):
476
690
  # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
477
691
  logging.debug("Suppressed non-critical error", exc_info=True)
@@ -482,25 +696,13 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
482
696
  """
483
697
  mw = self.main_window
484
698
 
485
- # Move Group / Selected Atoms drag handling
486
- move_group_dialog: Any = None
487
- try:
488
- for widget in QApplication.topLevelWidgets():
489
- if (
490
- type(widget).__name__
491
- in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
492
- and widget.isVisible()
493
- ):
494
- move_group_dialog = widget
495
- break
496
- except (AttributeError, RuntimeError, TypeError):
497
- logging.warning("Caught exception in " + __file__, exc_info=True)
699
+ move_group_dialog = self._find_move_dialog()
498
700
 
499
701
  self._heal_stuck_pointer_state(move_group_dialog)
500
702
  if move_group_dialog and getattr(
501
703
  move_group_dialog, "is_dragging_group_vtk", False
502
704
  ):
503
- # Dragging group - record offset
705
+ # Dragging group - record offset and update positions in real-time
504
706
  interactor = self.GetInteractor()
505
707
  current_pos = interactor.GetEventPosition()
506
708
 
@@ -513,6 +715,13 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
513
715
  if abs(dx) > 5 or abs(dy) > 5:
514
716
  move_group_dialog.mouse_moved_vtk = True
515
717
 
718
+ if (
719
+ move_group_dialog.mouse_moved_vtk
720
+ and hasattr(move_group_dialog, "initial_positions")
721
+ and self._should_drag_redraw()
722
+ ):
723
+ self._do_realtime_group_translate(mw, move_group_dialog, current_pos)
724
+
516
725
  return # Disable camera rotation
517
726
 
518
727
  # Group rotation handling
@@ -531,6 +740,14 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
531
740
  if abs(dx) > 5 or abs(dy) > 5:
532
741
  move_group_dialog.rotation_mouse_moved = True
533
742
 
743
+ if (
744
+ move_group_dialog.rotation_mouse_moved
745
+ and hasattr(move_group_dialog, "initial_positions")
746
+ and hasattr(move_group_dialog, "group_centroid")
747
+ and self._should_drag_redraw()
748
+ ):
749
+ self._do_realtime_group_rotate(mw, move_group_dialog, current_pos)
750
+
534
751
  return # Disable camera rotation
535
752
 
536
753
  interactor = self.GetInteractor()
@@ -547,6 +764,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
547
764
  if self._is_dragging_atom and mw.dragged_atom_info is not None:
548
765
  # Custom atom drag
549
766
  self.is_dragging = True
767
+ if self._should_drag_redraw():
768
+ self._do_realtime_atom_drag(mw)
550
769
  else:
551
770
  # Camera interaction. VTK's built-in Rotate divides mouse motion by
552
771
  # the live render size, so rotation slows as the canvas grows. Handle
@@ -580,25 +799,276 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
580
799
  else:
581
800
  mw.view_3d_manager.plotter.setCursor(Qt.CursorShape.ArrowCursor)
582
801
 
802
+ def _do_realtime_atom_drag(self, mw: Any) -> None:
803
+ """Update the dragged atom's position and redraw during mouse-move."""
804
+ try:
805
+ atom_id = mw.dragged_atom_info.get("id") if mw.dragged_atom_info else None
806
+ if atom_id is None:
807
+ return
808
+ if not (
809
+ mw.view_3d_manager.current_mol
810
+ and mw.view_3d_manager.current_mol.GetNumConformers() > 0
811
+ ):
812
+ return
813
+
814
+ interactor = self.GetInteractor()
815
+ renderer = mw.view_3d_manager.plotter.renderer
816
+ current_pos = interactor.GetEventPosition()
817
+ conf = mw.view_3d_manager.current_mol.GetConformer()
818
+ pos_3d = conf.GetAtomPosition(atom_id)
819
+
820
+ depth_z = self._world_to_display_depth(
821
+ renderer, pos_3d.x, pos_3d.y, pos_3d.z
822
+ )
823
+ if depth_z is None:
824
+ return
825
+ new_world = self._display_to_world(
826
+ renderer, current_pos[0], current_pos[1], depth_z
827
+ )
828
+ if new_world is None:
829
+ return
830
+
831
+ conf.SetAtomPosition(
832
+ atom_id,
833
+ Geometry.Point3D(
834
+ float(new_world[0]), float(new_world[1]), float(new_world[2])
835
+ ),
836
+ )
837
+ if isinstance(
838
+ mw.view_3d_manager.atom_positions_3d, (list, np.ndarray)
839
+ ) and atom_id < len(mw.view_3d_manager.atom_positions_3d):
840
+ mw.view_3d_manager.atom_positions_3d[atom_id] = list(new_world)
841
+
842
+ _rt_mol = mw.view_3d_manager.current_mol
843
+
844
+ def _deferred_rt_atom() -> None:
845
+ try:
846
+ mw.view_3d_manager.draw_molecule_3d(_rt_mol)
847
+ except (AttributeError, RuntimeError, ValueError, TypeError):
848
+ logging.debug("Suppressed non-critical error", exc_info=True)
849
+ finally:
850
+ self._finish_drag_redraw()
851
+
852
+ self._drag_redraw_pending = True
853
+ QTimer.singleShot(0, _deferred_rt_atom)
854
+ self._invoke_drag_handlers(
855
+ "move",
856
+ [atom_id],
857
+ {atom_id: new_world},
858
+ )
859
+ except (AttributeError, RuntimeError, TypeError, ValueError):
860
+ logging.debug("Suppressed non-critical error", exc_info=True)
861
+
862
+ def _do_realtime_group_translate(
863
+ self, mw: Any, move_group_dialog: Any, current_pos: Any
864
+ ) -> None:
865
+ """Translate group atoms and redraw during mouse-move."""
866
+ try:
867
+ renderer = mw.view_3d_manager.plotter.renderer
868
+ drag_atom_idx = move_group_dialog.drag_atom_idx_vtk
869
+ drag_initial_pos = move_group_dialog.initial_positions[drag_atom_idx]
870
+
871
+ depth_z = self._world_to_display_depth(
872
+ renderer, drag_initial_pos[0], drag_initial_pos[1], drag_initial_pos[2]
873
+ )
874
+ if depth_z is None:
875
+ return
876
+ new_world = self._display_to_world(
877
+ renderer, current_pos[0], current_pos[1], depth_z
878
+ )
879
+ if new_world is None:
880
+ return
881
+
882
+ conf = mw.view_3d_manager.current_mol.GetConformer()
883
+ translation = np.array(new_world) - drag_initial_pos
884
+ new_positions: Dict[int, Tuple[float, float, float]] = {}
885
+ for atom_idx in move_group_dialog.group_atoms:
886
+ initial_pos = move_group_dialog.initial_positions[atom_idx]
887
+ new_pos = initial_pos + translation
888
+ mw.view_3d_manager.atom_positions_3d[atom_idx] = new_pos
889
+ conf.SetAtomPosition(
890
+ atom_idx,
891
+ Geometry.Point3D(
892
+ float(new_pos[0]), float(new_pos[1]), float(new_pos[2])
893
+ ),
894
+ )
895
+ new_positions[atom_idx] = (
896
+ float(new_pos[0]),
897
+ float(new_pos[1]),
898
+ float(new_pos[2]),
899
+ )
900
+
901
+ _rt_mol = mw.view_3d_manager.current_mol
902
+ _rt_dlg = move_group_dialog
903
+
904
+ def _deferred_rt_grp() -> None:
905
+ try:
906
+ mw.view_3d_manager.draw_molecule_3d(_rt_mol)
907
+ _rt_dlg.show_atom_labels()
908
+ except (AttributeError, RuntimeError, ValueError, TypeError):
909
+ logging.debug("Suppressed non-critical error", exc_info=True)
910
+ finally:
911
+ self._finish_drag_redraw()
912
+
913
+ self._drag_redraw_pending = True
914
+ QTimer.singleShot(0, _deferred_rt_grp)
915
+ self._invoke_drag_handlers(
916
+ "move", list(move_group_dialog.group_atoms), new_positions
917
+ )
918
+ except (AttributeError, RuntimeError, TypeError, ValueError):
919
+ logging.debug("Suppressed non-critical error", exc_info=True)
920
+
921
+ def _compute_delta_rotation_matrix(
922
+ self, renderer: Any, start_pos: tuple, current_pos: tuple
923
+ ) -> Optional[np.ndarray]:
924
+ """Compute 3D rotation matrix from screen mouse displacement (dx, dy)."""
925
+ try:
926
+ dx = current_pos[0] - start_pos[0]
927
+ dy = current_pos[1] - start_pos[1]
928
+ if abs(dx) < 1e-3 and abs(dy) < 1e-3:
929
+ return None
930
+
931
+ camera = renderer.GetActiveCamera()
932
+ view_up = np.array(camera.GetViewUp())
933
+ v_dir = np.array(camera.GetDirectionOfProjection())
934
+ right = np.cross(v_dir, view_up)
935
+
936
+ right_norm = np.linalg.norm(right)
937
+ if right_norm > 1e-6:
938
+ right = right / right_norm
939
+
940
+ view_up_norm = np.linalg.norm(view_up)
941
+ if view_up_norm > 1e-6:
942
+ view_up = view_up / view_up_norm
943
+
944
+ speed = _GROUP_ROTATION_RADIANS_PER_PIXEL * self._rotation_sensitivity()
945
+ rot_y = _rodrigues_matrix(view_up, float(dx) * speed)
946
+ rot_x = _rodrigues_matrix(right, -float(dy) * speed)
947
+ return rot_x @ rot_y
948
+ except (AttributeError, RuntimeError, TypeError, ValueError):
949
+ logging.debug("Suppressed non-critical error", exc_info=True)
950
+ return None
951
+
952
+ def _get_group_rotation_matrix(
953
+ self, mw: Any, move_group_dialog: Any, current_pos: Any
954
+ ) -> Optional[np.ndarray]:
955
+ """Compute group rotation matrix using delta or follow-mouse method."""
956
+ try:
957
+ renderer = mw.view_3d_manager.plotter.renderer
958
+ centroid = move_group_dialog.group_centroid
959
+ start_pos = getattr(move_group_dialog, "rotation_start_pos", None)
960
+ if start_pos is None:
961
+ return None
962
+
963
+ follow_mouse = bool(
964
+ mw.get_settings().get("rotate_group_follow_mouse", False)
965
+ )
966
+
967
+ if follow_mouse:
968
+ if not hasattr(move_group_dialog, "rotation_atom_idx"):
969
+ move_group_dialog.rotation_atom_idx = next(
970
+ iter(move_group_dialog.group_atoms)
971
+ )
972
+ grabbed_atom_idx = move_group_dialog.rotation_atom_idx
973
+ grabbed_initial_pos = move_group_dialog.initial_positions[
974
+ grabbed_atom_idx
975
+ ]
976
+
977
+ depth_z = self._world_to_display_depth(
978
+ renderer,
979
+ grabbed_initial_pos[0],
980
+ grabbed_initial_pos[1],
981
+ grabbed_initial_pos[2],
982
+ )
983
+ if depth_z is None:
984
+ return None
985
+ target_world = self._display_to_world(
986
+ renderer, current_pos[0], current_pos[1], depth_z
987
+ )
988
+ if target_world is None:
989
+ return None
990
+ target_pos = np.array(target_world)
991
+
992
+ v1 = grabbed_initial_pos - centroid
993
+ v2 = target_pos - centroid
994
+ v1_norm = np.linalg.norm(v1)
995
+ v2_norm = np.linalg.norm(v2)
996
+ if v1_norm < 1e-6 or v2_norm < 1e-6:
997
+ return None
998
+
999
+ v1_n = v1 / v1_norm
1000
+ v2_n = v2 / v2_norm
1001
+ rotation_axis = np.cross(v1_n, v2_n)
1002
+ axis_norm = np.linalg.norm(rotation_axis)
1003
+ if axis_norm < 1e-6:
1004
+ return None
1005
+ rotation_axis /= axis_norm
1006
+
1007
+ cos_angle = float(np.clip(np.dot(v1_n, v2_n), -1.0, 1.0))
1008
+ return _rodrigues_matrix(rotation_axis, float(np.arccos(cos_angle)))
1009
+ return self._compute_delta_rotation_matrix(renderer, start_pos, current_pos)
1010
+ except (AttributeError, RuntimeError, TypeError, ValueError):
1011
+ logging.debug("Suppressed non-critical error", exc_info=True)
1012
+ return None
1013
+
1014
+ def _do_realtime_group_rotate(
1015
+ self, mw: Any, move_group_dialog: Any, current_pos: Any
1016
+ ) -> None:
1017
+ """Rotate group atoms around their centroid and redraw during mouse-move."""
1018
+ try:
1019
+ rot_matrix = self._get_group_rotation_matrix(
1020
+ mw, move_group_dialog, current_pos
1021
+ )
1022
+ if rot_matrix is None:
1023
+ return
1024
+
1025
+ centroid = move_group_dialog.group_centroid
1026
+
1027
+ conf = mw.view_3d_manager.current_mol.GetConformer()
1028
+ new_positions: Dict[int, Tuple[float, float, float]] = {}
1029
+ for atom_idx in move_group_dialog.group_atoms:
1030
+ initial_pos = move_group_dialog.initial_positions[atom_idx]
1031
+ new_pos = rot_matrix @ (initial_pos - centroid) + centroid
1032
+ mw.view_3d_manager.atom_positions_3d[atom_idx] = new_pos
1033
+ conf.SetAtomPosition(
1034
+ atom_idx,
1035
+ Geometry.Point3D(
1036
+ float(new_pos[0]), float(new_pos[1]), float(new_pos[2])
1037
+ ),
1038
+ )
1039
+ new_positions[atom_idx] = (
1040
+ float(new_pos[0]),
1041
+ float(new_pos[1]),
1042
+ float(new_pos[2]),
1043
+ )
1044
+
1045
+ _rt_mol = mw.view_3d_manager.current_mol
1046
+ _rt_dlg = move_group_dialog
1047
+
1048
+ def _deferred_rt_rot() -> None:
1049
+ try:
1050
+ mw.view_3d_manager.draw_molecule_3d(_rt_mol)
1051
+ _rt_dlg.show_atom_labels()
1052
+ except (AttributeError, RuntimeError, ValueError, TypeError):
1053
+ logging.debug("Suppressed non-critical error", exc_info=True)
1054
+ finally:
1055
+ self._finish_drag_redraw()
1056
+
1057
+ self._drag_redraw_pending = True
1058
+ QTimer.singleShot(0, _deferred_rt_rot)
1059
+ self._invoke_drag_handlers(
1060
+ "move", list(move_group_dialog.group_atoms), new_positions
1061
+ )
1062
+ except (AttributeError, RuntimeError, TypeError, ValueError):
1063
+ logging.debug("Suppressed non-critical error", exc_info=True)
1064
+
583
1065
  def on_left_button_up(self, obj: Any, event: Any) -> None:
584
1066
  """
585
1067
  Handle click release and reset state.
586
1068
  """
587
1069
  mw = self.main_window
588
1070
 
589
- # Finalize Move Group / Selected Atoms drag
590
- move_group_dialog: Any = None
591
- try:
592
- for widget in QApplication.topLevelWidgets():
593
- if (
594
- type(widget).__name__
595
- in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
596
- and widget.isVisible()
597
- ):
598
- move_group_dialog = widget
599
- break
600
- except (AttributeError, RuntimeError, TypeError):
601
- logging.warning("Caught exception in " + __file__, exc_info=True)
1071
+ move_group_dialog = self._find_move_dialog()
602
1072
  # Prevent multi-click issues
603
1073
  if move_group_dialog:
604
1074
  if getattr(
@@ -699,6 +1169,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
699
1169
  move_group_dialog.on_atom_picked(clicked_atom)
700
1170
  except (AttributeError, RuntimeError, TypeError, ValueError):
701
1171
  logging.warning("Error in toggle", exc_info=True)
1172
+ self._end_drag_event()
702
1173
 
703
1174
  # Background click: deselect Move Group
704
1175
  if move_group_dialog and not getattr(
@@ -844,6 +1315,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
844
1315
  mw.edit_actions_manager.push_undo_state()
845
1316
 
846
1317
  QTimer.singleShot(0, _deferred_atom_redraw)
1318
+
1319
+ self._end_drag_event()
847
1320
  mw.dragged_atom_info = None
848
1321
  self._stop_vtk_left_button_state()
849
1322
 
@@ -911,19 +1384,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
911
1384
  """
912
1385
  mw = self.main_window
913
1386
 
914
- # Finalize Move Group / Selected Atoms rotation
915
- move_group_dialog: Any = None
916
- try:
917
- for widget in QApplication.topLevelWidgets():
918
- if (
919
- type(widget).__name__
920
- in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
921
- and widget.isVisible()
922
- ):
923
- move_group_dialog = widget
924
- break
925
- except (AttributeError, RuntimeError, TypeError):
926
- logging.warning("Caught exception in " + __file__, exc_info=True)
1387
+ move_group_dialog = self._find_move_dialog()
927
1388
  if move_group_dialog and getattr(
928
1389
  move_group_dialog, "is_rotating_group_vtk", False
929
1390
  ):
@@ -932,113 +1393,39 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
932
1393
  # Apply rotation on release if moved
933
1394
  try:
934
1395
  interactor = self.GetInteractor()
935
- renderer = mw.view_3d_manager.plotter.renderer
936
1396
  current_pos = interactor.GetEventPosition()
937
1397
  conf = mw.view_3d_manager.current_mol.GetConformer()
938
1398
  centroid = move_group_dialog.group_centroid
939
1399
 
940
- # Save initial grabbed atom index
941
- if not hasattr(move_group_dialog, "rotation_atom_idx"):
942
- move_group_dialog.rotation_atom_idx = next(
943
- iter(move_group_dialog.group_atoms)
944
- )
945
-
946
- grabbed_atom_idx = move_group_dialog.rotation_atom_idx
947
- grabbed_initial_pos = move_group_dialog.initial_positions[
948
- grabbed_atom_idx
949
- ]
950
-
951
- # Get start screen coordinates
952
- renderer.SetWorldPoint(
953
- grabbed_initial_pos[0],
954
- grabbed_initial_pos[1],
955
- grabbed_initial_pos[2],
956
- 1.0,
957
- )
958
- renderer.WorldToDisplay()
959
- start_display = renderer.GetDisplayPoint()
960
-
961
- # Convert current mouse pos to world (same depth)
962
- renderer.SetDisplayPoint(
963
- current_pos[0], current_pos[1], start_display[2]
964
- )
965
- renderer.DisplayToWorld()
966
- target_world = renderer.GetWorldPoint()
967
- target_pos = np.array(
968
- [target_world[0], target_world[1], target_world[2]]
1400
+ rot_matrix = self._get_group_rotation_matrix(
1401
+ mw, move_group_dialog, current_pos
969
1402
  )
1403
+ if rot_matrix is not None:
1404
+ for atom_idx in move_group_dialog.group_atoms:
1405
+ initial_pos = move_group_dialog.initial_positions[atom_idx]
1406
+ relative_pos = initial_pos - centroid
1407
+ rotated_pos = rot_matrix @ relative_pos
1408
+ new_pos = rotated_pos + centroid
970
1409
 
971
- # Vectors relative to centroid
972
- v1 = grabbed_initial_pos - centroid
973
- v2 = target_pos - centroid
974
-
975
- # Normalize vectors
976
- v1_norm = np.linalg.norm(v1)
977
- v2_norm = np.linalg.norm(v2)
978
-
979
- if v1_norm > 1e-6 and v2_norm > 1e-6:
980
- v1_normalized = v1 / v1_norm
981
- v2_normalized = v2 / v2_norm
982
-
983
- # Rotation axis
984
- rotation_axis = np.cross(v1_normalized, v2_normalized)
985
- axis_norm = np.linalg.norm(rotation_axis)
986
-
987
- if axis_norm > 1e-6:
988
- rotation_axis = rotation_axis / axis_norm
989
-
990
- # Rotation angle
991
- cos_angle = np.clip(
992
- np.dot(v1_normalized, v2_normalized), -1.0, 1.0
993
- )
994
- angle = np.arccos(cos_angle)
995
-
996
- # Create rotation matrix
997
- K = np.array(
998
- [
999
- [0, -rotation_axis[2], rotation_axis[1]],
1000
- [rotation_axis[2], 0, -rotation_axis[0]],
1001
- [-rotation_axis[1], rotation_axis[0], 0],
1002
- ]
1003
- )
1004
-
1005
- rot_matrix = (
1006
- np.eye(3)
1007
- + np.sin(angle) * K
1008
- + (1 - np.cos(angle)) * (K @ K)
1410
+ conf.SetAtomPosition(
1411
+ atom_idx,
1412
+ Geometry.Point3D(
1413
+ float(new_pos[0]),
1414
+ float(new_pos[1]),
1415
+ float(new_pos[2]),
1416
+ ),
1009
1417
  )
1418
+ mw.view_3d_manager.atom_positions_3d[atom_idx] = new_pos
1010
1419
 
1011
- # Rotate group around centroid
1012
- for atom_idx in move_group_dialog.group_atoms:
1013
- initial_pos = move_group_dialog.initial_positions[
1014
- atom_idx
1015
- ]
1016
- # Relative position from centroid
1017
- relative_pos = initial_pos - centroid
1018
- # Apply rotation
1019
- rotated_pos = rot_matrix @ relative_pos
1020
- # Restore absolute position
1021
- new_pos = rotated_pos + centroid
1022
-
1023
- conf.SetAtomPosition(
1024
- atom_idx,
1025
- Geometry.Point3D(
1026
- float(new_pos[0]),
1027
- float(new_pos[1]),
1028
- float(new_pos[2]),
1029
- ),
1030
- )
1031
- mw.view_3d_manager.atom_positions_3d[atom_idx] = new_pos
1032
-
1033
- # Update 3D display
1034
- mw.view_3d_manager.draw_molecule_3d(
1035
- mw.view_3d_manager.current_mol
1036
- )
1037
- mw.view_3d_manager.update_chiral_labels()
1038
- move_group_dialog.show_atom_labels()
1039
- mw.edit_actions_manager.push_undo_state()
1420
+ mw.view_3d_manager.draw_molecule_3d(
1421
+ mw.view_3d_manager.current_mol
1422
+ )
1423
+ mw.view_3d_manager.update_chiral_labels()
1424
+ move_group_dialog.show_atom_labels()
1425
+ mw.edit_actions_manager.push_undo_state()
1040
1426
  except (AttributeError, RuntimeError, TypeError, ValueError):
1041
1427
  logging.warning("Error finalizing group rotation", exc_info=True)
1428
+ self._end_drag_event()
1042
1429
 
1043
1430
  # Reset state
1044
1431
  move_group_dialog.is_rotating_group_vtk = False
@@ -53,11 +53,6 @@ class MoveGroupDialog(BasePickingDialog):
53
53
  self.selected_atoms: set[int] = set()
54
54
  self.group_atoms: set[int] = set() # All atoms connected to selected atoms
55
55
 
56
- # Add preselected atoms
57
- if preselected_atoms:
58
- # For MoveGroup, we pick the first atom and select its connected group
59
- self.on_atom_picked(preselected_atoms[0])
60
-
61
56
  self.clicked_atom_for_toggle: Optional[int] = None
62
57
  # State for group movement (used by CustomInteractorStyle)
63
58
  self.initial_positions: dict = {}
@@ -82,6 +77,11 @@ class MoveGroupDialog(BasePickingDialog):
82
77
 
83
78
  self.init_ui()
84
79
 
80
+ # After init_ui: picking an atom highlights it and updates the labels,
81
+ # which needs both the actor state and the widgets to exist.
82
+ if preselected_atoms:
83
+ self.on_atom_picked(preselected_atoms[0])
84
+
85
85
  def init_ui(self) -> None:
86
86
  """Build the move-group dialog with atom picker, translate/rotate inputs, and controls."""
87
87
  self.setWindowTitle("Move Group")
@@ -37,6 +37,8 @@ class Settings3DSceneTab(SettingsTabBase):
37
37
  self.bg_button: Any = None
38
38
  self.light_checkbox: Any = None
39
39
  self.projection_combo: Any = None
40
+ self.realtime_drag_checkbox: Any = None
41
+ self.rotate_group_follow_mouse_checkbox: Any = None
40
42
  self.current_bg_color = default_settings["background_color"]
41
43
  self._setup_ui()
42
44
 
@@ -88,6 +90,24 @@ class Settings3DSceneTab(SettingsTabBase):
88
90
  self._wrap_layout(self.rotation_sens_slider, self.rotation_sens_label),
89
91
  )
90
92
 
93
+ self.realtime_drag_checkbox = QCheckBox()
94
+ self.realtime_drag_checkbox.setToolTip(
95
+ "Update the structure continuously while dragging an atom or group.\n"
96
+ "When off, the move is applied only on mouse release.\n"
97
+ "Structures larger than 300 atoms always use release-only updates."
98
+ )
99
+ form_layout.addRow("Real-time 3D Drag:", self.realtime_drag_checkbox)
100
+
101
+ self.rotate_group_follow_mouse_checkbox = QCheckBox()
102
+ self.rotate_group_follow_mouse_checkbox.setToolTip(
103
+ "On: right-drag rotation must start on an atom of the group,\n"
104
+ "and that atom follows the cursor.\n"
105
+ "Off: right-dragging anywhere on the 3D view rotates the group."
106
+ )
107
+ form_layout.addRow(
108
+ "Rotate Groups: Follow Mouse:", self.rotate_group_follow_mouse_checkbox
109
+ )
110
+
91
111
  def _select_color(self) -> None:
92
112
  color = QColorDialog.getColor(QColor(self.current_bg_color), self)
93
113
  if color.isValid():
@@ -123,6 +143,12 @@ class Settings3DSceneTab(SettingsTabBase):
123
143
 
124
144
  sens_val = settings_dict.get("mouse_rotation_sensitivity", 1.0)
125
145
  self.rotation_sens_slider.setValue(int(sens_val * 100))
146
+ self.realtime_drag_checkbox.setChecked(
147
+ settings_dict.get("realtime_3d_drag", True)
148
+ )
149
+ self.rotate_group_follow_mouse_checkbox.setChecked(
150
+ settings_dict.get("rotate_group_follow_mouse", False)
151
+ )
126
152
 
127
153
  def get_settings(self) -> dict[str, Any]:
128
154
  return {
@@ -134,6 +160,8 @@ class Settings3DSceneTab(SettingsTabBase):
134
160
  "specular_power": self.spec_power_slider.value(),
135
161
  "projection_mode": self.projection_combo.currentText(),
136
162
  "mouse_rotation_sensitivity": self.rotation_sens_slider.value() / 100.0,
163
+ "realtime_3d_drag": self.realtime_drag_checkbox.isChecked(),
164
+ "rotate_group_follow_mouse": self.rotate_group_follow_mouse_checkbox.isChecked(),
137
165
  }
138
166
 
139
167
 
@@ -17,6 +17,9 @@ import os
17
17
  from PyQt6.QtGui import QColor, QFont
18
18
  from rdkit import Chem
19
19
 
20
+ # Dialog classes that drive group drag / rotate gestures in the 3D view.
21
+ MOVE_DIALOG_TYPES = ("MoveGroupDialog", "MoveSelectedAtomsDialog")
22
+
20
23
 
21
24
  def _get_version() -> str:
22
25
  try:
@@ -21,6 +21,8 @@ DEFAULT_SETTINGS = {
21
21
  "show_3d_axes": True,
22
22
  # Camera rotation speed multiplier for the 3D scene (window-size independent).
23
23
  "mouse_rotation_sensitivity": 1.0,
24
+ "realtime_3d_drag": True,
25
+ "rotate_group_follow_mouse": False,
24
26
  # --- 3D Model Parameters (Ball and Stick) ---
25
27
  "ball_stick_atom_scale": 1.0,
26
28
  "ball_stick_bond_radius": 0.1,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.4.1
3
+ Version: 4.5.0
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -9,8 +9,8 @@ moleditpy_linux/core/__init__.py,sha256=BEOaCz93HzgTe0FhBzSj6Sfk8MAWxcY9vCDGmOri
9
9
  moleditpy_linux/core/mol_geometry.py,sha256=PzdJGxWs6vXwVyILBG6-6qhiVYuB33YTr8lcHRJVLNE,23933
10
10
  moleditpy_linux/core/molecular_data.py,sha256=CboEOZMjtbyRSQ7-f6kj0jngUM0Tw-7L6lU9eDtdycQ,18606
11
11
  moleditpy_linux/plugins/__init__.py,sha256=BEOaCz93HzgTe0FhBzSj6Sfk8MAWxcY9vCDGmOriJx4,267
12
- moleditpy_linux/plugins/plugin_interface.py,sha256=GGRQsUGSb36RgujErcQWX-dr5pm55dnQ8K75NfviAe0,22054
13
- moleditpy_linux/plugins/plugin_manager.py,sha256=h6QrHl57ChRKX3hpIE8-2e3qWVhEFV_-nZDCD9v_fh4,32289
12
+ moleditpy_linux/plugins/plugin_interface.py,sha256=shBuVwQu3cQxhhM5M82EkQWMt19RcOxyXZ6CvkjXHtg,22981
13
+ moleditpy_linux/plugins/plugin_manager.py,sha256=NzDKllVYLNaFDyV_rEDxtMyKfMWl4LFHfEKnEcOUtcA,34390
14
14
  moleditpy_linux/plugins/plugin_manager_window.py,sha256=M3JJhMWLYQ2jRZcLiPj2Ho5xXqwRwT_sb5fJaaqjr7U,13375
15
15
  moleditpy_linux/ui/__init__.py,sha256=_BslFbhSM4Ba4GDzH1XpvlqlGgvi4OFkcuU3pPeIz3s,751
16
16
  moleditpy_linux/ui/about_dialog.py,sha256=saCFGcJXoWTIwWkH3iwIPy9dRy-zlgVUKfN6x2E1S2Y,4552
@@ -19,7 +19,7 @@ moleditpy_linux/ui/alignment_dialog.py,sha256=21-RCNXHwdeD9ARQ1GZxdOS0wTeYwzQmFm
19
19
  moleditpy_linux/ui/analysis_window.py,sha256=tAsm8GLPjXGuMupM3uHbcaMVuJ3X_MZNB8yBcjECcyk,9800
20
20
  moleditpy_linux/ui/angle_dialog.py,sha256=Wgyz3VYTVJZYILxCuj_0NGtKBnq47sSg0IwlXqFRNK8,18842
21
21
  moleditpy_linux/ui/app_state.py,sha256=4Q1Lygj_1DbG83prV1I3eLbuNWtF3_19SGpVHhCxOxE,32442
22
- moleditpy_linux/ui/atom_item.py,sha256=fixMGu1rIyDWen5W3mt0lVS_mKZ3rAB2GmLgoB6JttY,22401
22
+ moleditpy_linux/ui/atom_item.py,sha256=vWBINnyhN8w8GzEflibfQ268wVQJerqE0rHA-HEUGlQ,22551
23
23
  moleditpy_linux/ui/atom_picking.py,sha256=nbdFdcaEOgLQtqZXYoKQUvFyVHagPKlms96chw00m2Y,10566
24
24
  moleditpy_linux/ui/base_picking_dialog.py,sha256=F4XAx7Rrk6pZG7KECrJxNghtz4sB1JIaOd2mDsz2BfU,5975
25
25
  moleditpy_linux/ui/bond_item.py,sha256=rVjORuGLp_Cxhmw7ARTE83QM614VSIDA5LVcIYpWWc0,25995
@@ -28,7 +28,7 @@ moleditpy_linux/ui/calculation_worker.py,sha256=bwrzCW5935Twe5EY7Mtd35YQaWJILvBP
28
28
  moleditpy_linux/ui/color_settings_dialog.py,sha256=RFp8h0R1yC8YwqOxc972Da9xO95Gu-0qywAAEeXMj4E,14224
29
29
  moleditpy_linux/ui/compute_logic.py,sha256=OBEJvw4atAaEXIv4eueoilIjTF1sxvJQGQB4ABOthYA,31462
30
30
  moleditpy_linux/ui/constrained_optimization_dialog.py,sha256=rc3YK6DOJdIbhriwsM2jOAW9j_-3PvFz1XtFHpGw4E4,36346
31
- moleditpy_linux/ui/custom_interactor_style.py,sha256=0bN4UiSH6YVSx4mYSnY0tW8KyDnBAtxrGKd_P5NajB8,49226
31
+ moleditpy_linux/ui/custom_interactor_style.py,sha256=ut4p-S4iUr8lTVp2T1m_-d7l9pSUCoOdzk08qssECn8,65499
32
32
  moleditpy_linux/ui/custom_qt_interactor.py,sha256=76L0Jpbg09VXzHOzJs9kG260Vs9qcwbt8Chn0iOopDg,2820
33
33
  moleditpy_linux/ui/dialog_3d_picking_mixin.py,sha256=nsZQ27PYZrO9s_w5EXxxluVfOOmNKcv0vvm89IWN7mU,10604
34
34
  moleditpy_linux/ui/dialog_logic.py,sha256=KCz3ZPJap4K_tiNDjCzGqQHYxdEsvcUG6Lws2tDhvx4,19617
@@ -43,7 +43,7 @@ moleditpy_linux/ui/main_window_init.py,sha256=94_Sf_E5ZTtkyQvSIURWXbuXxabg46L0Ax
43
43
  moleditpy_linux/ui/mirror_dialog.py,sha256=Tsc2O9Ktjz0zzJWB_YkVgkqgDhytQ7ksN_PwpcY9vJI,5169
44
44
  moleditpy_linux/ui/molecular_scene_handler.py,sha256=p_OhYC965qden-aFQVifNTcMMi_Ikx8TzCwH9XgWR3A,70110
45
45
  moleditpy_linux/ui/molecule_scene.py,sha256=ZUZBaCyWaNEAITHI4JmJ__gQcI9kgttj8qBP6dgF6LU,44090
46
- moleditpy_linux/ui/move_group_dialog.py,sha256=3jfj8zmKrYs0N9537o3g0V3f0UOnr0pj8uNT0qvUlPM,27063
46
+ moleditpy_linux/ui/move_group_dialog.py,sha256=elgrjcSh3uP6sPc5WLgDI9EvtpiGPcutaKkmNJg-taM,27096
47
47
  moleditpy_linux/ui/move_selected_atoms_dialog.py,sha256=7_Krz69LDx71_pBr9_bP55UhT97SJZU-W6OEN59waDg,29889
48
48
  moleditpy_linux/ui/periodic_table_dialog.py,sha256=yadE1bbJaObAiIfpKwANqiDOBI8R3yOHvDTZYrw1J4Q,6134
49
49
  moleditpy_linux/ui/planarize_dialog.py,sha256=PvEXAx27iO3d9OWrr0kL4WTeNI2eMIx-FRZaFWW-cao,7936
@@ -59,18 +59,18 @@ moleditpy_linux/ui/view_3d_logic.py,sha256=fuXQKAQFH8e_Xm6e8mG2y4EN2EnBqFxJRqslo
59
59
  moleditpy_linux/ui/zoomable_view.py,sha256=Kpr9SaIYAKH-Fexm5-gi3cswsiT5V7g7yTk7acutZjA,6239
60
60
  moleditpy_linux/ui/settings_tabs/__init__.py,sha256=BEOaCz93HzgTe0FhBzSj6Sfk8MAWxcY9vCDGmOriJx4,267
61
61
  moleditpy_linux/ui/settings_tabs/settings_2d_tab.py,sha256=GbiRYuf1Jem7TsdXL9Kcdp_SNx4vuBG6f5_PanJgBU0,15294
62
- moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py,sha256=2KAx0-AXefaWEXoxWFs2ELMBU5NCkmipOfPa-DKFc78,12480
62
+ moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py,sha256=GqHS8Ni17fPyPAS1ElnLr4Q5Fr2zDjsU2qA8VHe-fP0,13910
63
63
  moleditpy_linux/ui/settings_tabs/settings_other_tab.py,sha256=gCg9ambXvN9WCYWaH5qDgbQzvom02Vscrbp0Pt4NrFM,7295
64
64
  moleditpy_linux/ui/settings_tabs/settings_tab_base.py,sha256=c7nX6I0-8mgemI9q9cVSwDWtJSOU36laEfNiq7hOprE,3619
65
65
  moleditpy_linux/utils/__init__.py,sha256=BEOaCz93HzgTe0FhBzSj6Sfk8MAWxcY9vCDGmOriJx4,267
66
- moleditpy_linux/utils/constants.py,sha256=JjNnXLHCrquuCgYUfehZ44I9-CJzQeGIQwARIyHUC0o,7872
67
- moleditpy_linux/utils/default_settings.py,sha256=K3wzi3cj4WAoyO_2xD8_XCNkzZpcrZYqSVIoLmP_R-4,3018
66
+ moleditpy_linux/utils/constants.py,sha256=73UzGo1aJBp_jDulb_1F2TUz6YTcI_4_wWuOV3qnexM,8016
67
+ moleditpy_linux/utils/default_settings.py,sha256=c3FfGWpaCvUYLJ57fdMhWyqGEeaYEbClPoVKZ9LPxn8,3090
68
68
  moleditpy_linux/utils/sip_isdeleted_safe.py,sha256=aXoW_PDHAsu0SkqcvkUZAsWDhl9Ka8-VnzgNE5ub41Y,1215
69
69
  moleditpy_linux/utils/suppress_log.py,sha256=EFkN79q6QfYXAuii8g1fBuvFJrnVsio3aFTKMvAmXW4,928
70
70
  moleditpy_linux/utils/system_utils.py,sha256=j1RAiiKXg3N-rjpTh7yxKVAYED_kUJy4aVrY-9Hh0Mc,2674
71
- moleditpy_linux-4.4.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
72
- moleditpy_linux-4.4.1.dist-info/METADATA,sha256=dfwO08W-auR4Wb7PGGQ1s7yaNNA8WI_LqPN-wIoqaoc,65353
73
- moleditpy_linux-4.4.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
74
- moleditpy_linux-4.4.1.dist-info/entry_points.txt,sha256=-OzipSi__yVwlimNtu3eiRP5t5UMg55Cs0udyhXYiyw,60
75
- moleditpy_linux-4.4.1.dist-info/top_level.txt,sha256=qyqe-hDYL6CXyin9E5Me5rVl3PG84VqiOjf9bQvfJLs,16
76
- moleditpy_linux-4.4.1.dist-info/RECORD,,
71
+ moleditpy_linux-4.5.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
72
+ moleditpy_linux-4.5.0.dist-info/METADATA,sha256=K2BAcuLvCky-mQGYbzrNRCgpOhn5Zh_CUjmyc_Emmas,65353
73
+ moleditpy_linux-4.5.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
74
+ moleditpy_linux-4.5.0.dist-info/entry_points.txt,sha256=-OzipSi__yVwlimNtu3eiRP5t5UMg55Cs0udyhXYiyw,60
75
+ moleditpy_linux-4.5.0.dist-info/top_level.txt,sha256=qyqe-hDYL6CXyin9E5Me5rVl3PG84VqiOjf9bQvfJLs,16
76
+ moleditpy_linux-4.5.0.dist-info/RECORD,,