MoleditPy-linux 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.
Files changed (78) hide show
  1. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/__init__.py +1 -1
  5. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/main.py +2 -1
  6. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/angle_dialog.py +47 -22
  7. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/app_state.py +7 -9
  8. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/atom_item.py +77 -27
  9. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/base_picking_dialog.py +19 -5
  10. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/bond_item.py +93 -16
  11. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/bond_length_dialog.py +25 -10
  12. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/calculation_worker.py +1 -1
  13. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/color_settings_dialog.py +12 -12
  14. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/compute_logic.py +9 -9
  15. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +4 -4
  16. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/custom_interactor_style.py +6 -6
  17. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/dialog_logic.py +2 -2
  18. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/dihedral_dialog.py +31 -17
  19. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/edit_3d_logic.py +4 -4
  20. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/edit_actions_logic.py +12 -12
  21. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/export_logic.py +9 -2
  22. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +2 -2
  23. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/io_logic.py +6 -6
  24. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/main_window_init.py +7 -7
  25. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +10 -10
  26. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/molecule_scene.py +8 -8
  27. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/move_group_dialog.py +3 -5
  28. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/planarize_dialog.py +1 -0
  29. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/settings_dialog.py +7 -7
  30. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/ui_manager.py +10 -10
  31. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/user_template_dialog.py +3 -3
  32. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/view_3d_logic.py +3 -3
  33. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/LICENSE +0 -0
  34. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/README.md +0 -0
  35. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/setup.cfg +0 -0
  36. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  37. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  38. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  39. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  40. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  41. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/__main__.py +0 -0
  42. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  43. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  44. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  45. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/assets/icon.png +0 -0
  46. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/core/__init__.py +0 -0
  47. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  48. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  49. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  50. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  51. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  52. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  53. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  54. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  55. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  56. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  57. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  58. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  59. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  60. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  61. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  62. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  63. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  64. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  65. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  66. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  67. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  68. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  69. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  70. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  71. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  72. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  73. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  74. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  75. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/utils/constants.py +0 -0
  76. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  77. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  78. {moleditpy_linux-3.3.1 → moleditpy_linux-3.4.0}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 3.3.1
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
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "3.3.1"
8
+ version = "3.4.0"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 3.3.1
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
@@ -19,4 +19,4 @@ try:
19
19
  except ImportError:
20
20
  OBABEL_AVAILABLE = False
21
21
 
22
- from .utils.constants import VERSION as __version__
22
+ from .utils.constants import VERSION as __version__ # noqa: F401
@@ -68,7 +68,8 @@ def main() -> None:
68
68
  ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
69
69
 
70
70
  parser = argparse.ArgumentParser(
71
- prog="moleditpy", description="MoleditPy molecular editor"
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
- # Take a fresh snapshot immediately upon completing the triad selection
193
- self._snapshot_positions = self.mol.GetConformer().GetPositions().copy()
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 = ["1st", "2nd (vertex)", "3rd"]
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(vertex)")
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 with current angle
301
+ # Update angle input box and slider
299
302
  try:
300
- self.angle_input.blockSignals(True)
301
- self.angle_input.setText(f"{current_angle:.2f}")
302
- self.angle_input.blockSignals(False)
303
- self.angle_slider.blockSignals(True)
304
- slider_val = int(round(current_angle))
305
- slider_val = max(-180, min(180, slider_val))
306
- self.angle_slider.setValue(slider_val)
307
- self.angle_slider.setEnabled(True)
308
- self.angle_slider.blockSignals(False)
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(vertex)")
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 snapshot if available (slider dragging) to keep the rotation axis stable
370
- snapshot = self._snapshot_positions
371
- if snapshot is not None:
372
- positions = snapshot.copy()
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_linux.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
- current_angle = self.calculate_angle()
385
- half_delta_deg = (new_angle_deg - current_angle) / 2.0
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
- current_angle + half_delta_deg,
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, TYPE_CHECKING
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_linux.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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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 # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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 chiral/cis-trans labels if applicable
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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 # [REPORT ERROR MISSING ATTRIBUTE]
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
- return QLineF(QPointF(0, 0), p2 - p1)
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: # [REPORT ERROR MISSING ATTRIBUTE]
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
- vec = line.unitVector()
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
- offset = QPointF(normal.dx(), normal.dy()) * wedge_width_half
322
- poly = QPolygonF([p1, p2 + offset, p2 - offset])
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
- # Use configured number of dashes (default 8)
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
- start_pt = p1 * (1 - t) + p2 * t
336
- width = (wedge_width_half * 2.0) * t
337
- offset = QPointF(normal.dx(), normal.dy()) * width / 2.0
338
- painter.drawLine(start_pt - offset, start_pt + offset)
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: # [REPORT ERROR MISSING ATTRIBUTE]
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: