MoleditPy-linux 4.7.2__tar.gz → 4.8.1__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 (85) hide show
  1. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  5. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/plugins/plugin_interface.py +12 -2
  6. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/plugins/plugin_manager.py +14 -2
  7. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/about_dialog.py +4 -2
  8. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/dialog_logic.py +2 -20
  9. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/edit_actions_logic.py +4 -2
  10. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/molecular_scene_handler.py +177 -19
  11. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/plugin_menu_manager.py +231 -60
  12. moleditpy_linux-4.8.1/src/moleditpy_linux/ui/preview_molecule.py +204 -0
  13. moleditpy_linux-4.8.1/src/moleditpy_linux/ui/template_preview_item.py +422 -0
  14. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/template_preview_view.py +2 -2
  15. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/ui_manager.py +18 -27
  16. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/user_template_dialog.py +70 -198
  17. moleditpy_linux-4.7.2/src/moleditpy_linux/ui/template_preview_item.py +0 -251
  18. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/LICENSE +0 -0
  19. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/README.md +0 -0
  20. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/setup.cfg +0 -0
  21. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  22. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  23. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  24. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  25. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/__init__.py +0 -0
  26. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/__main__.py +0 -0
  27. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  28. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/assets/icon.icns +0 -0
  29. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/assets/icon.ico +0 -0
  30. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/assets/icon.png +0 -0
  31. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/core/__init__.py +0 -0
  32. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  33. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/core/molecular_data.py +0 -0
  34. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/main.py +0 -0
  35. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/plugins/__init__.py +0 -0
  36. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  37. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/__init__.py +0 -0
  38. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  39. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  40. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  41. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  42. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/app_state.py +0 -0
  43. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/atom_item.py +0 -0
  44. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  45. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  46. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/bond_item.py +0 -0
  47. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  48. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  49. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  50. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  51. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  52. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  53. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  54. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  55. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  56. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  57. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  58. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/export_logic.py +0 -0
  59. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  60. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/io_logic.py +0 -0
  61. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/main_window.py +0 -0
  62. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  63. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  64. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  65. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  66. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  67. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  68. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  69. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  70. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  71. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  72. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  73. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  74. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  75. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/string_importers.py +0 -0
  76. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  77. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  79. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/utils/__init__.py +0 -0
  80. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/utils/constants.py +0 -0
  81. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/utils/default_settings.py +0 -0
  82. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  83. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  84. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  85. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.1}/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: 4.7.2
3
+ Version: 4.8.1
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 = "4.7.2"
8
+ version = "4.8.1"
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: 4.7.2
3
+ Version: 4.8.1
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
@@ -58,6 +58,7 @@ src/moleditpy_linux/ui/move_selected_atoms_dialog.py
58
58
  src/moleditpy_linux/ui/periodic_table_dialog.py
59
59
  src/moleditpy_linux/ui/planarize_dialog.py
60
60
  src/moleditpy_linux/ui/plugin_menu_manager.py
61
+ src/moleditpy_linux/ui/preview_molecule.py
61
62
  src/moleditpy_linux/ui/settings_dialog.py
62
63
  src/moleditpy_linux/ui/string_importers.py
63
64
  src/moleditpy_linux/ui/template_preview_item.py
@@ -31,6 +31,7 @@ class PluginContext:
31
31
  text: Optional[str] = None,
32
32
  icon: Optional[str] = None,
33
33
  shortcut: Optional[str] = None,
34
+ pin: Optional[str] = None,
34
35
  ) -> None:
35
36
  """
36
37
  Register a menu action.
@@ -41,9 +42,16 @@ class PluginContext:
41
42
  text: Label for the action (defaults to last part of path if None).
42
43
  icon: Path to icon or icon name (optional).
43
44
  shortcut: Keyboard shortcut (optional).
45
+ pin: Placement request. ``"header"`` puts a direct "Plugin/<entry>"
46
+ registration next to "Plugin Manager..." above the first
47
+ divider, for plugins that manage the plugin system itself.
48
+ Anything else is ignored, so unknown values stay forward
49
+ compatible. Added in 4.8.1 — passing it to an older MoleditPy
50
+ raises TypeError, so guard the call if your plugin supports
51
+ both (see PLUGIN_DEVELOPMENT_MANUAL_V4.md).
44
52
  """
45
53
  self._manager.register_menu_action(
46
- self._plugin_name, path, callback, text, icon, shortcut
54
+ self._plugin_name, path, callback, text, icon, shortcut, pin
47
55
  )
48
56
 
49
57
  def register_menu_action(
@@ -72,6 +80,7 @@ class PluginContext:
72
80
  text: Optional[str] = None,
73
81
  icon: Optional[str] = None,
74
82
  shortcut: Optional[str] = None,
83
+ pin: Optional[str] = None,
75
84
  ) -> None:
76
85
  """
77
86
  Register an action nested inside the Plugin menu.
@@ -87,9 +96,10 @@ class PluginContext:
87
96
  text: Label override (defaults to last part of path).
88
97
  icon: Path to icon (optional).
89
98
  shortcut: Keyboard shortcut (optional).
99
+ pin: Placement request; see add_menu_action.
90
100
  """
91
101
  full_path = f"Plugin/{path.lstrip('/')}"
92
- self.add_menu_action(full_path, callback, text, icon, shortcut)
102
+ self.add_menu_action(full_path, callback, text, icon, shortcut, pin)
93
103
 
94
104
  def add_toolbar_action(
95
105
  self,
@@ -358,7 +358,13 @@ class PluginManager:
358
358
  plugin_desc = getattr(
359
359
  module, "PLUGIN_DESCRIPTION", getattr(module, "__doc__", "")
360
360
  )
361
- plugin_category = getattr(module, "PLUGIN_CATEGORY", category)
361
+ # The folder the user filed the plugin under wins: that
362
+ # placement is a deliberate, user-controlled ordering. A
363
+ # plugin's own PLUGIN_CATEGORY only applies when the file sits
364
+ # at the plugins root, where there is no folder to honour.
365
+ plugin_category = category or str(
366
+ getattr(module, "PLUGIN_CATEGORY", "") or ""
367
+ )
362
368
 
363
369
  # Additional cleanup for docstring (strip whitespace)
364
370
  if plugin_desc is None:
@@ -445,8 +451,13 @@ class PluginManager:
445
451
  text: str,
446
452
  icon: str,
447
453
  shortcut: str,
454
+ pin: Optional[str] = None,
448
455
  ) -> None:
449
- """Register a plugin menu action with its path, callback, and display metadata."""
456
+ """Register a plugin menu action with its path, callback, and display metadata.
457
+
458
+ ``pin`` is optional so a V3-era caller reaching this method directly
459
+ keeps working.
460
+ """
450
461
  self.menu_actions.append(
451
462
  {
452
463
  "plugin": plugin_name,
@@ -455,6 +466,7 @@ class PluginManager:
455
466
  "text": text,
456
467
  "icon": icon,
457
468
  "shortcut": shortcut,
469
+ "pin": pin,
458
470
  }
459
471
  )
460
472
 
@@ -100,15 +100,16 @@ class AboutDialog(QDialog):
100
100
  layout.addLayout(button_layout)
101
101
 
102
102
  def image_clicked(self, event: QMouseEvent) -> None:
103
- """Easter egg: Clear all and load bipyrimidine from SMILES"""
103
+ """Clear all, load bipyrimidine from SMILES, and build it in 3D."""
104
104
  # Clear the current scene
105
105
  self.main_window.edit_actions_manager.clear_all()
106
106
 
107
107
  bipyrimidine_smiles = "C1=CN=C(N=C1)C2=NC=CC=N2"
108
108
  self.main_window.string_importer_manager.load_from_smiles(bipyrimidine_smiles)
109
109
 
110
- # Close the dialog
110
+ # Close the dialog first: the conversion puts up its own
111
111
  self.accept()
112
+ self.main_window.compute_manager.trigger_conversion()
112
113
 
113
114
  def image_mouse_press_event(self, event: QMouseEvent) -> None:
114
115
  """Handle mouse press on the image: trigger easter egg only for right-click."""
@@ -118,6 +119,7 @@ class AboutDialog(QDialog):
118
119
  else:
119
120
  event.ignore()
120
121
  except (AttributeError, RuntimeError, ValueError, TypeError):
122
+ logging.warning("Image click handling failed", exc_info=True)
121
123
  try:
122
124
  event.ignore()
123
125
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
@@ -114,29 +114,11 @@ class DialogManager:
114
114
  _template_dialog.activateWindow()
115
115
  return
116
116
 
117
- # Create new dialog
117
+ # Create new dialog. Picking a template arms it right away, and closing
118
+ # the dialog cleans the mode up, so there is nothing to do on finish.
118
119
  self.host.template_dialog = UserTemplateDialog(self.host, self.host)
119
120
  self.host.template_dialog.show()
120
121
 
121
- # Activate if a template is selected after dialog is closed
122
- def on_dialog_finished() -> None:
123
- if self.host.template_dialog.selected_template:
124
- template_name = self.host.template_dialog.selected_template.get(
125
- "name", "user_template"
126
- )
127
- mode_name = f"template_user_{template_name}"
128
-
129
- # Store template data for the scene to use
130
- self.host.set_scene_user_template_data(
131
- self.host.template_dialog.selected_template
132
- )
133
- self.host.ui_manager.set_mode(mode_name)
134
-
135
- # Update status
136
- self.host.update_status_message(f"Template mode: {template_name}")
137
-
138
- self.host.template_dialog.finished.connect(on_dialog_finished)
139
-
140
122
  def save_2d_as_template(self) -> None:
141
123
  """Save current 2D structure as a template"""
142
124
  if not self.host.state_manager.data.atoms:
@@ -883,9 +883,11 @@ class EditActionsManager:
883
883
  self.host.set_current_file_path(None)
884
884
  self.host.state_manager.update_window_title()
885
885
 
886
- # Reset 2D zoom
886
+ # Reset 2D zoom to the launch default (75%), not the bare identity
887
+ # transform -- resetTransform() left the view at 100%, so Clear All
888
+ # came back zoomed in compared to a freshly started session.
887
889
  if self.host.init_manager.view_2d:
888
- self.host.init_manager.view_2d.resetTransform()
890
+ self.host.view_3d_manager.reset_zoom()
889
891
 
890
892
  # Update scene and view
891
893
  self.host.init_manager.scene.update()
@@ -24,6 +24,7 @@ from .bond_item import BondItem
24
24
 
25
25
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
26
26
 
27
+ from ..core.mol_geometry import is_problematic_valence
27
28
  from ..utils.constants import DEFAULT_BOND_LENGTH, SUM_TOLERANCE
28
29
 
29
30
 
@@ -197,6 +198,106 @@ class TemplateMixin:
197
198
 
198
199
  return not atom1_has_other_double and not atom2_has_other_double
199
200
 
201
+ @staticmethod
202
+ def _bond_load(atom: Optional[AtomItem]) -> float:
203
+ """Total bond order already attached to *atom*."""
204
+ if atom is None:
205
+ return 0.0
206
+ return sum(b.order for b in atom.bonds)
207
+
208
+ def _plan_template_bonds(
209
+ self,
210
+ bonds: List[Tuple[int, int, int]],
211
+ vertex_atoms: List[Optional[AtomItem]],
212
+ ) -> Tuple[List[Tuple[int, int, int]], List[Tuple[int, int]]]:
213
+ """Decide the order every template bond ends up with once placed.
214
+
215
+ Two things constrain the template's own alternation:
216
+
217
+ * a bond that already exists is kept, unless the benzene policy allows
218
+ re-ordering it *and* both atoms have room for the extra order;
219
+ * a Kekule ring always carries a double next to its fused bond, so
220
+ fusing onto a single bond whose atoms each already hold a double --
221
+ every peripheral single bond of naphthalene -- would hand one of them
222
+ a fifth bond. Those bonds are lowered instead, which leaves the new
223
+ carbons as methylene: a real molecule rather than a hypervalent one.
224
+
225
+ Existing bonds are settled first so that an allowed upgrade is in the
226
+ atom's load before the new bonds hanging off it are fitted, and loads
227
+ are carried across the ring so several new bonds on one atom count
228
+ together.
229
+
230
+ Returns the planned orders and the vertex pairs the editor already
231
+ draws unchanged -- the ghost skips those, since painting a second copy
232
+ over a real double bond reads as a triple.
233
+ """
234
+ is_benzene_template = len(bonds) == 6 and any(o == 2 for _, _, o in bonds)
235
+ loads = {i: self._bond_load(atom) for i, atom in enumerate(vertex_atoms)}
236
+
237
+ def vertex(index: int) -> Optional[AtomItem]:
238
+ return vertex_atoms[index] if index < len(vertex_atoms) else None
239
+
240
+ def fits(index: int, extra: float) -> bool:
241
+ atom = vertex(index)
242
+ if atom is None or extra <= 0:
243
+ return True
244
+ return not is_problematic_valence(
245
+ getattr(atom, "symbol", "C"),
246
+ loads.get(index, 0.0) + extra,
247
+ int(getattr(atom, "charge", 0) or 0),
248
+ )
249
+
250
+ planned: Dict[int, int] = {}
251
+ unchanged: List[Tuple[int, int]] = []
252
+
253
+ # Every new bond needs at least a single, so an upgrade has to leave
254
+ # room for the ones still to come as well as for itself
255
+ incoming: Dict[int, int] = {}
256
+ for i, j, _order in bonds:
257
+ atom_i, atom_j = vertex(i), vertex(j)
258
+ if atom_i and atom_j and self.find_bond_between(atom_i, atom_j):
259
+ continue
260
+ incoming[i] = incoming.get(i, 0) + 1
261
+ incoming[j] = incoming.get(j, 0) + 1
262
+
263
+ # Pass 1 -- bonds that are already drawn
264
+ for position, (i, j, order) in enumerate(bonds):
265
+ atom_i, atom_j = vertex(i), vertex(j)
266
+ exist_b = (
267
+ self.find_bond_between(atom_i, atom_j) if atom_i and atom_j else None
268
+ )
269
+ if exist_b is None:
270
+ continue
271
+ current = int(getattr(exist_b, "order", order))
272
+ extra = order - current
273
+ if (
274
+ is_benzene_template
275
+ and self._should_overwrite_benzene_bond(exist_b)
276
+ and fits(i, extra + incoming.get(i, 0))
277
+ and fits(j, extra + incoming.get(j, 0))
278
+ ):
279
+ loads[i] = loads.get(i, 0.0) + extra
280
+ loads[j] = loads.get(j, 0.0) + extra
281
+ planned[position] = order
282
+ else:
283
+ planned[position] = current
284
+ unchanged.append((i, j))
285
+
286
+ # Pass 2 -- bonds the placement will create
287
+ for position, (i, j, order) in enumerate(bonds):
288
+ if position in planned:
289
+ continue
290
+ while order > 1 and not (fits(i, order) and fits(j, order)):
291
+ order -= 1
292
+ loads[i] = loads.get(i, 0.0) + order
293
+ loads[j] = loads.get(j, 0.0) + order
294
+ planned[position] = order
295
+
296
+ return (
297
+ [(i, j, planned[position]) for position, (i, j, _) in enumerate(bonds)],
298
+ unchanged,
299
+ )
300
+
200
301
  def add_molecule_fragment(
201
302
  self,
202
303
  points: List[Union[QPointF, Tuple[float, float]]],
@@ -214,8 +315,6 @@ class TemplateMixin:
214
315
  num_points = len(points)
215
316
  atom_items: List[Optional[AtomItem]] = [None] * num_points
216
317
 
217
- is_benzene_template = num_points == 6 and any(o == 2 for _, _, o in bonds_info)
218
-
219
318
  def coords(p: Any) -> Any:
220
319
  if hasattr(p, "x") and hasattr(p, "y"):
221
320
  return (p.x(), p.y())
@@ -317,6 +416,12 @@ class TemplateMixin:
317
416
  new_tb.append((i_idx, j_idx, new_order))
318
417
  template_bonds_to_use = new_tb
319
418
 
419
+ # Atoms exist but carry no template bonds yet, so their loads are the
420
+ # ones the fusion has to fit into.
421
+ template_bonds_to_use, _unchanged = self._plan_template_bonds(
422
+ list(template_bonds_to_use), atom_items
423
+ )
424
+
320
425
  # --- 5) Bond Creation/Update ---
321
426
  for id1_idx, id2_idx, order in template_bonds_to_use:
322
427
  if id1_idx < len(atom_items) and id2_idx < len(atom_items):
@@ -331,22 +436,15 @@ class TemplateMixin:
331
436
  exist_b = self.find_bond_between(a_item, b_item)
332
437
 
333
438
  if exist_b:
334
- # Keep existing bonds by default unless it's a safe benzene overwrite
335
- should_overwrite = (
336
- is_benzene_template
337
- and self._should_overwrite_benzene_bond(exist_b)
338
- )
339
-
340
- if should_overwrite:
341
- # Update bond order if conditions met
342
- exist_b.order = order
343
- exist_b.stereo = 0
344
- self.data.bonds[(id1, id2)]["order"] = order
345
- self.data.bonds[(id1, id2)]["stereo"] = 0
346
- exist_b.update()
347
- else:
348
- # Keep existing bond if conditions not met
439
+ # _plan_template_bonds already applied the keep-or-overwrite
440
+ # policy, so an unchanged order means keep this bond
441
+ if order == exist_b.order:
349
442
  continue
443
+ exist_b.order = order
444
+ exist_b.stereo = 0
445
+ self.data.bonds[(id1, id2)]["order"] = order
446
+ self.data.bonds[(id1, id2)]["stereo"] = 0
447
+ exist_b.update()
350
448
  else:
351
449
  # Create new bond
352
450
  self.create_bond(a_item, b_item, bond_order=order, bond_stereo=0)
@@ -448,6 +546,7 @@ class TemplateMixin:
448
546
  self.template_context["bonds_info"] = bonds_info
449
547
 
450
548
  # Snap individual preview vertices to nearby atoms to reflect template fusing visually
549
+ vertex_atoms: List[Optional[AtomItem]] = [None] * len(points)
451
550
  if self.get_setting("template_fusing_enabled_2d", True) and not alt_pressed:
452
551
  fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
453
552
  mapped_atoms = set(self.template_context.get("items", []))
@@ -468,6 +567,7 @@ class TemplateMixin:
468
567
  if best_idx != -1 and best_d <= click_map_threshold:
469
568
  points[best_idx] = ex_pos
470
569
  used_indices.add(best_idx)
570
+ vertex_atoms[best_idx] = ex_item
471
571
  except (AttributeError, TypeError, IndexError):
472
572
  # Safe defensive fallback catching AttributeError, TypeError, IndexError
473
573
  logging.debug("Suppressed non-critical error", exc_info=True)
@@ -497,8 +597,27 @@ class TemplateMixin:
497
597
  if nearby and best_d <= fuse_dist:
498
598
  points[i] = nearby.pos()
499
599
  mapped_atoms.add(nearby)
600
+ vertex_atoms[i] = nearby
601
+
602
+ # add_molecule_fragment rotates a fused aromatic ring to match the
603
+ # bonds already there; the preview shows the same alternation. It
604
+ # rotates the placement copy itself, so template_context keeps the
605
+ # unrotated orders.
606
+ preview_bonds = bonds_info
607
+ if is_aromatic and n == 6:
608
+ rotation = self._calculate_6ring_rotation(n, bonds_info, vertex_atoms)
609
+ orders = [order for (_, _, order) in bonds_info]
610
+ preview_bonds = [
611
+ (i, j, orders[(m + rotation) % n])
612
+ for m, (i, j, _) in enumerate(bonds_info)
613
+ ]
500
614
 
501
- self.template_preview.set_geometry(points, is_aromatic)
615
+ preview_bonds, editor_drawn = self._plan_template_bonds(
616
+ preview_bonds, vertex_atoms
617
+ )
618
+ self.template_preview.set_geometry(
619
+ points, is_aromatic, preview_bonds, editor_drawn
620
+ )
502
621
 
503
622
  self.template_preview.show()
504
623
  if self.views():
@@ -555,6 +674,44 @@ class TemplateMixin:
555
674
  points.append(current_p)
556
675
  return points
557
676
 
677
+ def _apply_template_atom_to_existing(
678
+ self, atom_item: Any, atom_data: Dict[str, Any]
679
+ ) -> None:
680
+ """Overwrite an existing atom with a user template atom's element/charge/radical."""
681
+ symbol = atom_data.get("symbol", "C")
682
+ # A hand-written template may carry nulls here
683
+ charge = int(atom_data.get("charge") or 0)
684
+ radical = int(atom_data.get("radical") or 0)
685
+
686
+ if (
687
+ atom_item.symbol == symbol
688
+ and atom_item.charge == charge
689
+ and atom_item.radical == radical
690
+ ):
691
+ return
692
+
693
+ try:
694
+ atom_item.prepareGeometryChange()
695
+ atom_item.symbol = symbol
696
+ atom_item.charge = charge
697
+ atom_item.radical = radical
698
+
699
+ record = self.data.atoms.get(atom_item.atom_id)
700
+ if record is not None:
701
+ record["symbol"] = symbol
702
+ record["charge"] = charge
703
+ record["radical"] = radical
704
+
705
+ # A bonded carbon is drawn as a bare vertex; the new element needs a label
706
+ atom_item.update_style()
707
+ for bond in atom_item.bonds:
708
+ bond.update()
709
+ other = bond.atom1 if bond.atom2 is atom_item else bond.atom2
710
+ if other is not None:
711
+ other.update_style()
712
+ except (AttributeError, RuntimeError, KeyError, TypeError) as e:
713
+ logging.warning(f"Error applying template atom to existing atom: {e}")
714
+
558
715
  def add_user_template_fragment(self, context: Dict[str, Any]) -> None:
559
716
  """Place user template fragment"""
560
717
  points = context.get("points", [])
@@ -569,9 +726,10 @@ class TemplateMixin:
569
726
  atom_id_map = {} # template id -> scene atom id
570
727
 
571
728
  for i, (pos, atom_data) in enumerate(zip(points, atoms_data)):
572
- # Skip first atom if attaching to existing atom
729
+ # Reuse the clicked atom for the template's first atom, overwriting its element
573
730
  if i == 0 and attachment_atom:
574
731
  atom_id_map[atom_data["id"]] = attachment_atom.atom_id
732
+ self._apply_template_atom_to_existing(attachment_atom, atom_data)
575
733
  continue
576
734
 
577
735
  symbol = atom_data.get("symbol", "C")