MoleditPy-linux 3.5.2__py3-none-any.whl → 3.6.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.
- moleditpy_linux/main.py +10 -0
- moleditpy_linux/ui/custom_interactor_style.py +55 -14
- moleditpy_linux/ui/dialog_logic.py +61 -13
- moleditpy_linux/ui/main_window_init.py +8 -0
- moleditpy_linux/ui/molecular_scene_handler.py +108 -24
- moleditpy_linux/ui/molecule_scene.py +4 -4
- moleditpy_linux/ui/move_selected_atoms_dialog.py +640 -0
- moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +3 -1
- moleditpy_linux/ui/settings_tabs/settings_other_tab.py +18 -4
- moleditpy_linux/ui/settings_tabs/settings_tab_base.py +53 -15
- moleditpy_linux/ui/ui_manager.py +1 -0
- moleditpy_linux/utils/default_settings.py +1 -1
- {moleditpy_linux-3.5.2.dist-info → moleditpy_linux-3.6.0.dist-info}/METADATA +3 -3
- {moleditpy_linux-3.5.2.dist-info → moleditpy_linux-3.6.0.dist-info}/RECORD +18 -17
- {moleditpy_linux-3.5.2.dist-info → moleditpy_linux-3.6.0.dist-info}/WHEEL +0 -0
- {moleditpy_linux-3.5.2.dist-info → moleditpy_linux-3.6.0.dist-info}/entry_points.txt +0 -0
- {moleditpy_linux-3.5.2.dist-info → moleditpy_linux-3.6.0.dist-info}/licenses/LICENSE +0 -0
- {moleditpy_linux-3.5.2.dist-info → moleditpy_linux-3.6.0.dist-info}/top_level.txt +0 -0
moleditpy_linux/main.py
CHANGED
|
@@ -148,4 +148,14 @@ def main() -> None:
|
|
|
148
148
|
app = QApplication([sys.argv[0]] + remaining)
|
|
149
149
|
window = MainWindow(initial_file=args.file, safe_mode=args.safe)
|
|
150
150
|
window.show()
|
|
151
|
+
|
|
152
|
+
# Force Windows to refresh taskbar/titlebar icon after event loop starts
|
|
153
|
+
if sys.platform == "win32":
|
|
154
|
+
try:
|
|
155
|
+
from PyQt6.QtCore import QTimer
|
|
156
|
+
|
|
157
|
+
QTimer.singleShot(100, lambda: window.setWindowIcon(window.windowIcon()))
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
151
161
|
sys.exit(app.exec())
|
|
@@ -22,10 +22,8 @@ from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera #
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
try:
|
|
25
|
-
from .move_group_dialog import MoveGroupDialog
|
|
26
25
|
from .atom_picking import pick_atom_index_from_screen
|
|
27
26
|
except ImportError:
|
|
28
|
-
from moleditpy_linux.ui.move_group_dialog import MoveGroupDialog
|
|
29
27
|
from moleditpy_linux.ui.atom_picking import pick_atom_index_from_screen
|
|
30
28
|
|
|
31
29
|
from rdkit import Geometry
|
|
@@ -107,10 +105,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
107
105
|
self._mouse_press_pos = None
|
|
108
106
|
|
|
109
107
|
# Check Move Group dialog
|
|
108
|
+
# Check Move Group or Move Selected Atoms dialog
|
|
110
109
|
move_group_dialog = None
|
|
111
110
|
for widget in QApplication.topLevelWidgets():
|
|
112
111
|
try:
|
|
113
|
-
if
|
|
112
|
+
if (
|
|
113
|
+
type(widget).__name__
|
|
114
|
+
in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
|
|
115
|
+
and widget.isVisible()
|
|
116
|
+
):
|
|
114
117
|
move_group_dialog = widget
|
|
115
118
|
break
|
|
116
119
|
except (AttributeError, RuntimeError, TypeError):
|
|
@@ -147,6 +150,20 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
147
150
|
self._suppress_next_left_button_up = True
|
|
148
151
|
return # Disable camera rotation
|
|
149
152
|
else:
|
|
153
|
+
if type(move_group_dialog).__name__ == "MoveSelectedAtomsDialog":
|
|
154
|
+
# For MoveSelectedAtomsDialog, we toggle ONLY the clicked atom, no BFS!
|
|
155
|
+
def _deferred_toggle(
|
|
156
|
+
idx=clicked_atom_idx, dlg=move_group_dialog
|
|
157
|
+
):
|
|
158
|
+
try:
|
|
159
|
+
dlg.on_atom_picked(idx)
|
|
160
|
+
except (AttributeError, RuntimeError):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
QTimer.singleShot(0, _deferred_toggle)
|
|
164
|
+
self._suppress_next_left_button_up = True
|
|
165
|
+
return
|
|
166
|
+
|
|
150
167
|
# Clicked outside group - Search connected component
|
|
151
168
|
visited = set()
|
|
152
169
|
queue = [clicked_atom_idx]
|
|
@@ -172,8 +189,14 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
172
189
|
|
|
173
190
|
# Multi-selection with Ctrl
|
|
174
191
|
is_ctrl_pressed = bool(
|
|
175
|
-
|
|
176
|
-
|
|
192
|
+
(
|
|
193
|
+
QApplication.keyboardModifiers()
|
|
194
|
+
& Qt.KeyboardModifier.ControlModifier
|
|
195
|
+
)
|
|
196
|
+
or (
|
|
197
|
+
self.GetInteractor()
|
|
198
|
+
and self.GetInteractor().GetControlKey()
|
|
199
|
+
)
|
|
177
200
|
)
|
|
178
201
|
|
|
179
202
|
if is_ctrl_pressed:
|
|
@@ -208,8 +231,10 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
208
231
|
super(CustomInteractorStyle, self).OnLeftButtonDown()
|
|
209
232
|
return
|
|
210
233
|
|
|
234
|
+
interactor = self.GetInteractor()
|
|
211
235
|
is_temp_mode = bool(
|
|
212
|
-
QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier
|
|
236
|
+
(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
|
|
237
|
+
or (interactor and interactor.GetAltKey())
|
|
213
238
|
)
|
|
214
239
|
is_edit_active = mw.edit_3d_manager.is_3d_edit_mode or is_temp_mode
|
|
215
240
|
|
|
@@ -293,11 +318,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
293
318
|
"""
|
|
294
319
|
mw = self.main_window
|
|
295
320
|
|
|
296
|
-
# Check if Move Group dialog is open
|
|
321
|
+
# Check if Move Group dialog or Move Selected Atoms dialog is open
|
|
297
322
|
move_group_dialog = None
|
|
298
323
|
try:
|
|
299
324
|
for widget in QApplication.topLevelWidgets():
|
|
300
|
-
if
|
|
325
|
+
if (
|
|
326
|
+
type(widget).__name__
|
|
327
|
+
in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
|
|
328
|
+
and widget.isVisible()
|
|
329
|
+
):
|
|
301
330
|
move_group_dialog = widget
|
|
302
331
|
break
|
|
303
332
|
except (AttributeError, RuntimeError, TypeError) as e:
|
|
@@ -350,11 +379,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
350
379
|
"""
|
|
351
380
|
mw = self.main_window
|
|
352
381
|
|
|
353
|
-
# Move Group drag handling
|
|
382
|
+
# Move Group / Selected Atoms drag handling
|
|
354
383
|
move_group_dialog = None
|
|
355
384
|
try:
|
|
356
385
|
for widget in QApplication.topLevelWidgets():
|
|
357
|
-
if
|
|
386
|
+
if (
|
|
387
|
+
type(widget).__name__
|
|
388
|
+
in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
|
|
389
|
+
and widget.isVisible()
|
|
390
|
+
):
|
|
358
391
|
move_group_dialog = widget
|
|
359
392
|
break
|
|
360
393
|
except (AttributeError, RuntimeError, TypeError):
|
|
@@ -442,11 +475,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
442
475
|
"""
|
|
443
476
|
mw = self.main_window
|
|
444
477
|
|
|
445
|
-
# Finalize Move Group drag
|
|
478
|
+
# Finalize Move Group / Selected Atoms drag
|
|
446
479
|
move_group_dialog = None
|
|
447
480
|
try:
|
|
448
481
|
for widget in QApplication.topLevelWidgets():
|
|
449
|
-
if
|
|
482
|
+
if (
|
|
483
|
+
type(widget).__name__
|
|
484
|
+
in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
|
|
485
|
+
and widget.isVisible()
|
|
486
|
+
):
|
|
450
487
|
move_group_dialog = widget
|
|
451
488
|
break
|
|
452
489
|
except (AttributeError, RuntimeError, TypeError):
|
|
@@ -749,11 +786,15 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
|
|
|
749
786
|
"""
|
|
750
787
|
mw = self.main_window
|
|
751
788
|
|
|
752
|
-
# Finalize Move Group rotation
|
|
789
|
+
# Finalize Move Group / Selected Atoms rotation
|
|
753
790
|
move_group_dialog = None
|
|
754
791
|
try:
|
|
755
792
|
for widget in QApplication.topLevelWidgets():
|
|
756
|
-
if
|
|
793
|
+
if (
|
|
794
|
+
type(widget).__name__
|
|
795
|
+
in ("MoveGroupDialog", "MoveSelectedAtomsDialog")
|
|
796
|
+
and widget.isVisible()
|
|
797
|
+
):
|
|
757
798
|
move_group_dialog = widget
|
|
758
799
|
break
|
|
759
800
|
except (AttributeError, RuntimeError, TypeError):
|
|
@@ -18,7 +18,8 @@ import json
|
|
|
18
18
|
import os
|
|
19
19
|
from typing import Any, List, Literal, Optional, cast
|
|
20
20
|
|
|
21
|
-
from PyQt6.QtWidgets import QInputDialog, QMessageBox
|
|
21
|
+
from PyQt6.QtWidgets import QInputDialog, QMessageBox, QDialog
|
|
22
|
+
from PyQt6.QtCore import Qt
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
try:
|
|
@@ -33,6 +34,7 @@ try:
|
|
|
33
34
|
from .dihedral_dialog import DihedralDialog
|
|
34
35
|
from .mirror_dialog import MirrorDialog
|
|
35
36
|
from .move_group_dialog import MoveGroupDialog
|
|
37
|
+
from .move_selected_atoms_dialog import MoveSelectedAtomsDialog
|
|
36
38
|
from .periodic_table_dialog import PeriodicTableDialog
|
|
37
39
|
from .planarize_dialog import PlanarizeDialog
|
|
38
40
|
from .settings_dialog import SettingsDialog
|
|
@@ -53,6 +55,7 @@ except ImportError:
|
|
|
53
55
|
from moleditpy_linux.ui.dihedral_dialog import DihedralDialog
|
|
54
56
|
from moleditpy_linux.ui.mirror_dialog import MirrorDialog
|
|
55
57
|
from moleditpy_linux.ui.move_group_dialog import MoveGroupDialog
|
|
58
|
+
from moleditpy_linux.ui.move_selected_atoms_dialog import MoveSelectedAtomsDialog
|
|
56
59
|
from moleditpy_linux.ui.periodic_table_dialog import PeriodicTableDialog
|
|
57
60
|
from moleditpy_linux.ui.planarize_dialog import PlanarizeDialog
|
|
58
61
|
from moleditpy_linux.ui.settings_dialog import SettingsDialog
|
|
@@ -213,6 +216,13 @@ class DialogManager:
|
|
|
213
216
|
self.host, "Error", f"Failed to save template: {str(e)}"
|
|
214
217
|
)
|
|
215
218
|
|
|
219
|
+
def _show_modeless_dialog(self, dialog: QDialog) -> None:
|
|
220
|
+
"""Show a modeless dialog on top, especially important for macOS."""
|
|
221
|
+
dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
|
222
|
+
dialog.show()
|
|
223
|
+
dialog.raise_()
|
|
224
|
+
dialog.activateWindow()
|
|
225
|
+
|
|
216
226
|
def open_translation_dialog(self) -> None:
|
|
217
227
|
"""Open the translation dialog"""
|
|
218
228
|
# Get preselected atoms
|
|
@@ -229,8 +239,8 @@ class DialogManager:
|
|
|
229
239
|
preselected_atoms,
|
|
230
240
|
parent=self.host,
|
|
231
241
|
)
|
|
232
|
-
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
233
|
-
|
|
242
|
+
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
243
|
+
self._show_modeless_dialog(dialog)
|
|
234
244
|
dialog.accepted.connect(
|
|
235
245
|
lambda: self.host.statusBar().showMessage("Translation applied.")
|
|
236
246
|
)
|
|
@@ -256,7 +266,7 @@ class DialogManager:
|
|
|
256
266
|
parent=self.host,
|
|
257
267
|
)
|
|
258
268
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
259
|
-
|
|
269
|
+
self._show_modeless_dialog(dialog)
|
|
260
270
|
dialog.accepted.connect(
|
|
261
271
|
lambda: self.host.statusBar().showMessage("Group transformation applied.")
|
|
262
272
|
)
|
|
@@ -265,6 +275,34 @@ class DialogManager:
|
|
|
265
275
|
lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
|
|
266
276
|
)
|
|
267
277
|
|
|
278
|
+
def open_move_selected_atoms_dialog(self) -> None:
|
|
279
|
+
"""Open Move Selected Atoms dialog"""
|
|
280
|
+
# Get preselected atoms
|
|
281
|
+
preselected_atoms = self._get_preselected_atoms_3d()
|
|
282
|
+
|
|
283
|
+
# Disable measurement mode
|
|
284
|
+
if self.host.edit_3d_manager.measurement_mode:
|
|
285
|
+
self.host.init_manager.measurement_action.setChecked(False)
|
|
286
|
+
self.host.edit_3d_manager.toggle_measurement_mode(False)
|
|
287
|
+
|
|
288
|
+
dialog = MoveSelectedAtomsDialog(
|
|
289
|
+
self.host.view_3d_manager.current_mol,
|
|
290
|
+
self.host,
|
|
291
|
+
preselected_atoms,
|
|
292
|
+
parent=self.host,
|
|
293
|
+
)
|
|
294
|
+
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
295
|
+
self._show_modeless_dialog(dialog)
|
|
296
|
+
dialog.accepted.connect(
|
|
297
|
+
lambda: self.host.statusBar().showMessage(
|
|
298
|
+
"Selected atoms transformation applied."
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
dialog.accepted.connect(self.host.edit_actions_manager.push_undo_state)
|
|
302
|
+
dialog.finished.connect(
|
|
303
|
+
lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
|
|
304
|
+
)
|
|
305
|
+
|
|
268
306
|
def open_align_plane_dialog(self, plane: str) -> None:
|
|
269
307
|
"""Open align dialog"""
|
|
270
308
|
# Get pre-selected atoms
|
|
@@ -283,7 +321,7 @@ class DialogManager:
|
|
|
283
321
|
parent=self.host,
|
|
284
322
|
)
|
|
285
323
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
286
|
-
|
|
324
|
+
self._show_modeless_dialog(dialog)
|
|
287
325
|
dialog.accepted.connect(
|
|
288
326
|
lambda: self.host.statusBar().showMessage(
|
|
289
327
|
f"Atoms aligned to {plane.upper()} plane."
|
|
@@ -311,7 +349,7 @@ class DialogManager:
|
|
|
311
349
|
parent=self.host,
|
|
312
350
|
)
|
|
313
351
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
314
|
-
|
|
352
|
+
self._show_modeless_dialog(dialog)
|
|
315
353
|
dialog.accepted.connect(
|
|
316
354
|
lambda: self.host.statusBar().showMessage(
|
|
317
355
|
"Selection planarized to best-fit plane."
|
|
@@ -340,7 +378,7 @@ class DialogManager:
|
|
|
340
378
|
parent=self.host,
|
|
341
379
|
)
|
|
342
380
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
343
|
-
|
|
381
|
+
self._show_modeless_dialog(dialog)
|
|
344
382
|
dialog.accepted.connect(
|
|
345
383
|
lambda: self.host.statusBar().showMessage(
|
|
346
384
|
f"Atoms aligned to {axis.upper()}-axis."
|
|
@@ -368,7 +406,7 @@ class DialogManager:
|
|
|
368
406
|
parent=self.host,
|
|
369
407
|
)
|
|
370
408
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
371
|
-
|
|
409
|
+
self._show_modeless_dialog(dialog)
|
|
372
410
|
dialog.accepted.connect(
|
|
373
411
|
lambda: self.host.statusBar().showMessage("Bond length adjusted.")
|
|
374
412
|
)
|
|
@@ -394,7 +432,7 @@ class DialogManager:
|
|
|
394
432
|
parent=self.host,
|
|
395
433
|
)
|
|
396
434
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
397
|
-
|
|
435
|
+
self._show_modeless_dialog(dialog)
|
|
398
436
|
dialog.accepted.connect(
|
|
399
437
|
lambda: self.host.statusBar().showMessage("Angle adjusted.")
|
|
400
438
|
)
|
|
@@ -420,7 +458,7 @@ class DialogManager:
|
|
|
420
458
|
parent=self.host,
|
|
421
459
|
)
|
|
422
460
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
423
|
-
|
|
461
|
+
self._show_modeless_dialog(dialog)
|
|
424
462
|
dialog.accepted.connect(
|
|
425
463
|
lambda: self.host.statusBar().showMessage("Dihedral angle adjusted.")
|
|
426
464
|
)
|
|
@@ -440,8 +478,18 @@ class DialogManager:
|
|
|
440
478
|
self.host.init_manager.measurement_action.setChecked(False)
|
|
441
479
|
self.host.edit_3d_manager.toggle_measurement_mode(False)
|
|
442
480
|
|
|
443
|
-
dialog = MirrorDialog(
|
|
444
|
-
|
|
481
|
+
dialog = MirrorDialog(
|
|
482
|
+
self.host.view_3d_manager.current_mol, self.host, parent=self.host
|
|
483
|
+
)
|
|
484
|
+
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
485
|
+
self._show_modeless_dialog(dialog)
|
|
486
|
+
dialog.accepted.connect(
|
|
487
|
+
lambda: self.host.statusBar().showMessage("Mirror applied.")
|
|
488
|
+
)
|
|
489
|
+
dialog.accepted.connect(self.host.edit_actions_manager.push_undo_state)
|
|
490
|
+
dialog.finished.connect(
|
|
491
|
+
lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
|
|
492
|
+
)
|
|
445
493
|
|
|
446
494
|
def open_settings_dialog(self) -> None:
|
|
447
495
|
"""Open the application settings dialog."""
|
|
@@ -468,7 +516,7 @@ class DialogManager:
|
|
|
468
516
|
self.host.view_3d_manager.current_mol, self.host, parent=self.host
|
|
469
517
|
)
|
|
470
518
|
self.host.edit_3d_manager.active_3d_dialogs.append(dialog)
|
|
471
|
-
|
|
519
|
+
self._show_modeless_dialog(dialog)
|
|
472
520
|
dialog.finished.connect(
|
|
473
521
|
lambda: self.host.edit_3d_manager.remove_dialog_from_list(dialog)
|
|
474
522
|
)
|
|
@@ -1866,6 +1866,14 @@ class MainInitManager:
|
|
|
1866
1866
|
edit_3d_menu.addAction(translation_action)
|
|
1867
1867
|
self.host.translation_action = translation_action
|
|
1868
1868
|
|
|
1869
|
+
move_selected_atoms_action = QAction("Move Selected Atoms...", self.host)
|
|
1870
|
+
move_selected_atoms_action.triggered.connect(
|
|
1871
|
+
self.host.dialog_manager.open_move_selected_atoms_dialog
|
|
1872
|
+
)
|
|
1873
|
+
move_selected_atoms_action.setEnabled(False)
|
|
1874
|
+
edit_3d_menu.addAction(move_selected_atoms_action)
|
|
1875
|
+
self.host.move_selected_atoms_action = move_selected_atoms_action
|
|
1876
|
+
|
|
1869
1877
|
move_group_action = QAction("Move Group...", self.host)
|
|
1870
1878
|
move_group_action.triggered.connect(
|
|
1871
1879
|
self.host.dialog_manager.open_move_group_dialog
|
|
@@ -228,6 +228,9 @@ class TemplateMixin:
|
|
|
228
228
|
return math.hypot(ax - bx, ay - by)
|
|
229
229
|
|
|
230
230
|
# --- 1) Map already clicked existing_items to template vertices ---
|
|
231
|
+
alt_pressed = bool(
|
|
232
|
+
QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier
|
|
233
|
+
)
|
|
231
234
|
existing_items = existing_items or []
|
|
232
235
|
used_indices = set()
|
|
233
236
|
ref_lengths = [
|
|
@@ -258,8 +261,14 @@ class TemplateMixin:
|
|
|
258
261
|
|
|
259
262
|
# --- 2) Enumerate existing atoms in the scene from self.data.atoms and map them ---
|
|
260
263
|
mapped_atoms = {it for it in atom_items if it is not None}
|
|
261
|
-
if
|
|
262
|
-
|
|
264
|
+
if (
|
|
265
|
+
hasattr(self, "data")
|
|
266
|
+
and hasattr(self.data, "atoms")
|
|
267
|
+
and self.data.atoms is not None
|
|
268
|
+
and self.get_setting("template_fusing_enabled_2d", True)
|
|
269
|
+
and not alt_pressed
|
|
270
|
+
):
|
|
271
|
+
map_threshold = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
263
272
|
for i, p in enumerate(points):
|
|
264
273
|
if atom_items[i] is not None:
|
|
265
274
|
continue
|
|
@@ -353,12 +362,14 @@ class TemplateMixin:
|
|
|
353
362
|
|
|
354
363
|
return atom_items
|
|
355
364
|
|
|
356
|
-
def update_template_preview(
|
|
365
|
+
def update_template_preview(
|
|
366
|
+
self, pos: QPointF, alt_pressed: Optional[bool] = None
|
|
367
|
+
) -> None:
|
|
357
368
|
mode_parts = self.mode.split("_")
|
|
358
369
|
|
|
359
370
|
# Check if this is a user template
|
|
360
371
|
if len(mode_parts) >= 3 and mode_parts[1] == "user":
|
|
361
|
-
self.update_user_template_preview(pos)
|
|
372
|
+
self.update_user_template_preview(pos, alt_pressed=alt_pressed)
|
|
362
373
|
return
|
|
363
374
|
|
|
364
375
|
is_aromatic = False
|
|
@@ -371,6 +382,10 @@ class TemplateMixin:
|
|
|
371
382
|
except ValueError:
|
|
372
383
|
return
|
|
373
384
|
|
|
385
|
+
if alt_pressed is None:
|
|
386
|
+
alt_pressed = bool(
|
|
387
|
+
QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier
|
|
388
|
+
)
|
|
374
389
|
item = None
|
|
375
390
|
if pos:
|
|
376
391
|
snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
|
|
@@ -383,7 +398,7 @@ class TemplateMixin:
|
|
|
383
398
|
break
|
|
384
399
|
|
|
385
400
|
points, bonds_info = [], []
|
|
386
|
-
|
|
401
|
+
bond_len = DEFAULT_BOND_LENGTH
|
|
387
402
|
self.template_context = {}
|
|
388
403
|
|
|
389
404
|
if isinstance(item, AtomItem):
|
|
@@ -391,7 +406,10 @@ class TemplateMixin:
|
|
|
391
406
|
continuous_angle = math.atan2(pos.y() - p0.y(), pos.x() - p0.x())
|
|
392
407
|
snap_angle_rad = math.radians(15)
|
|
393
408
|
snapped_angle = round(continuous_angle / snap_angle_rad) * snap_angle_rad
|
|
394
|
-
p1 = p0 + QPointF(
|
|
409
|
+
p1 = p0 + QPointF(
|
|
410
|
+
bond_len * math.cos(snapped_angle),
|
|
411
|
+
bond_len * math.sin(snapped_angle),
|
|
412
|
+
)
|
|
395
413
|
points = self._calculate_polygon_from_edge(p0, p1, n)
|
|
396
414
|
self.template_context["items"] = [item]
|
|
397
415
|
|
|
@@ -409,8 +427,8 @@ class TemplateMixin:
|
|
|
409
427
|
points = [
|
|
410
428
|
pos
|
|
411
429
|
+ QPointF(
|
|
412
|
-
|
|
413
|
-
|
|
430
|
+
bond_len * math.cos(start_angle + i * angle_step),
|
|
431
|
+
bond_len * math.sin(start_angle + i * angle_step),
|
|
414
432
|
)
|
|
415
433
|
for i in range(n)
|
|
416
434
|
]
|
|
@@ -426,6 +444,57 @@ class TemplateMixin:
|
|
|
426
444
|
self.template_context["points"] = points
|
|
427
445
|
self.template_context["bonds_info"] = bonds_info
|
|
428
446
|
|
|
447
|
+
# Snap individual preview vertices to nearby atoms to reflect template fusing visually
|
|
448
|
+
if self.get_setting("template_fusing_enabled_2d", True) and not alt_pressed:
|
|
449
|
+
fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
450
|
+
mapped_atoms = set(self.template_context.get("items", []))
|
|
451
|
+
used_indices = set()
|
|
452
|
+
click_map_threshold = max(0.5 * bond_len, 8.0)
|
|
453
|
+
|
|
454
|
+
# Map already clicked existing items first
|
|
455
|
+
for ex_item in self.template_context.get("items", []):
|
|
456
|
+
try:
|
|
457
|
+
ex_pos = ex_item.pos()
|
|
458
|
+
best_idx, best_d = -1, float("inf")
|
|
459
|
+
for i, p in enumerate(points):
|
|
460
|
+
if i in used_indices:
|
|
461
|
+
continue
|
|
462
|
+
d = math.hypot(p.x() - ex_pos.x(), p.y() - ex_pos.y())
|
|
463
|
+
if d < best_d:
|
|
464
|
+
best_d, best_idx = d, i
|
|
465
|
+
if best_idx != -1 and best_d <= click_map_threshold:
|
|
466
|
+
points[best_idx] = ex_pos
|
|
467
|
+
used_indices.add(best_idx)
|
|
468
|
+
except (AttributeError, TypeError, IndexError):
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
# Map unmapped points to other unmapped nearby atoms in the scene
|
|
472
|
+
if (
|
|
473
|
+
hasattr(self, "data")
|
|
474
|
+
and hasattr(self.data, "atoms")
|
|
475
|
+
and self.data.atoms is not None
|
|
476
|
+
):
|
|
477
|
+
for i, p in enumerate(points):
|
|
478
|
+
if i in used_indices:
|
|
479
|
+
continue
|
|
480
|
+
nearby = None
|
|
481
|
+
best_d = float("inf")
|
|
482
|
+
for atom_data in self.data.atoms.values():
|
|
483
|
+
a_item = atom_data.get("item")
|
|
484
|
+
if not a_item or a_item in mapped_atoms:
|
|
485
|
+
continue
|
|
486
|
+
try:
|
|
487
|
+
d = math.hypot(
|
|
488
|
+
p.x() - a_item.pos().x(), p.y() - a_item.pos().y()
|
|
489
|
+
)
|
|
490
|
+
except (AttributeError, TypeError):
|
|
491
|
+
continue
|
|
492
|
+
if d < best_d:
|
|
493
|
+
best_d, nearby = d, a_item
|
|
494
|
+
if nearby and best_d <= fuse_dist:
|
|
495
|
+
points[i] = nearby.pos()
|
|
496
|
+
mapped_atoms.add(nearby)
|
|
497
|
+
|
|
429
498
|
self.template_preview.set_geometry(points, is_aromatic)
|
|
430
499
|
|
|
431
500
|
self.template_preview.show()
|
|
@@ -558,7 +627,9 @@ class TemplateMixin:
|
|
|
558
627
|
if atom_id in self.data.atoms and self.data.atoms[atom_id]["item"]:
|
|
559
628
|
self.data.atoms[atom_id]["item"].update_style()
|
|
560
629
|
|
|
561
|
-
def update_user_template_preview(
|
|
630
|
+
def update_user_template_preview(
|
|
631
|
+
self, pos: QPointF, alt_pressed: Optional[bool] = None
|
|
632
|
+
) -> None:
|
|
562
633
|
"""Update user template preview"""
|
|
563
634
|
# Robust preview: avoid self.data.atoms for preview-only atoms
|
|
564
635
|
if not hasattr(self, "user_template_data") or not self.user_template_data:
|
|
@@ -659,8 +730,8 @@ class KeyboardMixin:
|
|
|
659
730
|
Returns the offset QPointF.
|
|
660
731
|
"""
|
|
661
732
|
start_pos = start_atom.pos()
|
|
662
|
-
|
|
663
|
-
new_pos_offset = QPointF(0, -
|
|
733
|
+
bond_len = bond_length
|
|
734
|
+
new_pos_offset = QPointF(0, -bond_len) # Default offset (up)
|
|
664
735
|
|
|
665
736
|
# Get non-H neighbors
|
|
666
737
|
neighbor_positions = []
|
|
@@ -673,7 +744,7 @@ class KeyboardMixin:
|
|
|
673
744
|
|
|
674
745
|
if num_non_H_neighbors == 0:
|
|
675
746
|
# Zero bonds: default direction (up)
|
|
676
|
-
new_pos_offset = QPointF(0, -
|
|
747
|
+
new_pos_offset = QPointF(0, -bond_len)
|
|
677
748
|
|
|
678
749
|
elif num_non_H_neighbors == 1:
|
|
679
750
|
# One bond: ~120/60 degree angle
|
|
@@ -688,7 +759,7 @@ class KeyboardMixin:
|
|
|
688
759
|
new_vx, new_vy = vx * cos_a - vy * sin_a, vx * sin_a + vy * cos_a
|
|
689
760
|
rotated_vector = QPointF(new_vx, new_vy)
|
|
690
761
|
line = QLineF(QPointF(0, 0), rotated_vector)
|
|
691
|
-
line.setLength(
|
|
762
|
+
line.setLength(bond_len)
|
|
692
763
|
new_pos_offset = line.p2()
|
|
693
764
|
|
|
694
765
|
elif num_non_H_neighbors == 3:
|
|
@@ -705,10 +776,10 @@ class KeyboardMixin:
|
|
|
705
776
|
# SUM_TOLERANCE is now a module-level constant
|
|
706
777
|
if bond_vectors_sum.manhattanLength() > SUM_TOLERANCE:
|
|
707
778
|
new_direction_line = QLineF(QPointF(0, 0), -bond_vectors_sum)
|
|
708
|
-
new_direction_line.setLength(
|
|
779
|
+
new_direction_line.setLength(bond_len)
|
|
709
780
|
new_pos_offset = new_direction_line.p2()
|
|
710
781
|
else:
|
|
711
|
-
new_pos_offset = QPointF(
|
|
782
|
+
new_pos_offset = QPointF(bond_len * 0.7071, -bond_len * 0.7071)
|
|
712
783
|
|
|
713
784
|
else: # 2, 4+ bonds: skeleton continuation or over-bonding
|
|
714
785
|
bond_vectors_sum = QPointF(0, 0)
|
|
@@ -721,26 +792,36 @@ class KeyboardMixin:
|
|
|
721
792
|
|
|
722
793
|
if bond_vectors_sum.manhattanLength() > 0.01:
|
|
723
794
|
new_direction_line = QLineF(QPointF(0, 0), -bond_vectors_sum)
|
|
724
|
-
new_direction_line.setLength(
|
|
795
|
+
new_direction_line.setLength(bond_len)
|
|
725
796
|
new_pos_offset = new_direction_line.p2()
|
|
726
797
|
else:
|
|
727
798
|
# Default (up) if sum is zero
|
|
728
|
-
new_pos_offset = QPointF(0, -
|
|
799
|
+
new_pos_offset = QPointF(0, -bond_len)
|
|
729
800
|
|
|
730
801
|
return new_pos_offset
|
|
731
802
|
|
|
732
803
|
def keyPressEvent(self, event: Any) -> None:
|
|
804
|
+
if not self.views():
|
|
805
|
+
try:
|
|
806
|
+
from PyQt6.QtWidgets import QGraphicsScene
|
|
807
|
+
|
|
808
|
+
QGraphicsScene.keyPressEvent(self, event) # type: ignore[arg-type]
|
|
809
|
+
except (ImportError, AttributeError, TypeError, RuntimeError):
|
|
810
|
+
logging.exception("Error delegating keyPressEvent")
|
|
811
|
+
return
|
|
812
|
+
|
|
733
813
|
view = self.views()[0]
|
|
734
814
|
cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
|
|
735
815
|
transform = view.transform()
|
|
736
816
|
key = event.key()
|
|
737
817
|
modifiers = event.modifiers()
|
|
818
|
+
|
|
738
819
|
item_at_cursor = None
|
|
739
820
|
if key == Qt.Key.Key_4:
|
|
740
821
|
snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
|
|
741
822
|
item_at_cursor = self.find_atom_near(cursor_pos, tol=snap_dist)
|
|
742
823
|
elif self.get_setting("template_fusing_enabled_2d", True):
|
|
743
|
-
fuse_dist = self.get_setting("template_fusing_distance_2d",
|
|
824
|
+
fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
744
825
|
item_at_cursor = self.find_atom_near(cursor_pos, tol=fuse_dist)
|
|
745
826
|
if item_at_cursor is None:
|
|
746
827
|
item_at_cursor = self.itemAt(cursor_pos, transform)
|
|
@@ -759,7 +840,7 @@ class KeyboardMixin:
|
|
|
759
840
|
# Calculate placement like update_template_preview
|
|
760
841
|
if isinstance(item_at_cursor, AtomItem):
|
|
761
842
|
p0 = item_at_cursor.pos()
|
|
762
|
-
|
|
843
|
+
bond_len = DEFAULT_BOND_LENGTH
|
|
763
844
|
|
|
764
845
|
# Check if this is a terminal atom (exactly 1 neighbor)
|
|
765
846
|
neighbor_positions = []
|
|
@@ -807,7 +888,8 @@ class KeyboardMixin:
|
|
|
807
888
|
angle_plus if diff_plus < diff_minus else angle_minus
|
|
808
889
|
)
|
|
809
890
|
p1 = p0 + QPointF(
|
|
810
|
-
|
|
891
|
+
bond_len * math.cos(best_angle),
|
|
892
|
+
bond_len * math.sin(best_angle),
|
|
811
893
|
)
|
|
812
894
|
bend_dir = p0 - v_to_neighbor
|
|
813
895
|
points = self._calculate_polygon_from_edge(
|
|
@@ -816,9 +898,9 @@ class KeyboardMixin:
|
|
|
816
898
|
else:
|
|
817
899
|
direction = QLineF(p0, cursor_pos).unitVector()
|
|
818
900
|
p1 = (
|
|
819
|
-
p0 + direction.p2() *
|
|
901
|
+
p0 + direction.p2() * bond_len
|
|
820
902
|
if direction.length() > 0
|
|
821
|
-
else p0 + QPointF(
|
|
903
|
+
else p0 + QPointF(bond_len, 0)
|
|
822
904
|
)
|
|
823
905
|
points = self._calculate_polygon_from_edge(
|
|
824
906
|
p0, p1, n, cursor_pos=cursor_pos
|
|
@@ -1076,8 +1158,10 @@ class KeyboardMixin:
|
|
|
1076
1158
|
|
|
1077
1159
|
if start_atom:
|
|
1078
1160
|
start_pos = start_atom.pos()
|
|
1079
|
-
|
|
1080
|
-
new_pos_offset = self._calculate_new_atom_position(
|
|
1161
|
+
bond_len = DEFAULT_BOND_LENGTH
|
|
1162
|
+
new_pos_offset = self._calculate_new_atom_position(
|
|
1163
|
+
start_atom, bond_len
|
|
1164
|
+
)
|
|
1081
1165
|
|
|
1082
1166
|
# SNAP_DISTANCE is a module-level constant
|
|
1083
1167
|
target_pos = start_pos + new_pos_offset
|
|
@@ -311,7 +311,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
311
311
|
and self.get_setting("template_fusing_enabled_2d", True)
|
|
312
312
|
and self.press_pos
|
|
313
313
|
):
|
|
314
|
-
fuse_dist = self.get_setting("template_fusing_distance_2d",
|
|
314
|
+
fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
315
315
|
item = self.find_atom_near(self.press_pos, tol=fuse_dist)
|
|
316
316
|
if item is None:
|
|
317
317
|
item = self.itemAt(self.press_pos, self.views()[0].transform())
|
|
@@ -361,7 +361,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
361
361
|
|
|
362
362
|
target_atom = None
|
|
363
363
|
if self.get_setting("template_fusing_enabled_2d", True) and current_pos:
|
|
364
|
-
fuse_dist = self.get_setting("template_fusing_distance_2d",
|
|
364
|
+
fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
365
365
|
target_atom = self.find_atom_near(current_pos, tol=fuse_dist)
|
|
366
366
|
else:
|
|
367
367
|
for item in self.items(current_pos):
|
|
@@ -558,7 +558,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
558
558
|
line = QLineF(self.start_atom.pos(), end_pos)
|
|
559
559
|
end_item = None
|
|
560
560
|
if self.get_setting("template_fusing_enabled_2d", True) and end_pos:
|
|
561
|
-
fuse_dist = self.get_setting("template_fusing_distance_2d",
|
|
561
|
+
fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
562
562
|
end_item = self.find_atom_near(end_pos, tol=fuse_dist)
|
|
563
563
|
if end_item is None:
|
|
564
564
|
end_item = self.itemAt(end_pos, self.views()[0].transform())
|
|
@@ -612,7 +612,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
|
|
|
612
612
|
else:
|
|
613
613
|
end_item = None
|
|
614
614
|
if self.get_setting("template_fusing_enabled_2d", True) and end_pos:
|
|
615
|
-
fuse_dist = self.get_setting("template_fusing_distance_2d",
|
|
615
|
+
fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
|
|
616
616
|
end_item = self.find_atom_near(end_pos, tol=fuse_dist)
|
|
617
617
|
if end_item is None:
|
|
618
618
|
end_item = self.itemAt(end_pos, self.views()[0].transform())
|