MoleditPy 3.4.1__tar.gz → 3.5.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-3.4.1 → moleditpy-3.5.0}/PKG-INFO +1 -1
  2. {moleditpy-3.4.1 → moleditpy-3.5.0}/pyproject.toml +1 -1
  3. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/MoleditPy.egg-info/PKG-INFO +1 -1
  4. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/bond_item.py +42 -1
  5. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/export_logic.py +0 -22
  6. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/molecular_scene_handler.py +121 -39
  7. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/molecule_scene.py +33 -11
  8. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +60 -3
  9. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/utils/default_settings.py +3 -0
  10. {moleditpy-3.4.1 → moleditpy-3.5.0}/LICENSE +0 -0
  11. {moleditpy-3.4.1 → moleditpy-3.5.0}/README.md +0 -0
  12. {moleditpy-3.4.1 → moleditpy-3.5.0}/setup.cfg +0 -0
  13. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
  14. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  15. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  16. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/MoleditPy.egg-info/requires.txt +0 -0
  17. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
  18. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/__init__.py +0 -0
  19. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/__main__.py +0 -0
  20. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/assets/file_icon.ico +0 -0
  21. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/assets/icon.icns +0 -0
  22. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/assets/icon.ico +0 -0
  23. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/assets/icon.png +0 -0
  24. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/core/__init__.py +0 -0
  25. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/core/mol_geometry.py +0 -0
  26. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/core/molecular_data.py +0 -0
  27. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/main.py +0 -0
  28. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/plugins/__init__.py +0 -0
  29. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/plugins/plugin_interface.py +0 -0
  30. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/plugins/plugin_manager.py +0 -0
  31. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
  32. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/__init__.py +0 -0
  33. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/about_dialog.py +0 -0
  34. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/align_plane_dialog.py +0 -0
  35. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/alignment_dialog.py +0 -0
  36. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/analysis_window.py +0 -0
  37. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/angle_dialog.py +0 -0
  38. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/app_state.py +0 -0
  39. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/atom_item.py +0 -0
  40. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/atom_picking.py +0 -0
  41. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/base_picking_dialog.py +0 -0
  42. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/bond_length_dialog.py +0 -0
  43. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/calculation_worker.py +0 -0
  44. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/color_settings_dialog.py +0 -0
  45. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/compute_logic.py +0 -0
  46. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/constrained_optimization_dialog.py +0 -0
  47. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/custom_interactor_style.py +0 -0
  48. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
  49. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
  50. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/dialog_logic.py +0 -0
  51. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/dihedral_dialog.py +0 -0
  52. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/edit_3d_logic.py +0 -0
  53. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/edit_actions_logic.py +0 -0
  54. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/geometry_base_dialog.py +0 -0
  55. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/io_logic.py +0 -0
  56. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/main_window.py +0 -0
  57. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/main_window_init.py +0 -0
  58. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/mirror_dialog.py +0 -0
  59. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/move_group_dialog.py +0 -0
  60. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
  61. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/planarize_dialog.py +0 -0
  62. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/settings_dialog.py +0 -0
  63. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
  64. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
  65. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -0
  66. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -0
  67. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/string_importers.py +0 -0
  68. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/template_preview_item.py +0 -0
  69. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/template_preview_view.py +0 -0
  70. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/translation_dialog.py +0 -0
  71. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/ui_manager.py +0 -0
  72. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/user_template_dialog.py +0 -0
  73. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/view_3d_logic.py +0 -0
  74. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/ui/zoomable_view.py +0 -0
  75. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/utils/__init__.py +0 -0
  76. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/utils/constants.py +0 -0
  77. {moleditpy-3.4.1 → moleditpy-3.5.0}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
  78. {moleditpy-3.4.1 → moleditpy-3.5.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.4.1
3
+ Version: 3.5.0
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy"
7
7
 
8
- version = "3.4.1"
8
+ version = "3.5.0"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 3.4.1
3
+ Version: 3.5.0
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -11,6 +11,7 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  from __future__ import annotations
14
+ import math
14
15
  import logging
15
16
  from typing import Any, Optional, Tuple, Union
16
17
 
@@ -109,6 +110,44 @@ class BondItem(QGraphicsItem):
109
110
  self.prepareGeometryChange()
110
111
  self.update()
111
112
 
113
+ @staticmethod
114
+ def _ring_inner_double_bond_shorten_factor(
115
+ line: QLineF, local_ring_center: QPointF
116
+ ) -> float:
117
+ """Shorten inner ring double bonds based on the ring angle at both atoms."""
118
+
119
+ def angle_between(v1: QPointF, v2: QPointF) -> Optional[float]:
120
+ len1 = math.hypot(v1.x(), v1.y())
121
+ len2 = math.hypot(v2.x(), v2.y())
122
+ if len1 <= 1e-6 or len2 <= 1e-6:
123
+ return None
124
+ dot = v1.x() * v2.x() + v1.y() * v2.y()
125
+ cos_angle = max(-1.0, min(1.0, dot / (len1 * len2)))
126
+ return math.acos(cos_angle)
127
+
128
+ p1 = line.p1()
129
+ p2 = line.p2()
130
+ half_angles = [
131
+ angle_between(p2 - p1, local_ring_center - p1),
132
+ angle_between(p1 - p2, local_ring_center - p2),
133
+ ]
134
+ interior_angles = [2 * a for a in half_angles if a is not None]
135
+ if not interior_angles:
136
+ return 0.8
137
+
138
+ ring_angle = sum(interior_angles) / len(interior_angles)
139
+ benzene_angle = 2 * math.pi / 3
140
+ cyclopropene_angle = math.pi / 3
141
+
142
+ if ring_angle <= benzene_angle:
143
+ angle_range = benzene_angle - cyclopropene_angle
144
+ sharpness = min(1.0, max(0.0, (benzene_angle - ring_angle) / angle_range))
145
+ return max(0.55, 0.8 - 0.25 * sharpness)
146
+
147
+ wide_angle_range = math.pi - benzene_angle
148
+ openness = min(1.0, max(0.0, (ring_angle - benzene_angle) / wide_angle_range))
149
+ return min(0.9, 0.8 + 0.1 * openness)
150
+
112
151
  def __init__(
113
152
  self, atom1_item: Any, atom2_item: Any, order: int = 1, stereo: int = 0
114
153
  ) -> None:
@@ -473,7 +512,9 @@ class BondItem(QGraphicsItem):
473
512
 
474
513
  # Draw short inner line (80% length)
475
514
  inner_line = line.translated(inner_offset)
476
- shorten_factor = 0.8
515
+ shorten_factor = self._ring_inner_double_bond_shorten_factor(
516
+ line, local_ring_center
517
+ )
477
518
  p1 = inner_line.p1()
478
519
  p2 = inner_line.p2()
479
520
  center = QPointF((p1.x() + p2.x()) / 2, (p1.y() + p2.y()) / 2)
@@ -79,12 +79,6 @@ class ExportManager:
79
79
  return basename
80
80
 
81
81
  def export_stl(self) -> None:
82
- if not self.host.view_3d_manager.current_mol:
83
- self.host.statusBar().showMessage(
84
- "Error: Please generate a 3D structure first."
85
- )
86
- return
87
-
88
82
  default_path = self._get_default_path()
89
83
 
90
84
  file_path, _ = QFileDialog.getSaveFileName(
@@ -113,12 +107,6 @@ class ExportManager:
113
107
 
114
108
  def export_obj_mtl(self) -> None:
115
109
  """Export as OBJ/MTL (with colors)."""
116
- if not self.host.view_3d_manager.current_mol:
117
- self.host.statusBar().showMessage(
118
- "Error: Please generate a 3D structure first."
119
- )
120
- return
121
-
122
110
  default_path = self._get_default_path()
123
111
 
124
112
  file_path, _ = QFileDialog.getSaveFileName(
@@ -257,12 +245,6 @@ class ExportManager:
257
245
 
258
246
  def export_color_stl(self) -> None:
259
247
  """Export as Color STL."""
260
- if not self.host.view_3d_manager.current_mol:
261
- self.host.statusBar().showMessage(
262
- "Error: Please generate a 3D structure first."
263
- )
264
- return
265
-
266
248
  default_path = self._get_default_path()
267
249
 
268
250
  file_path, _ = QFileDialog.getSaveFileName(
@@ -954,10 +936,6 @@ class ExportManager:
954
936
 
955
937
  def export_3d_png(self) -> None:
956
938
  """Export 3D view as PNG."""
957
- if not self.host.view_3d_manager.current_mol:
958
- self.host.statusBar().showMessage("No 3D molecule to export.", 2000)
959
- return
960
-
961
939
  # Default filename: {name}.png
962
940
  default_path = self._get_default_path()
963
941
 
@@ -236,7 +236,7 @@ class TemplateMixin:
236
236
  if i < num_points and j < num_points
237
237
  ]
238
238
  avg_len = (sum(ref_lengths) / len(ref_lengths)) if ref_lengths else 20.0
239
- map_threshold = max(0.5 * avg_len, 8.0)
239
+ click_map_threshold = max(0.5 * avg_len, 8.0)
240
240
 
241
241
  for ex_item in existing_items:
242
242
  try:
@@ -248,7 +248,7 @@ class TemplateMixin:
248
248
  d = dist_pts(p, ex_pos)
249
249
  if best_d is None or d < best_d:
250
250
  best_d, best_idx = d, i
251
- if best_idx != -1 and best_d <= max(map_threshold, 1.5 * avg_len):
251
+ if best_idx != -1 and best_d <= max(click_map_threshold, 1.5 * avg_len):
252
252
  atom_items[best_idx] = ex_item
253
253
  used_indices.add(best_idx)
254
254
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
@@ -258,27 +258,29 @@ class TemplateMixin:
258
258
 
259
259
  # --- 2) Enumerate existing atoms in the scene from self.data.atoms and map them ---
260
260
  mapped_atoms = {it for it in atom_items if it is not None}
261
- for i, p in enumerate(points):
262
- if atom_items[i] is not None:
263
- continue
261
+ if self.get_setting("atom_fusing_enabled_2d", True):
262
+ map_threshold = self.get_setting("atom_fusing_distance_2d", 14.0)
263
+ for i, p in enumerate(points):
264
+ if atom_items[i] is not None:
265
+ continue
264
266
 
265
- nearby = None
266
- best_d = float("inf")
267
+ nearby = None
268
+ best_d = float("inf")
267
269
 
268
- for atom_data in self.data.atoms.values():
269
- a_item = atom_data.get("item")
270
- if not a_item or a_item in mapped_atoms:
271
- continue
272
- try:
273
- d = dist_pts(p, a_item.pos())
274
- except (AttributeError, RuntimeError, ValueError, TypeError):
275
- continue
276
- if d < best_d:
277
- best_d, nearby = d, a_item
270
+ for atom_data in self.data.atoms.values():
271
+ a_item = atom_data.get("item")
272
+ if not a_item or a_item in mapped_atoms:
273
+ continue
274
+ try:
275
+ d = dist_pts(p, a_item.pos())
276
+ except (AttributeError, RuntimeError, ValueError, TypeError):
277
+ continue
278
+ if d < best_d:
279
+ best_d, nearby = d, a_item
278
280
 
279
- if nearby and best_d <= map_threshold:
280
- atom_items[i] = nearby
281
- mapped_atoms.add(nearby)
281
+ if nearby and best_d <= map_threshold:
282
+ atom_items[i] = nearby
283
+ mapped_atoms.add(nearby)
282
284
 
283
285
  # --- 3) Create missing vertices ---
284
286
  for i, p in enumerate(points):
@@ -369,12 +371,16 @@ class TemplateMixin:
369
371
  except ValueError:
370
372
  return
371
373
 
372
- items_under = self.items(pos) # top-most first
373
374
  item = None
374
- for it in items_under:
375
- if isinstance(it, (AtomItem, BondItem)):
376
- item = it
377
- break
375
+ if pos:
376
+ snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
377
+ item = self.find_atom_near(pos, tol=snap_dist)
378
+ if item is None:
379
+ items_under = self.items(pos) # top-most first
380
+ for it in items_under:
381
+ if isinstance(it, (AtomItem, BondItem)):
382
+ item = it
383
+ break
378
384
 
379
385
  points, bonds_info = [], []
380
386
  l = DEFAULT_BOND_LENGTH
@@ -566,12 +572,16 @@ class TemplateMixin:
566
572
  return
567
573
 
568
574
  # Find attachment point (first atom or clicked item)
569
- items_under = self.items(pos)
570
575
  attachment_atom = None
571
- for item in items_under:
572
- if isinstance(item, AtomItem):
573
- attachment_atom = item
574
- break
576
+ if pos:
577
+ snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
578
+ attachment_atom = self.find_atom_near(pos, tol=snap_dist)
579
+ if attachment_atom is None:
580
+ items_under = self.items(pos)
581
+ for item in items_under:
582
+ if isinstance(item, AtomItem):
583
+ attachment_atom = item
584
+ break
575
585
 
576
586
  # Calculate template positions
577
587
  points = []
@@ -722,9 +732,18 @@ class KeyboardMixin:
722
732
  def keyPressEvent(self, event: Any) -> None:
723
733
  view = self.views()[0]
724
734
  cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
725
- item_at_cursor = self.itemAt(cursor_pos, view.transform())
735
+ transform = view.transform()
726
736
  key = event.key()
727
737
  modifiers = event.modifiers()
738
+ item_at_cursor = None
739
+ if key == Qt.Key.Key_4:
740
+ snap_dist = self.get_setting("template_snapping_distance_2d", 14.0)
741
+ item_at_cursor = self.find_atom_near(cursor_pos, tol=snap_dist)
742
+ elif self.get_setting("atom_fusing_enabled_2d", True):
743
+ fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
744
+ item_at_cursor = self.find_atom_near(cursor_pos, tol=fuse_dist)
745
+ if item_at_cursor is None:
746
+ item_at_cursor = self.itemAt(cursor_pos, transform)
728
747
 
729
748
  if not self.window.ui_manager.is_2d_editable:
730
749
  return
@@ -741,13 +760,69 @@ class KeyboardMixin:
741
760
  if isinstance(item_at_cursor, AtomItem):
742
761
  p0 = item_at_cursor.pos()
743
762
  l = DEFAULT_BOND_LENGTH
744
- direction = QLineF(p0, cursor_pos).unitVector()
745
- p1 = (
746
- p0 + direction.p2() * l
747
- if direction.length() > 0
748
- else p0 + QPointF(l, 0)
749
- )
750
- points = self._calculate_polygon_from_edge(p0, p1, n)
763
+
764
+ # Check if this is a terminal atom (exactly 1 neighbor)
765
+ neighbor_positions = []
766
+ if hasattr(item_at_cursor, "bonds") and item_at_cursor.bonds:
767
+ for b in item_at_cursor.bonds:
768
+ if not sip_isdeleted_safe(b):
769
+ other = (
770
+ b.atom1
771
+ if b.atom2 is item_at_cursor
772
+ else b.atom2
773
+ )
774
+ if (
775
+ other
776
+ and not sip_isdeleted_safe(other)
777
+ and hasattr(other, "pos")
778
+ ):
779
+ try:
780
+ neighbor_positions.append(other.pos())
781
+ except RuntimeError:
782
+ continue
783
+
784
+ if len(neighbor_positions) == 1:
785
+ v_to_neighbor = neighbor_positions[0] - p0
786
+ angle_to_neighbor = math.atan2(
787
+ v_to_neighbor.y(), v_to_neighbor.x()
788
+ )
789
+ angle_plus = angle_to_neighbor + math.radians(120)
790
+ angle_minus = angle_to_neighbor - math.radians(120)
791
+ angle_cursor = math.atan2(
792
+ cursor_pos.y() - p0.y(), cursor_pos.x() - p0.x()
793
+ )
794
+ diff_plus = abs(
795
+ math.atan2(
796
+ math.sin(angle_cursor - angle_plus),
797
+ math.cos(angle_cursor - angle_plus),
798
+ )
799
+ )
800
+ diff_minus = abs(
801
+ math.atan2(
802
+ math.sin(angle_cursor - angle_minus),
803
+ math.cos(angle_cursor - angle_minus),
804
+ )
805
+ )
806
+ best_angle = (
807
+ angle_plus if diff_plus < diff_minus else angle_minus
808
+ )
809
+ p1 = p0 + QPointF(
810
+ l * math.cos(best_angle), l * math.sin(best_angle)
811
+ )
812
+ bend_dir = p0 - v_to_neighbor
813
+ points = self._calculate_polygon_from_edge(
814
+ p0, p1, n, cursor_pos=bend_dir
815
+ )
816
+ else:
817
+ direction = QLineF(p0, cursor_pos).unitVector()
818
+ p1 = (
819
+ p0 + direction.p2() * l
820
+ if direction.length() > 0
821
+ else p0 + QPointF(l, 0)
822
+ )
823
+ points = self._calculate_polygon_from_edge(
824
+ p0, p1, n, cursor_pos=cursor_pos
825
+ )
751
826
  existing_items = [item_at_cursor]
752
827
 
753
828
  elif isinstance(item_at_cursor, BondItem):
@@ -1008,7 +1083,12 @@ class KeyboardMixin:
1008
1083
  target_pos = start_pos + new_pos_offset
1009
1084
 
1010
1085
  # Find nearby atom
1011
- near_atom = self.find_atom_near(target_pos, tol=SNAP_DISTANCE)
1086
+ near_atom = None
1087
+ if self.get_setting("atom_fusing_enabled_2d", True):
1088
+ fuse_dist = self.get_setting(
1089
+ "atom_fusing_distance_2d", SNAP_DISTANCE
1090
+ )
1091
+ near_atom = self.find_atom_near(target_pos, tol=fuse_dist)
1012
1092
 
1013
1093
  if near_atom and near_atom is not start_atom:
1014
1094
  # Bond if exists
@@ -1377,6 +1457,8 @@ class SceneQueryMixin:
1377
1457
  return False
1378
1458
 
1379
1459
  def find_atom_near(self, pos: Any, tol: float = 14.0) -> Any:
1460
+ if pos is None:
1461
+ return None
1380
1462
  # Create a small search rectangle around the position
1381
1463
  search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
1382
1464
  nearby_items = self.items(search_rect)
@@ -107,7 +107,8 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
107
107
  and self.window
108
108
  and hasattr(self.window, "init_manager")
109
109
  ):
110
- return self.window.init_manager.settings.get(key, default)
110
+ settings = self.window.init_manager.settings
111
+ return settings.get(key, default)
111
112
  return default
112
113
 
113
114
  def update_connected_bonds(self, atoms: List[AtomItem]) -> None:
@@ -210,7 +211,7 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
210
211
  if isinstance(it, (AtomItem, BondItem))
211
212
  and not sip_isdeleted_safe(it)
212
213
  ]
213
- except Exception as e:
214
+ except (AttributeError, RuntimeError, TypeError, ValueError) as e:
214
215
  # Fallback to empty selection if the scene state is inconsistent during event processing
215
216
  logging.debug(
216
217
  f"Failed to retrieve selected items in mousePressEvent: {e}"
@@ -304,9 +305,16 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
304
305
  self.clearSelection()
305
306
  event.accept()
306
307
 
307
- item: Optional[QGraphicsItem] = self.itemAt(
308
- self.press_pos, self.views()[0].transform()
309
- )
308
+ item = None
309
+ if (
310
+ self.mode.startswith("bond")
311
+ and self.get_setting("atom_fusing_enabled_2d", True)
312
+ and self.press_pos
313
+ ):
314
+ fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
315
+ item = self.find_atom_near(self.press_pos, tol=fuse_dist)
316
+ if item is None:
317
+ item = self.itemAt(self.press_pos, self.views()[0].transform())
310
318
 
311
319
  if isinstance(item, AtomItem):
312
320
  self.start_atom = item
@@ -352,10 +360,14 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
352
360
  end_point = current_pos
353
361
 
354
362
  target_atom = None
355
- for item in self.items(current_pos):
356
- if isinstance(item, AtomItem):
357
- target_atom = item
358
- break
363
+ if self.get_setting("atom_fusing_enabled_2d", True) and current_pos:
364
+ fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
365
+ target_atom = self.find_atom_near(current_pos, tol=fuse_dist)
366
+ else:
367
+ for item in self.items(current_pos):
368
+ if isinstance(item, AtomItem):
369
+ target_atom = item
370
+ break
359
371
 
360
372
  is_valid_snap_target = target_atom is not None and (
361
373
  self.start_atom is None or target_atom is not self.start_atom
@@ -544,7 +556,12 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
544
556
  self.mode.startswith("atom") or self.mode.startswith("bond")
545
557
  ):
546
558
  line = QLineF(self.start_atom.pos(), end_pos)
547
- end_item = self.itemAt(end_pos, self.views()[0].transform())
559
+ end_item = None
560
+ if self.get_setting("atom_fusing_enabled_2d", True) and end_pos:
561
+ fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
562
+ end_item = self.find_atom_near(end_pos, tol=fuse_dist)
563
+ if end_item is None:
564
+ end_item = self.itemAt(end_pos, self.views()[0].transform())
548
565
  # Determine bond style to use
549
566
  # In atom modes, set bond_order/stereo to None so create_bond uses defaults (1, 0)
550
567
  # In bond_* modes, use current settings (self.bond_order/stereo)
@@ -593,7 +610,12 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
593
610
  self.create_atom(self.current_atom_symbol, end_pos)
594
611
  self.data_changed_in_event = True
595
612
  else:
596
- end_item = self.itemAt(end_pos, self.views()[0].transform())
613
+ end_item = None
614
+ if self.get_setting("atom_fusing_enabled_2d", True) and end_pos:
615
+ fuse_dist = self.get_setting("atom_fusing_distance_2d", 14.0)
616
+ end_item = self.find_atom_near(end_pos, tol=fuse_dist)
617
+ if end_item is None:
618
+ end_item = self.itemAt(end_pos, self.views()[0].transform())
597
619
  if isinstance(end_item, AtomItem):
598
620
  start_id = self.create_atom(
599
621
  self.current_atom_symbol, self.start_pos
@@ -15,6 +15,7 @@ from typing import Any, Optional
15
15
 
16
16
  from PyQt6.QtGui import QColor, QFont
17
17
  from PyQt6.QtWidgets import (
18
+ QCheckBox,
18
19
  QColorDialog,
19
20
  QComboBox,
20
21
  QFontComboBox,
@@ -135,16 +136,56 @@ class Settings2DTab(SettingsTabBase):
135
136
  ),
136
137
  )
137
138
 
138
- from PyQt6.QtWidgets import QCheckBox
139
-
140
139
  self.atom_use_bond_color_2d_checkbox = QCheckBox()
141
140
  self.atom_use_bond_color_2d_checkbox.setToolTip(
142
- "If checked, atoms will use the unified Bond Color instead of element-specific colors (CPK)."
141
+ "If checked, atoms will use the unified Bond Color "
142
+ "instead of element-specific colors (CPK)."
143
143
  )
144
144
  form_layout.addRow(
145
145
  "Use Bond Color for Atoms:", self.atom_use_bond_color_2d_checkbox
146
146
  )
147
147
 
148
+ form_layout.addRow(self._create_separator())
149
+
150
+ # --- Atom Fusing Settings ---
151
+ form_layout.addRow(QLabel("<b>Atom Fusing Settings</b>"))
152
+
153
+ self.atom_fusing_enabled_2d_checkbox = QCheckBox()
154
+ self.atom_fusing_enabled_2d_checkbox.setToolTip(
155
+ "If checked, drawing or placing templates near an existing atom "
156
+ "will connect to it rather than creating a new one."
157
+ )
158
+ form_layout.addRow("Enable Atom Fusing:", self.atom_fusing_enabled_2d_checkbox)
159
+
160
+ # Fusing Distance
161
+ self.atom_fusing_distance_2d_slider, self.atom_fusing_distance_2d_label = (
162
+ self._create_slider(5, 50, 1.0, is_int=True)
163
+ )
164
+ form_layout.addRow(
165
+ "Fusing Distance (px):",
166
+ self._wrap_layout(
167
+ self.atom_fusing_distance_2d_slider, self.atom_fusing_distance_2d_label
168
+ ),
169
+ )
170
+
171
+ form_layout.addRow(self._create_separator())
172
+
173
+ # --- Template Snapping Settings ---
174
+ form_layout.addRow(QLabel("<b>Template Snapping Settings</b>"))
175
+
176
+ # Template Snapping Distance
177
+ (
178
+ self.template_snapping_distance_2d_slider,
179
+ self.template_snapping_distance_2d_label,
180
+ ) = self._create_slider(5, 50, 1.0, is_int=True)
181
+ form_layout.addRow(
182
+ "Snapping Distance (px):",
183
+ self._wrap_layout(
184
+ self.template_snapping_distance_2d_slider,
185
+ self.template_snapping_distance_2d_label,
186
+ ),
187
+ )
188
+
148
189
  def _pick_bg_color_2d(self) -> None:
149
190
  color = QColorDialog.getColor(
150
191
  QColor(self.current_bg_color_2d), self, "Select 2D Background Color"
@@ -209,6 +250,15 @@ class Settings2DTab(SettingsTabBase):
209
250
  self.atom_use_bond_color_2d_checkbox.setChecked(
210
251
  settings_dict.get("atom_use_bond_color_2d", False)
211
252
  )
253
+ self.atom_fusing_enabled_2d_checkbox.setChecked(
254
+ settings_dict.get("atom_fusing_enabled_2d", True)
255
+ )
256
+ self.atom_fusing_distance_2d_slider.setValue(
257
+ int(settings_dict.get("atom_fusing_distance_2d", 14.0))
258
+ )
259
+ self.template_snapping_distance_2d_slider.setValue(
260
+ int(settings_dict.get("template_snapping_distance_2d", 14.0))
261
+ )
212
262
 
213
263
  def get_settings(self) -> dict[str, Any]:
214
264
  return {
@@ -223,4 +273,11 @@ class Settings2DTab(SettingsTabBase):
223
273
  "atom_font_family_2d": self.atom_font_family_2d_combo.currentFont().family(),
224
274
  "atom_font_size_2d": self.atom_font_size_2d_slider.value(),
225
275
  "atom_use_bond_color_2d": self.atom_use_bond_color_2d_checkbox.isChecked(),
276
+ "atom_fusing_enabled_2d": self.atom_fusing_enabled_2d_checkbox.isChecked(),
277
+ "atom_fusing_distance_2d": float(
278
+ self.atom_fusing_distance_2d_slider.value()
279
+ ),
280
+ "template_snapping_distance_2d": float(
281
+ self.template_snapping_distance_2d_slider.value()
282
+ ),
226
283
  }
@@ -76,6 +76,9 @@ DEFAULT_SETTINGS = {
76
76
  "bond_wedge_width_2d": 6.0,
77
77
  "bond_dash_count_2d": 8,
78
78
  "atom_font_family_2d": "Arial",
79
+ "atom_fusing_enabled_2d": True,
80
+ "atom_fusing_distance_2d": 14.0,
81
+ "template_snapping_distance_2d": 14.0,
79
82
  # --- Application Session / UI State ---
80
83
  "theme": "light",
81
84
  "window_size": [1200, 800],
File without changes
File without changes
File without changes