MoleditPy 3.3.1__tar.gz → 3.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {moleditpy-3.3.1 → moleditpy-3.4.0}/PKG-INFO +1 -1
- {moleditpy-3.3.1 → moleditpy-3.4.0}/pyproject.toml +1 -1
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/MoleditPy.egg-info/PKG-INFO +1 -1
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/__init__.py +1 -1
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/main.py +2 -1
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/angle_dialog.py +47 -22
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/app_state.py +7 -9
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/atom_item.py +77 -27
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/base_picking_dialog.py +19 -5
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/bond_item.py +93 -16
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/bond_length_dialog.py +25 -10
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/calculation_worker.py +1 -1
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/color_settings_dialog.py +12 -12
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/compute_logic.py +9 -9
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/constrained_optimization_dialog.py +4 -4
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/custom_interactor_style.py +6 -6
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/dialog_logic.py +2 -2
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/dihedral_dialog.py +31 -17
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/edit_3d_logic.py +4 -4
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/edit_actions_logic.py +12 -12
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/export_logic.py +9 -2
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/geometry_base_dialog.py +2 -2
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/io_logic.py +6 -6
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/main_window_init.py +7 -7
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/molecular_scene_handler.py +10 -10
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/molecule_scene.py +8 -8
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/move_group_dialog.py +3 -5
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/planarize_dialog.py +1 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/settings_dialog.py +7 -7
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/ui_manager.py +10 -10
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/user_template_dialog.py +3 -3
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/view_3d_logic.py +3 -3
- {moleditpy-3.3.1 → moleditpy-3.4.0}/LICENSE +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/README.md +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/setup.cfg +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/MoleditPy.egg-info/requires.txt +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/__main__.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/assets/file_icon.ico +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/assets/icon.icns +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/assets/icon.ico +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/assets/icon.png +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/core/__init__.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/core/mol_geometry.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/core/molecular_data.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/plugins/__init__.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/plugins/plugin_interface.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/plugins/plugin_manager.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/__init__.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/about_dialog.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/align_plane_dialog.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/alignment_dialog.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/analysis_window.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/atom_picking.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/main_window.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/mirror_dialog.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/string_importers.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/template_preview_item.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/template_preview_view.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/translation_dialog.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/ui/zoomable_view.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/utils/__init__.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/utils/constants.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/utils/default_settings.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
- {moleditpy-3.3.1 → moleditpy-3.4.0}/src/moleditpy/utils/system_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.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
|
|
@@ -68,7 +68,8 @@ def main() -> None:
|
|
|
68
68
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
|
69
69
|
|
|
70
70
|
parser = argparse.ArgumentParser(
|
|
71
|
-
prog="moleditpy",
|
|
71
|
+
prog="moleditpy",
|
|
72
|
+
description="MoleditPy — A Python-based molecular editing software",
|
|
72
73
|
)
|
|
73
74
|
parser.add_argument("file", nargs="?", default=None, help="File to open on startup")
|
|
74
75
|
_variant = " (Linux)" if "moleditpy_linux" in (__file__ or "") else ""
|
|
@@ -189,8 +189,10 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
189
189
|
self.atom2_idx = atom_idx
|
|
190
190
|
elif self.atom3_idx is None:
|
|
191
191
|
self.atom3_idx = atom_idx
|
|
192
|
-
#
|
|
193
|
-
|
|
192
|
+
# Capture the ABSOLUTE baseline for this selection session.
|
|
193
|
+
# This prevents 'direction change' drift during multiple slider drags.
|
|
194
|
+
self._baseline_positions = self.mol.GetConformer().GetPositions().copy()
|
|
195
|
+
self._snapshot_positions = self._baseline_positions.copy()
|
|
194
196
|
else:
|
|
195
197
|
# Reset and start over
|
|
196
198
|
self.atom1_idx = atom_idx
|
|
@@ -205,6 +207,7 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
205
207
|
self.atom1_idx = None
|
|
206
208
|
self.atom2_idx = None # vertex atom
|
|
207
209
|
self.atom3_idx = None
|
|
210
|
+
self._baseline_positions = None
|
|
208
211
|
self._snapshot_positions = None
|
|
209
212
|
self.clear_selection_labels()
|
|
210
213
|
self.update_display()
|
|
@@ -212,7 +215,7 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
212
215
|
def show_atom_labels(self) -> None:
|
|
213
216
|
"""Display labels on the selected atoms."""
|
|
214
217
|
selected_atoms = [self.atom1_idx, self.atom2_idx, self.atom3_idx]
|
|
215
|
-
labels = ["
|
|
218
|
+
labels = ["1", "2", "3"]
|
|
216
219
|
pairs = [
|
|
217
220
|
(idx, labels[i]) for i, idx in enumerate(selected_atoms) if idx is not None
|
|
218
221
|
]
|
|
@@ -271,7 +274,7 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
271
274
|
self._snapshot_positions = None
|
|
272
275
|
# Add labels
|
|
273
276
|
self.add_selection_label(self.atom1_idx, "1")
|
|
274
|
-
self.add_selection_label(self.atom2_idx, "2
|
|
277
|
+
self.add_selection_label(self.atom2_idx, "2")
|
|
275
278
|
# Clear angle input while selection is incomplete
|
|
276
279
|
try:
|
|
277
280
|
self.angle_input.blockSignals(True)
|
|
@@ -295,23 +298,35 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
295
298
|
current_angle = self.calculate_angle()
|
|
296
299
|
self.angle_label.setText(f"Current angle: {current_angle:.2f}°")
|
|
297
300
|
self.apply_button.setEnabled(True)
|
|
298
|
-
# Update angle input box
|
|
301
|
+
# Update angle input box and slider
|
|
299
302
|
try:
|
|
300
|
-
|
|
301
|
-
self.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
303
|
+
# Update input box and slider only if not dragging
|
|
304
|
+
if not self._slider_dragging:
|
|
305
|
+
self.angle_input.blockSignals(True)
|
|
306
|
+
self.angle_input.setText(f"{current_angle:.2f}")
|
|
307
|
+
self.angle_input.blockSignals(False)
|
|
308
|
+
|
|
309
|
+
# UPDATE SLIDER: Logic to prevent 'jumping' to positive value
|
|
310
|
+
slider_val = int(round(current_angle))
|
|
311
|
+
current_slider_val = self.angle_slider.value()
|
|
312
|
+
|
|
313
|
+
# If the user is on the negative side of the slider, keep the sign
|
|
314
|
+
if current_slider_val < 0:
|
|
315
|
+
slider_val = -slider_val
|
|
316
|
+
|
|
317
|
+
if current_slider_val != slider_val:
|
|
318
|
+
self.angle_slider.blockSignals(True)
|
|
319
|
+
self.angle_slider.setValue(slider_val)
|
|
320
|
+
self.angle_slider.blockSignals(False)
|
|
321
|
+
self.angle_slider.setEnabled(True)
|
|
322
|
+
else:
|
|
323
|
+
self.angle_slider.setEnabled(True)
|
|
309
324
|
except (AttributeError, RuntimeError, TypeError):
|
|
310
325
|
pass
|
|
311
326
|
|
|
312
327
|
# Add labels
|
|
313
328
|
self.add_selection_label(self.atom1_idx, "1")
|
|
314
|
-
self.add_selection_label(self.atom2_idx, "2
|
|
329
|
+
self.add_selection_label(self.atom2_idx, "2")
|
|
315
330
|
self.add_selection_label(self.atom3_idx, "3")
|
|
316
331
|
|
|
317
332
|
def calculate_angle(self) -> float:
|
|
@@ -366,10 +381,14 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
366
381
|
"""Adjust the bond angle."""
|
|
367
382
|
conf = self.mol.GetConformer()
|
|
368
383
|
|
|
369
|
-
# Use
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
384
|
+
# Use baseline positions (fixed for dialog session) to keep the rotation axis stable.
|
|
385
|
+
if (
|
|
386
|
+
hasattr(self, "_baseline_positions")
|
|
387
|
+
and self._baseline_positions is not None
|
|
388
|
+
):
|
|
389
|
+
positions = self._baseline_positions.copy()
|
|
390
|
+
elif self._snapshot_positions is not None:
|
|
391
|
+
positions = self._snapshot_positions.copy()
|
|
373
392
|
else:
|
|
374
393
|
positions = conf.GetPositions()
|
|
375
394
|
|
|
@@ -379,10 +398,16 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
379
398
|
idx_b: int = self.atom2_idx # vertex
|
|
380
399
|
idx_c: int = self.atom3_idx
|
|
381
400
|
|
|
401
|
+
# Calculate baseline angle from the POSITIONS we are working on (important for snapshot stability)
|
|
402
|
+
p_a, p_b, p_c = positions[idx_a], positions[idx_b], positions[idx_c]
|
|
403
|
+
from moleditpy.core.mol_geometry import calc_angle_deg
|
|
404
|
+
|
|
405
|
+
baseline_angle = calc_angle_deg(p_a, p_b, p_c)
|
|
406
|
+
|
|
382
407
|
if self.both_groups_radio.isChecked():
|
|
383
408
|
# Both arms rotate equally (half angle each)
|
|
384
|
-
|
|
385
|
-
half_delta_deg = (new_angle_deg -
|
|
409
|
+
# Use baseline_angle from snapshot to avoid drift/jumps
|
|
410
|
+
half_delta_deg = (new_angle_deg - baseline_angle) / 2.0
|
|
386
411
|
|
|
387
412
|
group1 = get_connected_group(self.mol, idx_a, exclude=idx_b)
|
|
388
413
|
group3 = get_connected_group(self.mol, idx_c, exclude=idx_b)
|
|
@@ -393,7 +418,7 @@ class AngleDialog(GeometryBaseDialog):
|
|
|
393
418
|
idx_c,
|
|
394
419
|
idx_b,
|
|
395
420
|
idx_a,
|
|
396
|
-
|
|
421
|
+
baseline_angle + half_delta_deg,
|
|
397
422
|
group1,
|
|
398
423
|
)
|
|
399
424
|
# Arm 3 rotates to the FINAL angle (relative to the now-moved Arm 1)
|
|
@@ -17,7 +17,8 @@ import binascii
|
|
|
17
17
|
import copy
|
|
18
18
|
import logging
|
|
19
19
|
import os
|
|
20
|
-
from typing import Any, Dict, Optional, Tuple
|
|
20
|
+
from typing import Any, Dict, Optional, Tuple
|
|
21
|
+
|
|
21
22
|
|
|
22
23
|
import numpy as np
|
|
23
24
|
|
|
@@ -45,9 +46,6 @@ try:
|
|
|
45
46
|
except (AttributeError, RuntimeError, TypeError, ImportError):
|
|
46
47
|
from moleditpy.core.molecular_data import MolecularData
|
|
47
48
|
|
|
48
|
-
if TYPE_CHECKING:
|
|
49
|
-
from .main_window import MainWindow
|
|
50
|
-
|
|
51
49
|
|
|
52
50
|
# --- Class Definition ---
|
|
53
51
|
class StateManager:
|
|
@@ -634,7 +632,7 @@ class StateManager:
|
|
|
634
632
|
method = json_data.get("last_successful_optimization_method", None)
|
|
635
633
|
if hasattr(self.host, "compute_manager"):
|
|
636
634
|
self.host.compute_manager.last_successful_optimization_method = method
|
|
637
|
-
else:
|
|
635
|
+
else:
|
|
638
636
|
logging.error(
|
|
639
637
|
"REPORT ERROR: Missing attribute 'compute_manager' on self.host"
|
|
640
638
|
)
|
|
@@ -808,7 +806,7 @@ class StateManager:
|
|
|
808
806
|
"update_atom_id_menu_text",
|
|
809
807
|
):
|
|
810
808
|
self.host.view_3d_manager.update_atom_id_menu_text()
|
|
811
|
-
else:
|
|
809
|
+
else:
|
|
812
810
|
logging.error(
|
|
813
811
|
"REPORT ERROR: Missing attribute 'update_atom_id_menu_text' on object"
|
|
814
812
|
)
|
|
@@ -817,13 +815,13 @@ class StateManager:
|
|
|
817
815
|
"update_atom_id_menu_state",
|
|
818
816
|
):
|
|
819
817
|
self.host.view_3d_manager.update_atom_id_menu_state()
|
|
820
|
-
else:
|
|
818
|
+
else:
|
|
821
819
|
logging.error(
|
|
822
820
|
"REPORT ERROR: Missing attribute 'update_atom_id_menu_state' on object"
|
|
823
821
|
)
|
|
824
822
|
except (RuntimeError, TypeError, AttributeError):
|
|
825
823
|
pass
|
|
826
|
-
else:
|
|
824
|
+
else:
|
|
827
825
|
logging.error(
|
|
828
826
|
"REPORT ERROR: Missing attribute 'create_atom_id_mapping' on object"
|
|
829
827
|
)
|
|
@@ -833,7 +831,7 @@ class StateManager:
|
|
|
833
831
|
self.host.view_3d_manager.draw_molecule_3d(
|
|
834
832
|
self.host.view_3d_manager.current_mol
|
|
835
833
|
)
|
|
836
|
-
else:
|
|
834
|
+
else:
|
|
837
835
|
logging.error(
|
|
838
836
|
"REPORT ERROR: Missing attribute 'draw_molecule_3d' on object"
|
|
839
837
|
)
|
|
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
|
-
import logging
|
|
14
|
+
import logging
|
|
15
15
|
from typing import Any, List, Optional
|
|
16
16
|
|
|
17
17
|
from PyQt6.QtCore import QPointF, QRectF, Qt
|
|
@@ -92,7 +92,7 @@ class AtomItem(QGraphicsItem):
|
|
|
92
92
|
if hasattr(scene, "get_setting"):
|
|
93
93
|
font_size = scene.get_setting("atom_font_size_2d", 20)
|
|
94
94
|
font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
|
|
95
|
-
else:
|
|
95
|
+
else:
|
|
96
96
|
logging.error(
|
|
97
97
|
f"REPORT ERROR: Missing attribute 'get_setting' on scene of type {type(scene)}"
|
|
98
98
|
)
|
|
@@ -119,7 +119,7 @@ class AtomItem(QGraphicsItem):
|
|
|
119
119
|
if hasattr(scene, "get_setting"):
|
|
120
120
|
font_size = scene.get_setting("atom_font_size_2d", 20)
|
|
121
121
|
font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
|
|
122
|
-
else:
|
|
122
|
+
else:
|
|
123
123
|
logging.error(
|
|
124
124
|
f"REPORT ERROR: Missing attribute 'get_setting' on scene of type {type(scene)}"
|
|
125
125
|
)
|
|
@@ -225,6 +225,77 @@ class AtomItem(QGraphicsItem):
|
|
|
225
225
|
# 3. Add final margins for selection highlights, etc.
|
|
226
226
|
return full_visual_rect.adjusted(-3, -3, 3, 3)
|
|
227
227
|
|
|
228
|
+
def get_bg_ellipse_path(self) -> QPainterPath:
|
|
229
|
+
path = QPainterPath()
|
|
230
|
+
if not self.is_visible:
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
font_size = 20
|
|
234
|
+
font_family = FONT_FAMILY
|
|
235
|
+
scene = self.scene()
|
|
236
|
+
if scene is not None:
|
|
237
|
+
if hasattr(scene, "get_setting"):
|
|
238
|
+
font_size = scene.get_setting("atom_font_size_2d", 20)
|
|
239
|
+
font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
|
|
240
|
+
|
|
241
|
+
font = QFont(font_family, font_size, FONT_WEIGHT_BOLD)
|
|
242
|
+
fm = QFontMetricsF(font)
|
|
243
|
+
|
|
244
|
+
hydrogen_part = ""
|
|
245
|
+
if self.implicit_h_count > 0:
|
|
246
|
+
is_skeletal_carbon = (
|
|
247
|
+
self.symbol == "C"
|
|
248
|
+
and self.charge == 0
|
|
249
|
+
and self.radical == 0
|
|
250
|
+
and len(self.bonds) > 0
|
|
251
|
+
)
|
|
252
|
+
if not is_skeletal_carbon:
|
|
253
|
+
hydrogen_part = "H"
|
|
254
|
+
if self.implicit_h_count > 1:
|
|
255
|
+
subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
256
|
+
hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
|
|
257
|
+
|
|
258
|
+
flip_text = False
|
|
259
|
+
if hydrogen_part and self.bonds:
|
|
260
|
+
my_pos_x = self.pos().x()
|
|
261
|
+
total_dx = 0.0
|
|
262
|
+
for b in self.bonds:
|
|
263
|
+
partner = b.atom2 if b.atom1 is self else b.atom1
|
|
264
|
+
try:
|
|
265
|
+
if partner is None or sip_isdeleted_safe(partner):
|
|
266
|
+
continue
|
|
267
|
+
partner_pos = partner.pos()
|
|
268
|
+
if partner_pos is None:
|
|
269
|
+
continue
|
|
270
|
+
total_dx += partner_pos.x() - my_pos_x
|
|
271
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
272
|
+
# Suppress non-critical UI sync errors during atom position retrieval
|
|
273
|
+
continue
|
|
274
|
+
if total_dx > 0:
|
|
275
|
+
flip_text = True
|
|
276
|
+
|
|
277
|
+
if flip_text:
|
|
278
|
+
display_text = hydrogen_part + self.symbol
|
|
279
|
+
else:
|
|
280
|
+
display_text = self.symbol + hydrogen_part
|
|
281
|
+
|
|
282
|
+
text_rect = fm.boundingRect(display_text)
|
|
283
|
+
text_rect.adjust(-2, -2, 2, 2)
|
|
284
|
+
if hydrogen_part:
|
|
285
|
+
symbol_rect = fm.boundingRect(self.symbol)
|
|
286
|
+
if flip_text:
|
|
287
|
+
offset_x = symbol_rect.width() // 2
|
|
288
|
+
text_rect.moveTo(offset_x - text_rect.width(), -text_rect.height() / 2)
|
|
289
|
+
else:
|
|
290
|
+
offset_x = -symbol_rect.width() // 2
|
|
291
|
+
text_rect.moveTo(offset_x, -text_rect.height() / 2)
|
|
292
|
+
else:
|
|
293
|
+
text_rect.moveCenter(QPointF(0, 0))
|
|
294
|
+
|
|
295
|
+
bg_rect = text_rect.adjusted(-5, -8, 5, 8)
|
|
296
|
+
path.addEllipse(bg_rect)
|
|
297
|
+
return path
|
|
298
|
+
|
|
228
299
|
def shape(self) -> QPainterPath:
|
|
229
300
|
"""Define the shape of the atom item for collision detection."""
|
|
230
301
|
scene = self.scene()
|
|
@@ -360,29 +431,6 @@ class AtomItem(QGraphicsItem):
|
|
|
360
431
|
offset_x = -symbol_rect.width() // 2
|
|
361
432
|
text_rect.moveTo(offset_x, -text_rect.height() // 2)
|
|
362
433
|
|
|
363
|
-
# 2. Handle background (fill with white or clear if transparent)
|
|
364
|
-
if self.scene():
|
|
365
|
-
bg_brush = self.scene().backgroundBrush()
|
|
366
|
-
bg_rect = text_rect.adjusted(-5, -8, 5, 8)
|
|
367
|
-
|
|
368
|
-
if bg_brush.style() == Qt.BrushStyle.NoBrush:
|
|
369
|
-
# Use CompositionMode_Clear to erase overlapping bond lines
|
|
370
|
-
painter.save()
|
|
371
|
-
painter.setCompositionMode(
|
|
372
|
-
QPainter.CompositionMode.CompositionMode_Clear
|
|
373
|
-
)
|
|
374
|
-
painter.setBrush(
|
|
375
|
-
QColor(0, 0, 0, 255)
|
|
376
|
-
) # Color doesn't matter (alpha is key)
|
|
377
|
-
painter.setPen(Qt.PenStyle.NoPen)
|
|
378
|
-
painter.drawEllipse(bg_rect)
|
|
379
|
-
painter.restore()
|
|
380
|
-
else:
|
|
381
|
-
# Fill with background color if it exists
|
|
382
|
-
painter.setBrush(bg_brush)
|
|
383
|
-
painter.setPen(Qt.PenStyle.NoPen)
|
|
384
|
-
painter.drawEllipse(bg_rect)
|
|
385
|
-
|
|
386
434
|
# 3. Draw the atom symbol itself
|
|
387
435
|
# Color is already determined above
|
|
388
436
|
painter.setPen(QPen(color))
|
|
@@ -454,7 +502,9 @@ class AtomItem(QGraphicsItem):
|
|
|
454
502
|
for bond in self.bonds:
|
|
455
503
|
if bond.scene():
|
|
456
504
|
bond.update_position()
|
|
457
|
-
|
|
505
|
+
elif change == QGraphicsItem.GraphicsItemChange.ItemSceneHasChanged:
|
|
506
|
+
if self.scene() is not None:
|
|
507
|
+
self.update_style()
|
|
458
508
|
return res
|
|
459
509
|
|
|
460
510
|
def hoverEnterEvent(self, event: Any) -> None:
|
|
@@ -14,7 +14,7 @@ import logging
|
|
|
14
14
|
from typing import TYPE_CHECKING, Optional, Union
|
|
15
15
|
|
|
16
16
|
import numpy as np
|
|
17
|
-
from PyQt6.QtCore import Qt
|
|
17
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
18
18
|
from PyQt6.QtGui import QCloseEvent, QKeyEvent
|
|
19
19
|
from PyQt6.QtWidgets import QDialog, QWidget
|
|
20
20
|
from rdkit import Chem, Geometry
|
|
@@ -114,14 +114,28 @@ class BasePickingDialog(Dialog3DPickingMixin, QDialog):
|
|
|
114
114
|
# If for some reason the cache is incompatible, draw_molecule_3d below will rebuild it
|
|
115
115
|
pass
|
|
116
116
|
|
|
117
|
-
# 3. Redraw
|
|
117
|
+
# 3. Redraw molecule
|
|
118
118
|
self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
|
|
119
119
|
self._molecule_modified = True
|
|
120
120
|
|
|
121
|
-
# 4. Refresh
|
|
121
|
+
# 4. Refresh display (deferred to ensure stability)
|
|
122
|
+
is_dragging = getattr(self, "_slider_dragging", False)
|
|
123
|
+
|
|
124
|
+
if is_dragging and hasattr(self, "show_atom_labels"):
|
|
125
|
+
QTimer.singleShot(200, self.show_atom_labels)
|
|
126
|
+
elif hasattr(self, "update_display"):
|
|
127
|
+
QTimer.singleShot(200, self.update_display)
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
hasattr(self.main_window.view_3d_manager, "plotter")
|
|
131
|
+
and self.main_window.view_3d_manager.plotter
|
|
132
|
+
):
|
|
133
|
+
QTimer.singleShot(200, self.main_window.view_3d_manager.plotter.render)
|
|
134
|
+
|
|
135
|
+
# 5. Refresh chiral/cis-trans labels if applicable
|
|
122
136
|
if hasattr(self.main_window.view_3d_manager, "update_chiral_labels"):
|
|
123
137
|
self.main_window.view_3d_manager.update_chiral_labels()
|
|
124
|
-
else:
|
|
138
|
+
else:
|
|
125
139
|
logging.error(
|
|
126
140
|
"REPORT ERROR: Missing attribute 'update_chiral_labels' on object"
|
|
127
141
|
)
|
|
@@ -131,7 +145,7 @@ class BasePickingDialog(Dialog3DPickingMixin, QDialog):
|
|
|
131
145
|
if hasattr(self.main_window, "state_manager"):
|
|
132
146
|
self.main_window.edit_actions_manager.push_undo_state()
|
|
133
147
|
self._molecule_modified = False
|
|
134
|
-
else:
|
|
148
|
+
else:
|
|
135
149
|
logging.error(
|
|
136
150
|
"REPORT ERROR: Missing attribute 'state_manager' on self.main_window"
|
|
137
151
|
)
|
|
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
|
-
import logging
|
|
14
|
+
import logging
|
|
15
15
|
from typing import Any, Optional, Tuple, Union
|
|
16
16
|
|
|
17
17
|
from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt
|
|
@@ -138,7 +138,48 @@ class BondItem(QGraphicsItem):
|
|
|
138
138
|
# This is robust and efficient.
|
|
139
139
|
p1 = self.atom1.pos()
|
|
140
140
|
p2 = self.atom2.pos()
|
|
141
|
-
|
|
141
|
+
line = QLineF(QPointF(0, 0), p2 - p1)
|
|
142
|
+
|
|
143
|
+
if line.length() == 0:
|
|
144
|
+
return line
|
|
145
|
+
|
|
146
|
+
# Shorten from atom1 side
|
|
147
|
+
t1 = 0.0
|
|
148
|
+
if getattr(self.atom1, "is_visible", True) and hasattr(
|
|
149
|
+
self.atom1, "get_bg_ellipse_path"
|
|
150
|
+
):
|
|
151
|
+
path1 = self.atom1.get_bg_ellipse_path()
|
|
152
|
+
if not path1.isEmpty():
|
|
153
|
+
low, high = 0.0, 1.0
|
|
154
|
+
for _ in range(12):
|
|
155
|
+
mid = (low + high) / 2
|
|
156
|
+
if path1.contains(line.pointAt(mid)):
|
|
157
|
+
low = mid
|
|
158
|
+
else:
|
|
159
|
+
high = mid
|
|
160
|
+
t1 = low
|
|
161
|
+
|
|
162
|
+
# Shorten from atom2 side
|
|
163
|
+
t2 = 1.0
|
|
164
|
+
if getattr(self.atom2, "is_visible", True) and hasattr(
|
|
165
|
+
self.atom2, "get_bg_ellipse_path"
|
|
166
|
+
):
|
|
167
|
+
path2 = self.atom2.get_bg_ellipse_path()
|
|
168
|
+
if not path2.isEmpty():
|
|
169
|
+
line2 = QLineF(QPointF(0, 0), p1 - p2)
|
|
170
|
+
low, high = 0.0, 1.0
|
|
171
|
+
for _ in range(12):
|
|
172
|
+
mid = (low + high) / 2
|
|
173
|
+
if path2.contains(line2.pointAt(mid)):
|
|
174
|
+
low = mid
|
|
175
|
+
else:
|
|
176
|
+
high = mid
|
|
177
|
+
t2 = 1.0 - low
|
|
178
|
+
|
|
179
|
+
if t1 < t2:
|
|
180
|
+
return QLineF(line.pointAt(t1), line.pointAt(t2))
|
|
181
|
+
else:
|
|
182
|
+
return QLineF(line.center(), line.center())
|
|
142
183
|
except (AttributeError, RuntimeError, ValueError, TypeError):
|
|
143
184
|
# Fallback for inconsistent/deleted atom references
|
|
144
185
|
# return zero line to prevent downstream crashes.
|
|
@@ -164,7 +205,7 @@ class BondItem(QGraphicsItem):
|
|
|
164
205
|
)
|
|
165
206
|
bond_offset = scene.get_setting(key, 3.5)
|
|
166
207
|
wedge_width = scene.get_setting("bond_wedge_width_2d", 6.0)
|
|
167
|
-
else:
|
|
208
|
+
else:
|
|
168
209
|
logging.error(
|
|
169
210
|
f"REPORT ERROR: Missing attribute 'get_setting' on scene of type {type(scene)}"
|
|
170
211
|
)
|
|
@@ -184,7 +225,7 @@ class BondItem(QGraphicsItem):
|
|
|
184
225
|
if hasattr(scene, "get_setting"):
|
|
185
226
|
font_size = scene.get_setting("atom_font_size_2d", 20)
|
|
186
227
|
font_family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
|
|
187
|
-
else:
|
|
228
|
+
else:
|
|
188
229
|
logging.error(
|
|
189
230
|
f"REPORT ERROR: Missing attribute 'get_setting' on scene of type {type(scene)}"
|
|
190
231
|
)
|
|
@@ -298,7 +339,7 @@ class BondItem(QGraphicsItem):
|
|
|
298
339
|
pen.setCapStyle(cap_style)
|
|
299
340
|
painter.setPen(pen)
|
|
300
341
|
painter.setBrush(QBrush(bond_color))
|
|
301
|
-
else:
|
|
342
|
+
else:
|
|
302
343
|
logging.error(
|
|
303
344
|
f"REPORT ERROR: Missing attribute 'get_setting' on scene of type {type(scene)}"
|
|
304
345
|
)
|
|
@@ -310,16 +351,48 @@ class BondItem(QGraphicsItem):
|
|
|
310
351
|
wedge_width_half = 6.0
|
|
311
352
|
num_dashes = 8
|
|
312
353
|
|
|
354
|
+
painter.save()
|
|
355
|
+
|
|
313
356
|
# --- Draw Stereochemistry (Wedge/Dash) ---
|
|
314
357
|
if self.order == 1 and self.stereo in [1, 2]:
|
|
315
|
-
|
|
358
|
+
try:
|
|
359
|
+
orig_line = QLineF(QPointF(0, 0), self.atom2.pos() - self.atom1.pos())
|
|
360
|
+
orig_len = orig_line.length()
|
|
361
|
+
if orig_len > 0:
|
|
362
|
+
d1 = QLineF(orig_line.p1(), line.p1()).length()
|
|
363
|
+
t1 = max(d1, 5.0) / orig_len
|
|
364
|
+
d2 = QLineF(orig_line.p1(), line.p2()).length()
|
|
365
|
+
t2 = min(d2, orig_len - 5.0) / orig_len
|
|
366
|
+
if t1 > t2:
|
|
367
|
+
t1, t2 = 0.5, 0.5
|
|
368
|
+
else:
|
|
369
|
+
t1, t2 = 0.0, 1.0
|
|
370
|
+
except (AttributeError, TypeError, ValueError):
|
|
371
|
+
orig_line = line
|
|
372
|
+
orig_len = line.length()
|
|
373
|
+
t1, t2 = 0.0, 1.0
|
|
374
|
+
|
|
375
|
+
vec = orig_line.unitVector()
|
|
316
376
|
normal = vec.normalVector()
|
|
317
|
-
p1 = line.p1() + vec.p2() * 5
|
|
318
|
-
p2 = line.p2() - vec.p2() * 5
|
|
319
377
|
|
|
320
378
|
if self.stereo == 1: # Wedge
|
|
321
|
-
|
|
322
|
-
|
|
379
|
+
p_start = orig_line.pointAt(t1)
|
|
380
|
+
p_end = orig_line.pointAt(t2)
|
|
381
|
+
|
|
382
|
+
width_start = wedge_width_half * t1
|
|
383
|
+
width_end = wedge_width_half * t2
|
|
384
|
+
|
|
385
|
+
offset_start = QPointF(normal.dx(), normal.dy()) * width_start
|
|
386
|
+
offset_end = QPointF(normal.dx(), normal.dy()) * width_end
|
|
387
|
+
|
|
388
|
+
poly = QPolygonF(
|
|
389
|
+
[
|
|
390
|
+
p_start - offset_start,
|
|
391
|
+
p_start + offset_start,
|
|
392
|
+
p_end + offset_end,
|
|
393
|
+
p_end - offset_end,
|
|
394
|
+
]
|
|
395
|
+
)
|
|
323
396
|
painter.drawPolygon(poly)
|
|
324
397
|
|
|
325
398
|
elif self.stereo == 2: # Dash
|
|
@@ -329,13 +402,15 @@ class BondItem(QGraphicsItem):
|
|
|
329
402
|
pen.setWidthF(2.5)
|
|
330
403
|
painter.setPen(pen)
|
|
331
404
|
|
|
332
|
-
#
|
|
405
|
+
# Draw dashes evenly spaced along the original length,
|
|
406
|
+
# but only draw the ones that fall within the visible shortened segment.
|
|
333
407
|
for i in range(num_dashes + 1):
|
|
334
408
|
t = i / num_dashes
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
409
|
+
if t1 <= t <= t2:
|
|
410
|
+
start_pt = orig_line.pointAt(t)
|
|
411
|
+
width = wedge_width_half * t
|
|
412
|
+
offset = QPointF(normal.dx(), normal.dy()) * width
|
|
413
|
+
painter.drawLine(start_pt - offset, start_pt + offset)
|
|
339
414
|
painter.restore()
|
|
340
415
|
|
|
341
416
|
# --- Draw Regular Bonds (Single/Double/Triple) ---
|
|
@@ -355,7 +430,7 @@ class BondItem(QGraphicsItem):
|
|
|
355
430
|
else "bond_spacing_double_2d"
|
|
356
431
|
)
|
|
357
432
|
bond_offset = scene.get_setting(key, 3.5)
|
|
358
|
-
else:
|
|
433
|
+
else:
|
|
359
434
|
logging.error(
|
|
360
435
|
f"REPORT ERROR: Missing attribute 'get_setting' on scene of type {type(scene)}"
|
|
361
436
|
)
|
|
@@ -495,6 +570,8 @@ class BondItem(QGraphicsItem):
|
|
|
495
570
|
# If highlight fails, it's just a visual artifact.
|
|
496
571
|
pass
|
|
497
572
|
|
|
573
|
+
painter.restore()
|
|
574
|
+
|
|
498
575
|
def update_position(self, notify: bool = True) -> None:
|
|
499
576
|
try:
|
|
500
577
|
if notify:
|