MoleditPy-linux 4.7.2__tar.gz → 4.8.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 (85) hide show
  1. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  5. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/about_dialog.py +4 -2
  6. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/dialog_logic.py +2 -20
  7. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +57 -2
  8. moleditpy_linux-4.8.0/src/moleditpy_linux/ui/preview_molecule.py +204 -0
  9. moleditpy_linux-4.8.0/src/moleditpy_linux/ui/template_preview_item.py +406 -0
  10. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/template_preview_view.py +2 -2
  11. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/ui_manager.py +18 -27
  12. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/user_template_dialog.py +70 -198
  13. moleditpy_linux-4.7.2/src/moleditpy_linux/ui/template_preview_item.py +0 -251
  14. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/LICENSE +0 -0
  15. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/README.md +0 -0
  16. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/setup.cfg +0 -0
  17. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  18. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  19. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  20. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  21. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/__init__.py +0 -0
  22. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/__main__.py +0 -0
  23. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  24. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  25. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  26. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/icon.png +0 -0
  27. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/core/__init__.py +0 -0
  28. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  29. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  30. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/main.py +0 -0
  31. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  32. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  33. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  34. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  35. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  36. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  37. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  38. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  39. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  40. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/app_state.py +0 -0
  41. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/atom_item.py +0 -0
  42. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  43. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  44. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/bond_item.py +0 -0
  45. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  46. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  47. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  48. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  49. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  50. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  51. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  52. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  53. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  54. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  55. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  56. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  57. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
  58. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  59. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/io_logic.py +0 -0
  60. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  61. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  62. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  63. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  64. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  65. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  66. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  67. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  68. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  69. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  70. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  71. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  72. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  73. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  74. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  75. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  76. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  77. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  79. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  80. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/constants.py +0 -0
  81. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  82. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  83. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  84. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  85. {moleditpy_linux-4.7.2 → moleditpy_linux-4.8.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: 4.7.2
3
+ Version: 4.8.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 = "4.7.2"
8
+ version = "4.8.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: 4.7.2
3
+ Version: 4.8.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
@@ -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
@@ -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:
@@ -448,6 +448,7 @@ class TemplateMixin:
448
448
  self.template_context["bonds_info"] = bonds_info
449
449
 
450
450
  # Snap individual preview vertices to nearby atoms to reflect template fusing visually
451
+ vertex_atoms: List[Optional[AtomItem]] = [None] * len(points)
451
452
  if self.get_setting("template_fusing_enabled_2d", True) and not alt_pressed:
452
453
  fuse_dist = self.get_setting("template_fusing_distance_2d", 7.0)
453
454
  mapped_atoms = set(self.template_context.get("items", []))
@@ -468,6 +469,7 @@ class TemplateMixin:
468
469
  if best_idx != -1 and best_d <= click_map_threshold:
469
470
  points[best_idx] = ex_pos
470
471
  used_indices.add(best_idx)
472
+ vertex_atoms[best_idx] = ex_item
471
473
  except (AttributeError, TypeError, IndexError):
472
474
  # Safe defensive fallback catching AttributeError, TypeError, IndexError
473
475
  logging.debug("Suppressed non-critical error", exc_info=True)
@@ -497,8 +499,22 @@ class TemplateMixin:
497
499
  if nearby and best_d <= fuse_dist:
498
500
  points[i] = nearby.pos()
499
501
  mapped_atoms.add(nearby)
502
+ vertex_atoms[i] = nearby
503
+
504
+ # add_molecule_fragment rotates a fused aromatic ring to match the
505
+ # bonds already there; the preview shows the same alternation. It
506
+ # rotates the placement copy itself, so template_context keeps the
507
+ # unrotated orders.
508
+ preview_bonds = bonds_info
509
+ if is_aromatic and n == 6:
510
+ rotation = self._calculate_6ring_rotation(n, bonds_info, vertex_atoms)
511
+ orders = [order for (_, _, order) in bonds_info]
512
+ preview_bonds = [
513
+ (i, j, orders[(m + rotation) % n])
514
+ for m, (i, j, _) in enumerate(bonds_info)
515
+ ]
500
516
 
501
- self.template_preview.set_geometry(points, is_aromatic)
517
+ self.template_preview.set_geometry(points, is_aromatic, preview_bonds)
502
518
 
503
519
  self.template_preview.show()
504
520
  if self.views():
@@ -555,6 +571,44 @@ class TemplateMixin:
555
571
  points.append(current_p)
556
572
  return points
557
573
 
574
+ def _apply_template_atom_to_existing(
575
+ self, atom_item: Any, atom_data: Dict[str, Any]
576
+ ) -> None:
577
+ """Overwrite an existing atom with a user template atom's element/charge/radical."""
578
+ symbol = atom_data.get("symbol", "C")
579
+ # A hand-written template may carry nulls here
580
+ charge = int(atom_data.get("charge") or 0)
581
+ radical = int(atom_data.get("radical") or 0)
582
+
583
+ if (
584
+ atom_item.symbol == symbol
585
+ and atom_item.charge == charge
586
+ and atom_item.radical == radical
587
+ ):
588
+ return
589
+
590
+ try:
591
+ atom_item.prepareGeometryChange()
592
+ atom_item.symbol = symbol
593
+ atom_item.charge = charge
594
+ atom_item.radical = radical
595
+
596
+ record = self.data.atoms.get(atom_item.atom_id)
597
+ if record is not None:
598
+ record["symbol"] = symbol
599
+ record["charge"] = charge
600
+ record["radical"] = radical
601
+
602
+ # A bonded carbon is drawn as a bare vertex; the new element needs a label
603
+ atom_item.update_style()
604
+ for bond in atom_item.bonds:
605
+ bond.update()
606
+ other = bond.atom1 if bond.atom2 is atom_item else bond.atom2
607
+ if other is not None:
608
+ other.update_style()
609
+ except (AttributeError, RuntimeError, KeyError, TypeError) as e:
610
+ logging.warning(f"Error applying template atom to existing atom: {e}")
611
+
558
612
  def add_user_template_fragment(self, context: Dict[str, Any]) -> None:
559
613
  """Place user template fragment"""
560
614
  points = context.get("points", [])
@@ -569,9 +623,10 @@ class TemplateMixin:
569
623
  atom_id_map = {} # template id -> scene atom id
570
624
 
571
625
  for i, (pos, atom_data) in enumerate(zip(points, atoms_data)):
572
- # Skip first atom if attaching to existing atom
626
+ # Reuse the clicked atom for the template's first atom, overwriting its element
573
627
  if i == 0 and attachment_atom:
574
628
  atom_id_map[atom_data["id"]] = attachment_atom.atom_id
629
+ self._apply_template_atom_to_existing(attachment_atom, atom_data)
575
630
  continue
576
631
 
577
632
  symbol = atom_data.get("symbol", "C")
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any, Dict, List, Sequence, Tuple
17
+
18
+ from PyQt6.QtCore import QPointF, QRectF
19
+ from PyQt6.QtWidgets import QGraphicsItem, QGraphicsScene
20
+
21
+ from .atom_item import AtomItem
22
+ from .bond_item import BondItem
23
+
24
+
25
+ class PreviewScene(QGraphicsScene):
26
+ """Scene for preview-only atom/bond items.
27
+
28
+ AtomItem and BondItem read their font, colour and spacing through
29
+ scene().get_setting(), so any scene holding them has to answer it. ``host``
30
+ is the editor scene, so previews follow the user's 2D settings.
31
+ """
32
+
33
+ def __init__(self, host: Any = None) -> None:
34
+ super().__init__()
35
+ self.host: Any = host
36
+
37
+ def get_setting(self, key: str, default: Any = None) -> Any:
38
+ """Forward a settings lookup to the editor scene."""
39
+ host = self.host
40
+ if host is None or not hasattr(host, "get_setting"):
41
+ return default
42
+ try:
43
+ return host.get_setting(key, default)
44
+ except (AttributeError, RuntimeError, TypeError, ValueError):
45
+ return default
46
+
47
+ def views(self) -> List[Any]:
48
+ """Report the editor's views so zoom-dependent radii match the real items."""
49
+ host = self.host
50
+ if host is not None and hasattr(host, "views"):
51
+ try:
52
+ return list(host.views())
53
+ except (AttributeError, RuntimeError, TypeError):
54
+ return []
55
+ return list(super().views())
56
+
57
+
58
+ def ensure_preview_settings(scene: Any, host: Any = None) -> None:
59
+ """Give a plain QGraphicsScene the get_setting() the preview items call."""
60
+ if hasattr(scene, "get_setting"):
61
+ return
62
+
63
+ def get_setting(key: str, default: Any = None) -> Any:
64
+ if host is None or not hasattr(host, "get_setting"):
65
+ return default
66
+ try:
67
+ # The host outlives the preview normally, but a closed editor would
68
+ # otherwise raise out of paint()
69
+ return host.get_setting(key, default)
70
+ except (AttributeError, RuntimeError, TypeError, ValueError):
71
+ return default
72
+
73
+ scene.get_setting = get_setting
74
+
75
+
76
+ def build_preview_items(
77
+ atoms: Sequence[Dict[str, Any]],
78
+ bonds: Sequence[Sequence[int]],
79
+ ) -> Tuple[List[AtomItem], List[BondItem], Dict[int, Tuple[int, ...]]]:
80
+ """Build the atom/bond items that draw a template the way the editor draws it.
81
+
82
+ ``atoms`` entries need ``pos`` plus the usual ``symbol``/``charge``/``radical``;
83
+ ``bonds`` are ``(index1, index2[, order[, stereo]])``. The items are returned
84
+ unparented — the caller decides which scene they belong to — together with the
85
+ ring each bond belongs to, so a moved preview can recompute its ring centres.
86
+ """
87
+ atom_items: List[AtomItem] = []
88
+ for i, atom_data in enumerate(atoms):
89
+ item = AtomItem(
90
+ i,
91
+ atom_data.get("symbol", "C"),
92
+ QPointF(atom_data.get("pos", QPointF())),
93
+ charge=int(atom_data.get("charge", 0) or 0),
94
+ radical=int(atom_data.get("radical", 0) or 0),
95
+ )
96
+ item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
97
+ item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
98
+ item.setAcceptHoverEvents(False)
99
+ atom_items.append(item)
100
+
101
+ bond_items: List[BondItem] = []
102
+ seen_pairs: set[Tuple[int, int]] = set()
103
+ for bond in bonds:
104
+ if len(bond) < 2:
105
+ continue
106
+ i, j = bond[0], bond[1]
107
+ if not isinstance(i, int) or not isinstance(j, int):
108
+ continue
109
+ if not (0 <= i < len(atom_items) and 0 <= j < len(atom_items)) or i == j:
110
+ continue
111
+ # A repeated pair would make RDKit reject the whole preview molecule
112
+ if (min(i, j), max(i, j)) in seen_pairs:
113
+ continue
114
+ seen_pairs.add((min(i, j), max(i, j)))
115
+ order = int(bond[2]) if len(bond) > 2 else 1
116
+ stereo = int(bond[3]) if len(bond) > 3 else 0
117
+ atom1, atom2 = atom_items[i], atom_items[j]
118
+ bond_item = BondItem(atom1, atom2, order, stereo)
119
+ bond_item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
120
+ bond_item.setAcceptHoverEvents(False)
121
+ # create_bond() does this too; without it carbons never turn invisible
122
+ atom1.bonds.append(bond_item)
123
+ atom2.bonds.append(bond_item)
124
+ bond_items.append(bond_item)
125
+
126
+ ring_atoms = apply_preview_topology(atom_items, bond_items)
127
+ for item in atom_items:
128
+ item.update_style()
129
+ return atom_items, bond_items, ring_atoms
130
+
131
+
132
+ def preview_content_rect(
133
+ atom_items: Sequence[AtomItem], bond_items: Sequence[BondItem]
134
+ ) -> QRectF:
135
+ """Return the rect the preview actually draws into, without any hit padding."""
136
+ rect = QRectF()
137
+ for atom in atom_items:
138
+ rect = rect.united(atom.visual_rect().translated(atom.pos()))
139
+ for bond in bond_items:
140
+ line = bond.get_line_in_local_coords()
141
+ rect = rect.united(
142
+ QRectF(line.p1(), line.p2()).normalized().translated(bond.pos())
143
+ )
144
+ return rect
145
+
146
+
147
+ def apply_preview_topology(
148
+ atom_items: Sequence[AtomItem], bond_items: Sequence[BondItem]
149
+ ) -> Dict[int, Tuple[int, ...]]:
150
+ """Fill in implicit hydrogens and ring info for the preview.
151
+
152
+ Deliberately no sanitization: a preview must draw whatever the template says,
153
+ so the valence is never checked and an odd one still gets its H count.
154
+ """
155
+ try:
156
+ from rdkit import Chem # pylint: disable=import-outside-toplevel
157
+
158
+ bond_types = {
159
+ 1: Chem.BondType.SINGLE,
160
+ 2: Chem.BondType.DOUBLE,
161
+ 3: Chem.BondType.TRIPLE,
162
+ 4: Chem.BondType.AROMATIC,
163
+ }
164
+ mol = Chem.RWMol()
165
+ for item in atom_items:
166
+ atom = Chem.Atom(item.symbol)
167
+ atom.SetFormalCharge(item.charge)
168
+ atom.SetNumRadicalElectrons(item.radical)
169
+ mol.AddAtom(atom)
170
+ index_of = {id(item): i for i, item in enumerate(atom_items)}
171
+ for bond in bond_items:
172
+ mol.AddBond(
173
+ index_of[id(bond.atom1)],
174
+ index_of[id(bond.atom2)],
175
+ bond_types.get(bond.order, Chem.BondType.SINGLE),
176
+ )
177
+ mol.UpdatePropertyCache(strict=False)
178
+ Chem.FastFindRings(mol)
179
+ except (ValueError, RuntimeError, TypeError, AttributeError, ImportError):
180
+ logging.debug("Preview topology unavailable", exc_info=True)
181
+ return {}
182
+
183
+ for item, atom in zip(atom_items, mol.GetAtoms()):
184
+ item.implicit_h_count = atom.GetTotalNumHs()
185
+
186
+ ring_info = mol.GetRingInfo()
187
+ ring_atoms: Dict[int, Tuple[int, ...]] = {}
188
+ best_ring_size: Dict[int, int] = {}
189
+ for atom_ring, bond_ring in zip(ring_info.AtomRings(), ring_info.BondRings()):
190
+ positions = [atom_items[idx].pos() for idx in atom_ring]
191
+ center = (
192
+ sum(p.x() for p in positions) / len(positions),
193
+ sum(p.y() for p in positions) / len(positions),
194
+ )
195
+ for bond_idx in bond_ring:
196
+ if bond_idx >= len(bond_items):
197
+ continue
198
+ bond = bond_items[bond_idx]
199
+ bond.is_in_ring = True
200
+ if best_ring_size.get(bond_idx, len(atom_items) + 1) > len(atom_ring):
201
+ bond.ring_center = center
202
+ best_ring_size[bond_idx] = len(atom_ring)
203
+ ring_atoms[bond_idx] = tuple(atom_ring)
204
+ return ring_atoms