MoleditPy-linux 4.7.1__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.1 → moleditpy_linux-4.8.0}/PKG-INFO +3 -3
  2. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/README.md +2 -2
  3. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/pyproject.toml +1 -1
  4. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/PKG-INFO +3 -3
  5. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  6. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/about_dialog.py +4 -2
  7. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/dialog_logic.py +2 -20
  8. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/export_logic.py +6 -4
  9. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +57 -2
  10. moleditpy_linux-4.8.0/src/moleditpy_linux/ui/preview_molecule.py +204 -0
  11. moleditpy_linux-4.8.0/src/moleditpy_linux/ui/template_preview_item.py +406 -0
  12. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/template_preview_view.py +2 -2
  13. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/ui_manager.py +18 -27
  14. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/user_template_dialog.py +70 -198
  15. moleditpy_linux-4.7.1/src/moleditpy_linux/ui/template_preview_item.py +0 -251
  16. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/LICENSE +0 -0
  17. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/setup.cfg +0 -0
  18. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  19. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  20. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  21. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  22. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/__init__.py +0 -0
  23. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/__main__.py +0 -0
  24. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  25. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  26. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  27. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/assets/icon.png +0 -0
  28. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/core/__init__.py +0 -0
  29. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  30. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  31. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/main.py +0 -0
  32. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  33. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  34. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  35. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  36. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  37. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  38. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  39. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  40. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  41. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/app_state.py +0 -0
  42. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/atom_item.py +0 -0
  43. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  44. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  45. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/bond_item.py +0 -0
  46. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  47. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  48. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  49. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  50. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  51. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  52. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  53. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  54. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  55. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  56. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  57. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  58. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  59. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/io_logic.py +0 -0
  60. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  61. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  62. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  63. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  64. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  65. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  66. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  67. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  68. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  69. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  70. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  71. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  72. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  73. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  74. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  75. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  76. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  77. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  79. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  80. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/constants.py +0 -0
  81. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  82. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  83. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  84. {moleditpy_linux-4.7.1 → moleditpy_linux-4.8.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  85. {moleditpy_linux-4.7.1 → 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.1
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
@@ -786,7 +786,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
786
786
 
787
787
  ## Installation and Execution
788
788
 
789
- For detailed instructions, please refer to the project [Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki). A [Docker version](https://github.com/HiroYokoyama/python_molecular_editor_docker) is also available. A [Windows installer](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer) is also distributed.
789
+ For detailed instructions, please refer to the project [Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki). A [Docker version](https://github.com/HiroYokoyama/python_molecular_editor_docker) is also available. A [Windows installer](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer) and a [macOS app bundle](https://hiroyokoyama.github.io/python_molecular_editor/macos-installer/macos_installer) are also distributed.
790
790
 
791
791
  #### Requirements
792
792
 
@@ -922,7 +922,7 @@ Additionally, please cite the plugins you used.
922
922
 
923
923
  ## インストールと実行
924
924
 
925
- 詳細な手順については、プロジェクトの[Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki)を参照してください。[Docker版](https://github.com/HiroYokoyama/python_molecular_editor_docker)も利用可能です。[Windows向けインストーラー](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer-jp)も使用できます。
925
+ 詳細な手順については、プロジェクトの[Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki)を参照してください。[Docker版](https://github.com/HiroYokoyama/python_molecular_editor_docker)も利用可能です。[Windows向けインストーラー](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer-jp)や[macOSアプリバンドル](https://hiroyokoyama.github.io/python_molecular_editor/macos-installer/macos_installer-jp)も使用できます。
926
926
 
927
927
  #### 必要ライブラリ
928
928
 
@@ -83,7 +83,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
83
83
 
84
84
  ## Installation and Execution
85
85
 
86
- For detailed instructions, please refer to the project [Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki). A [Docker version](https://github.com/HiroYokoyama/python_molecular_editor_docker) is also available. A [Windows installer](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer) is also distributed.
86
+ For detailed instructions, please refer to the project [Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki). A [Docker version](https://github.com/HiroYokoyama/python_molecular_editor_docker) is also available. A [Windows installer](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer) and a [macOS app bundle](https://hiroyokoyama.github.io/python_molecular_editor/macos-installer/macos_installer) are also distributed.
87
87
 
88
88
  #### Requirements
89
89
 
@@ -219,7 +219,7 @@ Additionally, please cite the plugins you used.
219
219
 
220
220
  ## インストールと実行
221
221
 
222
- 詳細な手順については、プロジェクトの[Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki)を参照してください。[Docker版](https://github.com/HiroYokoyama/python_molecular_editor_docker)も利用可能です。[Windows向けインストーラー](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer-jp)も使用できます。
222
+ 詳細な手順については、プロジェクトの[Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki)を参照してください。[Docker版](https://github.com/HiroYokoyama/python_molecular_editor_docker)も利用可能です。[Windows向けインストーラー](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer-jp)や[macOSアプリバンドル](https://hiroyokoyama.github.io/python_molecular_editor/macos-installer/macos_installer-jp)も使用できます。
223
223
 
224
224
  #### 必要ライブラリ
225
225
 
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.7.1"
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.1
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
@@ -786,7 +786,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
786
786
 
787
787
  ## Installation and Execution
788
788
 
789
- For detailed instructions, please refer to the project [Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki). A [Docker version](https://github.com/HiroYokoyama/python_molecular_editor_docker) is also available. A [Windows installer](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer) is also distributed.
789
+ For detailed instructions, please refer to the project [Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki). A [Docker version](https://github.com/HiroYokoyama/python_molecular_editor_docker) is also available. A [Windows installer](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer) and a [macOS app bundle](https://hiroyokoyama.github.io/python_molecular_editor/macos-installer/macos_installer) are also distributed.
790
790
 
791
791
  #### Requirements
792
792
 
@@ -922,7 +922,7 @@ Additionally, please cite the plugins you used.
922
922
 
923
923
  ## インストールと実行
924
924
 
925
- 詳細な手順については、プロジェクトの[Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki)を参照してください。[Docker版](https://github.com/HiroYokoyama/python_molecular_editor_docker)も利用可能です。[Windows向けインストーラー](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer-jp)も使用できます。
925
+ 詳細な手順については、プロジェクトの[Wiki](https://github.com/HiroYokoyama/python_molecular_editor/wiki)を参照してください。[Docker版](https://github.com/HiroYokoyama/python_molecular_editor_docker)も利用可能です。[Windows向けインストーラー](https://hiroyokoyama.github.io/python_molecular_editor/windows-installer/windows_installer-jp)や[macOSアプリバンドル](https://hiroyokoyama.github.io/python_molecular_editor/macos-installer/macos_installer-jp)も使用できます。
926
926
 
927
927
  #### 必要ライブラリ
928
928
 
@@ -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:
@@ -136,7 +136,7 @@ class ExportManager:
136
136
  """
137
137
  try:
138
138
  # Create MTL file
139
- with open(mtl_path, "w") as mtl_file:
139
+ with open(mtl_path, "w", encoding="utf-8") as mtl_file:
140
140
  mtl_file.write(f"# Material file for {os.path.basename(obj_path)}\n")
141
141
  mtl_file.write("# Generated with individual object colors\n\n")
142
142
 
@@ -157,7 +157,7 @@ class ExportManager:
157
157
  mtl_file.write("\n")
158
158
 
159
159
  # Create OBJ file
160
- with open(obj_path, "w") as obj_file:
160
+ with open(obj_path, "w", encoding="utf-8") as obj_file:
161
161
  obj_file.write("# OBJ file with multiple materials\n")
162
162
  obj_file.write("# Generated with individual object colors\n")
163
163
  obj_file.write(f"mtllib {os.path.basename(mtl_path)}\n\n")
@@ -225,8 +225,10 @@ class ExportManager:
225
225
  vertex_offset += mesh.n_points
226
226
  obj_file.write("\n")
227
227
 
228
- except (AttributeError, RuntimeError, ValueError) as e:
229
- raise Exception(f"Failed to create multi-material OBJ: {e}")
228
+ except (AttributeError, OSError, RuntimeError, ValueError) as e:
229
+ # ValueError, not Exception: export_obj_mtl catches this to report
230
+ # the failure in the status bar.
231
+ raise ValueError(f"Failed to create multi-material OBJ: {e}") from e
230
232
 
231
233
  def export_color_stl(self) -> None:
232
234
  """Export as Color STL."""
@@ -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