MoleditPy 3.0.6__tar.gz → 3.1.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 (79) hide show
  1. {moleditpy-3.0.6 → moleditpy-3.1.0}/PKG-INFO +1 -1
  2. {moleditpy-3.0.6 → moleditpy-3.1.0}/pyproject.toml +1 -1
  3. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/MoleditPy.egg-info/PKG-INFO +1 -1
  4. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/app_state.py +2 -2
  5. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/dialog_logic.py +0 -4
  6. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/io_logic.py +21 -25
  7. moleditpy-3.1.0/src/moleditpy/ui/string_importers.py +208 -0
  8. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/user_template_dialog.py +0 -4
  9. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/view_3d_logic.py +3 -2
  10. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/utils/constants.py +1 -1
  11. moleditpy-3.0.6/src/moleditpy/ui/string_importers.py +0 -257
  12. {moleditpy-3.0.6 → moleditpy-3.1.0}/LICENSE +0 -0
  13. {moleditpy-3.0.6 → moleditpy-3.1.0}/README.md +0 -0
  14. {moleditpy-3.0.6 → moleditpy-3.1.0}/setup.cfg +0 -0
  15. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
  16. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  17. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  18. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/MoleditPy.egg-info/requires.txt +0 -0
  19. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
  20. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/__init__.py +0 -0
  21. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/__main__.py +0 -0
  22. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/assets/file_icon.ico +0 -0
  23. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/assets/icon.icns +0 -0
  24. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/assets/icon.ico +0 -0
  25. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/assets/icon.png +0 -0
  26. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/core/__init__.py +0 -0
  27. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/core/mol_geometry.py +0 -0
  28. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/core/molecular_data.py +0 -0
  29. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/main.py +0 -0
  30. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/plugins/__init__.py +0 -0
  31. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/plugins/plugin_interface.py +0 -0
  32. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/plugins/plugin_manager.py +0 -0
  33. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
  34. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/__init__.py +0 -0
  35. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/about_dialog.py +0 -0
  36. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/align_plane_dialog.py +0 -0
  37. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/alignment_dialog.py +0 -0
  38. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/analysis_window.py +0 -0
  39. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/angle_dialog.py +0 -0
  40. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/atom_item.py +0 -0
  41. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/base_picking_dialog.py +0 -0
  42. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/bond_item.py +0 -0
  43. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/bond_length_dialog.py +0 -0
  44. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/calculation_worker.py +0 -0
  45. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/color_settings_dialog.py +0 -0
  46. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/compute_logic.py +0 -0
  47. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/constrained_optimization_dialog.py +0 -0
  48. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/custom_interactor_style.py +0 -0
  49. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
  50. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
  51. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/dihedral_dialog.py +0 -0
  52. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/edit_3d_logic.py +0 -0
  53. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/edit_actions_logic.py +0 -0
  54. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/export_logic.py +0 -0
  55. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/geometry_base_dialog.py +0 -0
  56. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/main_window.py +0 -0
  57. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/main_window_init.py +0 -0
  58. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/mirror_dialog.py +0 -0
  59. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/molecular_scene_handler.py +0 -0
  60. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/molecule_scene.py +0 -0
  61. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/move_group_dialog.py +0 -0
  62. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
  63. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/planarize_dialog.py +0 -0
  64. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/settings_dialog.py +0 -0
  65. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
  66. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +0 -0
  67. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
  68. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -0
  69. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -0
  70. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/sip_isdeleted_safe.py +0 -0
  71. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/template_preview_item.py +0 -0
  72. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/template_preview_view.py +0 -0
  73. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/translation_dialog.py +0 -0
  74. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/ui_manager.py +0 -0
  75. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/ui/zoomable_view.py +0 -0
  76. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/utils/__init__.py +0 -0
  77. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/utils/default_settings.py +0 -0
  78. {moleditpy-3.0.6 → moleditpy-3.1.0}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
  79. {moleditpy-3.0.6 → moleditpy-3.1.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.0.6
3
+ Version: 3.1.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.0.6"
8
+ version = "3.1.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.0.6
3
+ Version: 3.1.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
@@ -364,9 +364,9 @@ class StateManager:
364
364
  # 'Save As' if not PMEPRJ
365
365
  file_path = self.host.init_manager.current_file_path
366
366
  if not file_path or not file_path.lower().endswith(".pmeprj"):
367
- self.save_project_as()
367
+ self.host.io_manager.save_project_as()
368
368
  else:
369
- self.save_project()
369
+ self.host.io_manager.save_project()
370
370
  return (
371
371
  not self.host.state_manager.has_unsaved_changes
372
372
  ) # Return True only if save was successful
@@ -200,10 +200,6 @@ class DialogManager:
200
200
  with open(filepath, "w", encoding="utf-8") as f:
201
201
  json.dump(template_data, f, indent=2, ensure_ascii=False)
202
202
 
203
- # Mark as saved (no unsaved changes for this operation)
204
- self.host.state_manager.has_unsaved_changes = False
205
- self.host.state_manager.update_window_title()
206
-
207
203
  QMessageBox.information(
208
204
  self.host, "Success", f"Template '{name}' saved successfully."
209
205
  )
@@ -564,10 +564,7 @@ class IOManager:
564
564
  self.host.statusBar().showMessage(f"Export error: {e}")
565
565
 
566
566
  def load_mol_file(self, file_path: Optional[str] = None) -> None:
567
- """Regular 2D MOL file loading logic."""
568
- if not self.host.state_manager.check_unsaved_changes():
569
- return
570
-
567
+ """Import a MOL/SDF file and add its contents to the 2D editor."""
571
568
  if not file_path:
572
569
  default_dir = (
573
570
  os.path.dirname(self.host.init_manager.current_file_path)
@@ -601,11 +598,6 @@ class IOManager:
601
598
  raise ValueError("Failed to read molecule from file.")
602
599
 
603
600
  Chem.Kekulize(mol)
604
- self.host.ui_manager.restore_ui_for_editing()
605
- self.host.edit_actions_manager.clear_2d_editor(push_to_undo=False)
606
- self.host.view_3d_manager.current_mol = None
607
- self.host.view_3d_manager.plotter.clear()
608
- self.host.init_manager.analysis_action.setEnabled(False)
609
601
 
610
602
  if mol.GetNumConformers() == 0:
611
603
  AllChem.Compute2DCoords(mol)
@@ -615,9 +607,22 @@ class IOManager:
615
607
  AllChem.WedgeMolBonds(mol, conf)
616
608
 
617
609
  SCALE_FACTOR = 50.0
618
- view_center = self.host.init_manager.view_2d.mapToScene(
619
- self.host.init_manager.view_2d.viewport().rect().center()
620
- )
610
+ existing_atoms = self.host.state_manager.data.atoms
611
+ if existing_atoms:
612
+ max_x = max(
613
+ v["pos"].x() if hasattr(v["pos"], "x") else v["pos"][0]
614
+ for v in existing_atoms.values()
615
+ )
616
+ avg_y = sum(
617
+ v["pos"].y() if hasattr(v["pos"], "y") else v["pos"][1]
618
+ for v in existing_atoms.values()
619
+ ) / len(existing_atoms)
620
+ place_center = QPointF(max_x + 80.0, avg_y)
621
+ else:
622
+ place_center = self.host.init_manager.view_2d.mapToScene(
623
+ self.host.init_manager.view_2d.viewport().rect().center()
624
+ )
625
+
621
626
  positions = [conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
622
627
  mol_center_x = (
623
628
  sum(p.x for p in positions) / len(positions) if positions else 0.0
@@ -630,8 +635,8 @@ class IOManager:
630
635
  for i in range(mol.GetNumAtoms()):
631
636
  atom = mol.GetAtomWithIdx(i)
632
637
  pos = conf.GetAtomPosition(i)
633
- scene_x = ((pos.x - mol_center_x) * SCALE_FACTOR) + view_center.x()
634
- scene_y = (-(pos.y - mol_center_y) * SCALE_FACTOR) + view_center.y()
638
+ scene_x = ((pos.x - mol_center_x) * SCALE_FACTOR) + place_center.x()
639
+ scene_y = (-(pos.y - mol_center_y) * SCALE_FACTOR) + place_center.y()
635
640
  atom_id = self.host.init_manager.scene.create_atom(
636
641
  atom.GetSymbol(),
637
642
  QPointF(scene_x, scene_y),
@@ -662,18 +667,9 @@ class IOManager:
662
667
  bond_stereo=stereo,
663
668
  )
664
669
 
665
- self.host.statusBar().showMessage(f"Successfully loaded {file_path}")
666
- self.host.state_manager.reset_undo_stack()
667
- self.host.init_manager.current_file_path = file_path
668
- self.host.state_manager.has_unsaved_changes = False
669
- self.host.state_manager.update_window_title()
670
+ self.host.statusBar().showMessage(f"Successfully imported {file_path}")
670
671
  self.host.init_manager.scene.update_all_items()
671
-
672
- # Reset camera/zoom after drawing
673
- QTimer.singleShot(
674
- 50, lambda: self.host.view_3d_manager.plotter.view_isometric()
675
- )
676
- QTimer.singleShot(100, lambda: self.host.view_3d_manager.plotter.render())
672
+ self.host.edit_actions_manager.push_undo_state()
677
673
  QTimer.singleShot(100, self.host.view_3d_manager.fit_to_view)
678
674
  except Exception as e:
679
675
  self.host.statusBar().showMessage(f"Error loading file: {e}")
@@ -0,0 +1,208 @@
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
+ # RDKit imports (explicit to satisfy flake8 and used features)
16
+ from rdkit import Chem
17
+ from rdkit.Chem import AllChem
18
+
19
+ # PyQt6 Modules
20
+ from PyQt6.QtCore import QPointF, QTimer
21
+ from PyQt6.QtWidgets import QInputDialog
22
+
23
+ try:
24
+ from PyQt6 import sip as _sip # type: ignore
25
+
26
+ _sip_isdeleted = getattr(_sip, "isdeleted", None)
27
+ except ImportError:
28
+ _sip = None
29
+ _sip_isdeleted = None
30
+
31
+
32
+ # --- Classes ---
33
+ class StringImporterManager:
34
+ """Mixin for string-based molecular input (SMILES, InChI)."""
35
+
36
+ def __init__(self, host):
37
+ self.host = host
38
+
39
+ def import_smiles_dialog(self) -> None:
40
+ """Dialog for SMILES input."""
41
+ smiles, ok = QInputDialog.getText(
42
+ self.host, "Import SMILES", "Enter SMILES string:"
43
+ )
44
+ if ok and smiles:
45
+ self.load_from_smiles(smiles)
46
+
47
+ def import_inchi_dialog(self) -> None:
48
+ """Dialog for InChI input."""
49
+ inchi, ok = QInputDialog.getText(
50
+ self.host, "Import InChI", "Enter InChI string:"
51
+ )
52
+ if ok and inchi:
53
+ self.load_from_inchi(inchi)
54
+
55
+ def _placement_center(self) -> QPointF:
56
+ """Return the scene point where the next imported molecule should be centered.
57
+
58
+ If the editor already contains atoms, offset to the right of the
59
+ rightmost atom so the new fragment does not overlap. Otherwise use
60
+ the current viewport center.
61
+ """
62
+ existing = self.host.state_manager.data.atoms
63
+ if existing:
64
+
65
+ def _x(v):
66
+ return v["pos"].x() if hasattr(v["pos"], "x") else v["pos"][0]
67
+
68
+ def _y(v):
69
+ return v["pos"].y() if hasattr(v["pos"], "y") else v["pos"][1]
70
+
71
+ max_x = max(_x(v) for v in existing.values())
72
+ avg_y = sum(_y(v) for v in existing.values()) / len(existing)
73
+ return QPointF(max_x + 80.0, avg_y)
74
+
75
+ return self.host.init_manager.view_2d.mapToScene(
76
+ self.host.init_manager.view_2d.viewport().rect().center()
77
+ )
78
+
79
+ def _place_mol_bonds(self, mol, rdkit_idx_to_my_id: dict) -> None:
80
+ """Create bonds in the 2D scene from an RDKit molecule."""
81
+ for bond in mol.GetBonds():
82
+ b_idx, e_idx = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
83
+ b_type = bond.GetBondTypeAsDouble()
84
+ b_dir = bond.GetBondDir()
85
+ stereo = 0
86
+ if b_dir == Chem.BondDir.BEGINWEDGE:
87
+ stereo = 1
88
+ elif b_dir == Chem.BondDir.BEGINDASH:
89
+ stereo = 2
90
+ if bond.GetBondType() == Chem.BondType.DOUBLE:
91
+ if bond.GetStereo() == Chem.BondStereo.STEREOZ:
92
+ stereo = 3
93
+ elif bond.GetStereo() == Chem.BondStereo.STEREOE:
94
+ stereo = 4
95
+
96
+ if b_idx in rdkit_idx_to_my_id and e_idx in rdkit_idx_to_my_id:
97
+ a1_id = rdkit_idx_to_my_id[b_idx]
98
+ a2_id = rdkit_idx_to_my_id[e_idx]
99
+ a1_item = self.host.state_manager.data.atoms[a1_id]["item"]
100
+ a2_item = self.host.state_manager.data.atoms[a2_id]["item"]
101
+ self.host.init_manager.scene.create_bond(
102
+ a1_item, a2_item, bond_order=int(b_type), bond_stereo=stereo
103
+ )
104
+
105
+ def _place_mol_atoms(self, mol, conf, place_center: QPointF) -> dict:
106
+ """Create atoms in the 2D scene and return rdkit_idx → atom_id mapping."""
107
+ SCALE_FACTOR = 50.0
108
+ positions = [conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
109
+ mol_center_x = (
110
+ sum(p.x for p in positions) / len(positions) if positions else 0.0
111
+ )
112
+ mol_center_y = (
113
+ sum(p.y for p in positions) / len(positions) if positions else 0.0
114
+ )
115
+
116
+ rdkit_idx_to_my_id = {}
117
+ for i in range(mol.GetNumAtoms()):
118
+ atom = mol.GetAtomWithIdx(i)
119
+ pos = conf.GetAtomPosition(i)
120
+ scene_x = ((pos.x - mol_center_x) * SCALE_FACTOR) + place_center.x()
121
+ scene_y = (-(pos.y - mol_center_y) * SCALE_FACTOR) + place_center.y()
122
+ atom_id = self.host.init_manager.scene.create_atom(
123
+ atom.GetSymbol(),
124
+ QPointF(scene_x, scene_y),
125
+ charge=atom.GetFormalCharge(),
126
+ )
127
+ rdkit_idx_to_my_id[i] = atom_id
128
+ return rdkit_idx_to_my_id
129
+
130
+ def load_from_smiles(self, smiles_string: str) -> None:
131
+ """Add a molecule from a SMILES string to the 2D editor."""
132
+ cleaned_smiles = smiles_string.strip()
133
+
134
+ try:
135
+ mol = Chem.MolFromSmiles(cleaned_smiles)
136
+ if mol is None:
137
+ if not cleaned_smiles:
138
+ raise ValueError("SMILES string was empty.")
139
+ raise ValueError("Invalid SMILES string.")
140
+
141
+ AllChem.Compute2DCoords(mol)
142
+ Chem.Kekulize(mol)
143
+ AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
144
+ conf = mol.GetConformer()
145
+ AllChem.WedgeMolBonds(mol, conf)
146
+ except ValueError as e:
147
+ self.host.statusBar().showMessage(f"Invalid SMILES: {e}")
148
+ return
149
+ except (RuntimeError, TypeError, AttributeError) as e:
150
+ self.host.statusBar().showMessage(f"Error parsing SMILES: {e}")
151
+ return
152
+
153
+ try:
154
+ place_center = self._placement_center()
155
+ rdkit_idx_to_my_id = self._place_mol_atoms(
156
+ mol, mol.GetConformer(), place_center
157
+ )
158
+ self._place_mol_bonds(mol, rdkit_idx_to_my_id)
159
+
160
+ self.host.statusBar().showMessage("Successfully loaded from SMILES.")
161
+ self.host.init_manager.scene.update_all_items()
162
+ self.host.edit_actions_manager.push_undo_state()
163
+ QTimer.singleShot(0, self.host.view_3d_manager.fit_to_view)
164
+
165
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
166
+ self.host.statusBar().showMessage(f"Error loading from SMILES: {e}")
167
+
168
+ def load_from_inchi(self, inchi_string: str) -> None:
169
+ """Add a molecule from an InChI string to the 2D editor."""
170
+ cleaned_inchi = inchi_string.strip()
171
+
172
+ try:
173
+ mol = Chem.MolFromInchi(cleaned_inchi)
174
+ if mol is None:
175
+ if not cleaned_inchi:
176
+ raise ValueError("InChI string was empty.")
177
+ raise ValueError("Invalid InChI string.")
178
+
179
+ AllChem.Compute2DCoords(mol)
180
+ Chem.Kekulize(mol)
181
+ AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
182
+ conf = mol.GetConformer()
183
+ AllChem.WedgeMolBonds(mol, conf)
184
+ except ValueError as e:
185
+ self.host.statusBar().showMessage(f"Invalid InChI: {e}")
186
+ return
187
+ except (RuntimeError, TypeError, AttributeError) as e:
188
+ self.host.statusBar().showMessage(f"Error parsing InChI: {e}")
189
+ return
190
+
191
+ try:
192
+ place_center = self._placement_center()
193
+ rdkit_idx_to_my_id = self._place_mol_atoms(
194
+ mol, mol.GetConformer(), place_center
195
+ )
196
+ self._place_mol_bonds(mol, rdkit_idx_to_my_id)
197
+
198
+ self.host.statusBar().showMessage("Successfully loaded from InChI.")
199
+ self.host.init_manager.scene.update_all_items()
200
+ self.host.edit_actions_manager.push_undo_state()
201
+ QTimer.singleShot(0, self.host.view_3d_manager.fit_to_view)
202
+
203
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
204
+ self.host.statusBar().showMessage(f"Error loading from InChI: {e}")
205
+
206
+
207
+ # Backward-compat aliases
208
+ MainWindowStringImporters = StringImporterManager
@@ -634,10 +634,6 @@ class UserTemplateDialog(QDialog):
634
634
  return
635
635
 
636
636
  if self.save_template_file(filepath, template_data):
637
- # Mark main window as saved
638
- self.main_window.state_manager.has_unsaved_changes = False
639
- self.main_window.state_manager.update_window_title()
640
-
641
637
  QMessageBox.information(
642
638
  self, "Success", f"Template '{name}' saved successfully."
643
639
  )
@@ -282,8 +282,9 @@ class View3DManager:
282
282
  self.host.view_3d_manager.plotter.camera_position = camera_state
283
283
 
284
284
  # Update projection mode and force render
285
- settings = getattr(self, "settings", {})
286
- proj_mode = settings.get("projection_mode", "Perspective")
285
+ proj_mode = self.host.init_manager.settings.get(
286
+ "projection_mode", "Perspective"
287
+ )
287
288
  if hasattr(self.host.view_3d_manager.plotter, "renderer") and hasattr(
288
289
  self.host.view_3d_manager.plotter.renderer, "GetActiveCamera"
289
290
  ):
@@ -16,7 +16,7 @@ from PyQt6.QtGui import QColor, QFont
16
16
  from rdkit import Chem
17
17
 
18
18
  # Version
19
- VERSION = "3.0.6"
19
+ VERSION = "3.1.0"
20
20
 
21
21
  ATOM_RADIUS = 18
22
22
  BOND_OFFSET = 3.5
@@ -1,257 +0,0 @@
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
- # RDKit imports (explicit to satisfy flake8 and used features)
16
- from rdkit import Chem
17
- from rdkit.Chem import AllChem
18
-
19
- # PyQt6 Modules
20
- from PyQt6.QtCore import QPointF, QTimer
21
- from PyQt6.QtWidgets import QInputDialog
22
-
23
- try:
24
- from PyQt6 import sip as _sip # type: ignore
25
-
26
- _sip_isdeleted = getattr(_sip, "isdeleted", None)
27
- except ImportError:
28
- _sip = None
29
- _sip_isdeleted = None
30
-
31
-
32
- # --- Classes ---
33
- class StringImporterManager:
34
- """Mixin for string-based molecular input (SMILES, InChI)."""
35
-
36
- def __init__(self, host):
37
- self.host = host
38
-
39
- def import_smiles_dialog(self) -> None:
40
- """Dialog for SMILES input."""
41
- smiles, ok = QInputDialog.getText(
42
- self.host, "Import SMILES", "Enter SMILES string:"
43
- )
44
- if ok and smiles:
45
- self.load_from_smiles(smiles)
46
-
47
- def import_inchi_dialog(self) -> None:
48
- """Dialog for InChI input."""
49
- inchi, ok = QInputDialog.getText(
50
- self.host, "Import InChI", "Enter InChI string:"
51
- )
52
- if ok and inchi:
53
- self.load_from_inchi(inchi)
54
-
55
- def load_from_smiles(self, smiles_string: str) -> None:
56
- """Load molecule from SMILES string to 2D editor."""
57
- if not self.host.state_manager.check_unsaved_changes():
58
- return # User cancelled
59
-
60
- cleaned_smiles = smiles_string.strip()
61
-
62
- try:
63
- mol = Chem.MolFromSmiles(cleaned_smiles)
64
- if mol is None:
65
- if not cleaned_smiles:
66
- raise ValueError("SMILES string was empty.")
67
- raise ValueError("Invalid SMILES string.")
68
-
69
- AllChem.Compute2DCoords(mol)
70
- Chem.Kekulize(mol)
71
-
72
- AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
73
- conf = mol.GetConformer()
74
- AllChem.WedgeMolBonds(mol, conf)
75
- except ValueError as e:
76
- self.host.statusBar().showMessage(f"Invalid SMILES: {e}")
77
- return
78
- except (RuntimeError, TypeError, AttributeError) as e:
79
- self.host.statusBar().showMessage(f"Error parsing SMILES: {e}")
80
- return
81
-
82
- try:
83
- self.host.ui_manager.restore_ui_for_editing()
84
- self.host.edit_actions_manager.clear_2d_editor(push_to_undo=False)
85
- self.host.view_3d_manager.current_mol = None
86
- self.host.view_3d_manager.plotter.clear()
87
- self.host.init_manager.analysis_action.setEnabled(False)
88
-
89
- conf = mol.GetConformer()
90
- SCALE_FACTOR = 50.0
91
-
92
- view_center = self.host.init_manager.view_2d.mapToScene(
93
- self.host.init_manager.view_2d.viewport().rect().center()
94
- )
95
- positions = [conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
96
- mol_center_x = (
97
- sum(p.x for p in positions) / len(positions) if positions else 0.0
98
- )
99
- mol_center_y = (
100
- sum(p.y for p in positions) / len(positions) if positions else 0.0
101
- )
102
-
103
- rdkit_idx_to_my_id = {}
104
- for i in range(mol.GetNumAtoms()):
105
- atom = mol.GetAtomWithIdx(i)
106
- pos = conf.GetAtomPosition(i)
107
- charge = atom.GetFormalCharge()
108
-
109
- relative_x = pos.x - mol_center_x
110
- relative_y = pos.y - mol_center_y
111
-
112
- scene_x = (relative_x * SCALE_FACTOR) + view_center.x()
113
- scene_y = (-relative_y * SCALE_FACTOR) + view_center.y()
114
-
115
- atom_id = self.host.init_manager.scene.create_atom(
116
- atom.GetSymbol(), QPointF(scene_x, scene_y), charge=charge
117
- )
118
- rdkit_idx_to_my_id[i] = atom_id
119
-
120
- for bond in mol.GetBonds():
121
- b_idx, e_idx = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
122
- b_type = bond.GetBondTypeAsDouble()
123
- b_dir = bond.GetBondDir()
124
- stereo = 0
125
- # Single bond stereo
126
- if b_dir == Chem.BondDir.BEGINWEDGE:
127
- stereo = 1 # Wedge
128
- elif b_dir == Chem.BondDir.BEGINDASH:
129
- stereo = 2 # Dash
130
- # Double bond E/Z
131
- if bond.GetBondType() == Chem.BondType.DOUBLE:
132
- if bond.GetStereo() == Chem.BondStereo.STEREOZ:
133
- stereo = 3 # Z
134
- elif bond.GetStereo() == Chem.BondStereo.STEREOE:
135
- stereo = 4 # E
136
-
137
- if b_idx in rdkit_idx_to_my_id and e_idx in rdkit_idx_to_my_id:
138
- a1_id, a2_id = rdkit_idx_to_my_id[b_idx], rdkit_idx_to_my_id[e_idx]
139
- a1_item = self.host.state_manager.data.atoms[a1_id]["item"]
140
- a2_item = self.host.state_manager.data.atoms[a2_id]["item"]
141
- self.host.init_manager.scene.create_bond(
142
- a1_item, a2_item, bond_order=int(b_type), bond_stereo=stereo
143
- )
144
-
145
- self.host.statusBar().showMessage("Successfully loaded from SMILES.")
146
- self.host.init_manager.scene.update_all_items()
147
- self.host.state_manager.reset_undo_stack()
148
- self.host.state_manager.has_unsaved_changes = False
149
- self.host.state_manager.update_window_title()
150
- QTimer.singleShot(0, self.host.view_3d_manager.fit_to_view)
151
-
152
- except (AttributeError, RuntimeError, ValueError, TypeError) as e:
153
- self.host.statusBar().showMessage(f"Error loading from SMILES: {e}")
154
-
155
- def load_from_inchi(self, inchi_string: str) -> None:
156
- """Load molecule from InChI string to 2D editor."""
157
- if not self.host.state_manager.check_unsaved_changes():
158
- return # User cancelled
159
-
160
- cleaned_inchi = inchi_string.strip()
161
-
162
- try:
163
- mol = Chem.MolFromInchi(cleaned_inchi)
164
- if mol is None:
165
- if not cleaned_inchi:
166
- raise ValueError("InChI string was empty.")
167
- raise ValueError("Invalid InChI string.")
168
-
169
- AllChem.Compute2DCoords(mol)
170
- Chem.Kekulize(mol)
171
-
172
- AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
173
- conf = mol.GetConformer()
174
- AllChem.WedgeMolBonds(mol, conf)
175
- except ValueError as e:
176
- self.host.statusBar().showMessage(f"Invalid InChI: {e}")
177
- return
178
- except (RuntimeError, TypeError, AttributeError) as e:
179
- self.host.statusBar().showMessage(f"Error parsing InChI: {e}")
180
- return
181
-
182
- try:
183
- self.host.ui_manager.restore_ui_for_editing()
184
- self.host.edit_actions_manager.clear_2d_editor(push_to_undo=False)
185
- self.host.view_3d_manager.current_mol = None
186
- self.host.view_3d_manager.plotter.clear()
187
- self.host.init_manager.analysis_action.setEnabled(False)
188
-
189
- conf = mol.GetConformer()
190
- SCALE_FACTOR = 50.0
191
-
192
- view_center = self.host.init_manager.view_2d.mapToScene(
193
- self.host.init_manager.view_2d.viewport().rect().center()
194
- )
195
- positions = [conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
196
- mol_center_x = (
197
- sum(p.x for p in positions) / len(positions) if positions else 0.0
198
- )
199
- mol_center_y = (
200
- sum(p.y for p in positions) / len(positions) if positions else 0.0
201
- )
202
-
203
- rdkit_idx_to_my_id = {}
204
- for i in range(mol.GetNumAtoms()):
205
- atom = mol.GetAtomWithIdx(i)
206
- pos = conf.GetAtomPosition(i)
207
- charge = atom.GetFormalCharge()
208
-
209
- relative_x = pos.x - mol_center_x
210
- relative_y = pos.y - mol_center_y
211
-
212
- scene_x = (relative_x * SCALE_FACTOR) + view_center.x()
213
- scene_y = (-relative_y * SCALE_FACTOR) + view_center.y()
214
-
215
- atom_id = self.host.init_manager.scene.create_atom(
216
- atom.GetSymbol(), QPointF(scene_x, scene_y), charge=charge
217
- )
218
- rdkit_idx_to_my_id[i] = atom_id
219
-
220
- for bond in mol.GetBonds():
221
- b_idx, e_idx = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
222
- b_type = bond.GetBondTypeAsDouble()
223
- b_dir = bond.GetBondDir()
224
- stereo = 0
225
- # Single bond stereo
226
- if b_dir == Chem.BondDir.BEGINWEDGE:
227
- stereo = 1 # Wedge
228
- elif b_dir == Chem.BondDir.BEGINDASH:
229
- stereo = 2 # Dash
230
- # Double bond E/Z
231
- if bond.GetBondType() == Chem.BondType.DOUBLE:
232
- if bond.GetStereo() == Chem.BondStereo.STEREOZ:
233
- stereo = 3 # Z
234
- elif bond.GetStereo() == Chem.BondStereo.STEREOE:
235
- stereo = 4 # E
236
-
237
- if b_idx in rdkit_idx_to_my_id and e_idx in rdkit_idx_to_my_id:
238
- a1_id, a2_id = rdkit_idx_to_my_id[b_idx], rdkit_idx_to_my_id[e_idx]
239
- a1_item = self.host.state_manager.data.atoms[a1_id]["item"]
240
- a2_item = self.host.state_manager.data.atoms[a2_id]["item"]
241
- self.host.init_manager.scene.create_bond(
242
- a1_item, a2_item, bond_order=int(b_type), bond_stereo=stereo
243
- )
244
-
245
- self.host.statusBar().showMessage("Successfully loaded from InChI.")
246
- self.host.init_manager.scene.update_all_items()
247
- self.host.state_manager.reset_undo_stack()
248
- self.host.state_manager.has_unsaved_changes = False
249
- self.host.state_manager.update_window_title()
250
- QTimer.singleShot(0, self.host.view_3d_manager.fit_to_view)
251
-
252
- except (AttributeError, RuntimeError, ValueError, TypeError) as e:
253
- self.host.statusBar().showMessage(f"Error loading from InChI: {e}")
254
-
255
-
256
- # Backward-compat aliases
257
- MainWindowStringImporters = StringImporterManager
File without changes
File without changes
File without changes