MoleditPy-linux 4.1.1__tar.gz → 4.1.2__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 (80) hide show
  1. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/plugins/plugin_interface.py +11 -0
  5. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/io_logic.py +184 -111
  6. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/utils/constants.py +5 -0
  7. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/LICENSE +0 -0
  8. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/README.md +0 -0
  9. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/setup.cfg +0 -0
  10. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  11. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  12. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  13. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  14. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  15. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/__init__.py +0 -0
  16. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/__main__.py +0 -0
  17. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  18. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/assets/icon.icns +0 -0
  19. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/assets/icon.ico +0 -0
  20. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/assets/icon.png +0 -0
  21. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/core/__init__.py +0 -0
  22. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  23. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/core/molecular_data.py +0 -0
  24. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/main.py +0 -0
  25. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/plugins/__init__.py +0 -0
  26. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  27. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  28. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/__init__.py +0 -0
  29. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  30. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  31. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  32. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  33. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  34. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/app_state.py +0 -0
  35. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/atom_item.py +0 -0
  36. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  37. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  38. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/bond_item.py +0 -0
  39. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  40. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  41. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  42. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  43. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  44. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  45. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  46. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  47. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  48. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  49. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  50. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  51. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/export_logic.py +0 -0
  52. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  53. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/main_window.py +0 -0
  54. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  55. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  56. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  57. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  58. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  59. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  60. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  61. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  62. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  63. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  64. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  65. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  66. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  67. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  68. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  69. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/string_importers.py +0 -0
  70. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  71. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  72. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  73. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  74. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  75. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  76. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  77. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/utils/__init__.py +0 -0
  78. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/utils/default_settings.py +0 -0
  79. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  80. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.2}/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.1.1
3
+ Version: 4.1.2
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.1.1"
8
+ version = "4.1.2"
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.1.1
3
+ Version: 4.1.2
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
@@ -499,6 +499,17 @@ class PluginContext:
499
499
  if mw and hasattr(mw, "string_importer_manager"):
500
500
  mw.string_importer_manager.load_from_smiles(smiles)
501
501
 
502
+ def show_xyz_data(
503
+ self, xyz_text: str, source_name: str = "XYZ data"
504
+ ) -> Optional[Any]:
505
+ """Display XYZ text in the 3D viewer and return the loaded RDKit Mol."""
506
+ mw = self.get_main_window()
507
+ if mw and hasattr(mw, "io_manager"):
508
+ show = getattr(mw.io_manager, "show_xyz_data", None)
509
+ if show is not None:
510
+ return show(xyz_text, source_name=source_name)
511
+ return None
512
+
502
513
  def to_xyz_block(self) -> Optional[str]:
503
514
  """Return the current 3D structure as an XYZ block (only element x y z lines)."""
504
515
  mol = self.current_mol
@@ -34,7 +34,7 @@ from PyQt6.QtWidgets import (
34
34
  from rdkit import Chem
35
35
  from rdkit.Chem import AllChem, rdGeometry, rdMolTransforms, Descriptors
36
36
 
37
- from ..utils.constants import COVALENT_RADII, VERSION
37
+ from ..utils.constants import COVALENT_RADII, DUMMY_XYZ_SYMBOLS, VERSION
38
38
 
39
39
 
40
40
  class IOManager:
@@ -84,139 +84,210 @@ class IOManager:
84
84
  lines[3] = self.fix_mol_counts_line(lines[3])
85
85
  return "\n".join(lines)
86
86
 
87
- def load_xyz_file(self, file_path: str) -> Optional[Any]:
88
- """Load XYZ file and create RDKit Mol with charge prompt and bond determination."""
89
- if not self.host.state_manager.check_unsaved_changes():
90
- return None
91
-
87
+ def _normalize_xyz_symbol(self, raw_symbol: str) -> Tuple[str, bool]:
88
+ """Return the RDKit symbol for an XYZ atom and whether it is a dummy."""
89
+ stripped = raw_symbol.strip()
90
+ if ":" in stripped:
91
+ return "*", True
92
+ if stripped.upper() in DUMMY_XYZ_SYMBOLS:
93
+ return "*", True
94
+ symbol = stripped.capitalize()
95
+ try:
96
+ atomic_num = Chem.GetPeriodicTable().GetAtomicNumber(symbol)
97
+ except (RuntimeError, ValueError, TypeError):
98
+ return "*", True
99
+ if atomic_num <= 0:
100
+ return "*", True
101
+ return symbol, False
102
+
103
+ def _mol_from_xyz_lines(self, raw_lines: list[str]) -> Any:
104
+ """Create an RDKit molecule from XYZ text lines."""
105
+ lines = [ln.strip() for ln in raw_lines if not ln.strip().startswith("#")]
106
+ while lines and not lines[0]:
107
+ lines.pop(0)
108
+
109
+ if not lines:
110
+ raise ValueError("XYZ file format error: too few lines")
111
+
112
+ atom_start = 2
92
113
  try:
93
- with open(file_path, "r", encoding="utf-8") as f:
94
- raw_lines = f.readlines()
95
-
96
- lines = [ln.strip() for ln in raw_lines if not ln.strip().startswith("#")]
97
- while lines and not lines[0]:
98
- lines.pop(0)
99
-
100
114
  if len(lines) < 2:
101
115
  raise ValueError("XYZ file format error: too few lines")
102
-
103
116
  num_atoms = int(lines[0])
104
117
  if num_atoms == 0:
105
118
  raise ValueError("XYZ file has zero atoms")
119
+ except ValueError as exc:
120
+ # Not a standard headed XYZ — treat all lines as atom rows
121
+ if "zero atoms" in str(exc) or "too few" in str(exc):
122
+ raise
123
+ num_atoms = len(lines)
124
+ atom_start = 0
125
+
126
+ atoms_data = []
127
+ has_dummy_atoms = False
128
+ atom_lines = lines[atom_start : atom_start + num_atoms]
129
+ if len(atom_lines) < num_atoms:
130
+ raise ValueError("XYZ file format error: fewer atom rows than expected")
131
+
132
+ for i, line in enumerate(atom_lines):
133
+ parts = line.split()
134
+ if len(parts) < 4:
135
+ raise ValueError(f"Invalid atom data at line {atom_start + i + 1}")
136
+ raw_symbol = parts[0]
137
+ symbol, is_dummy = self._normalize_xyz_symbol(raw_symbol)
138
+ has_dummy_atoms = has_dummy_atoms or is_dummy
139
+ atoms_data.append(
140
+ (symbol, float(parts[1]), float(parts[2]), float(parts[3]))
141
+ )
106
142
 
107
- atoms_data = []
108
- for i, line in enumerate(lines[2 : 2 + num_atoms]):
109
- parts = line.split()
110
- if len(parts) < 4:
111
- raise ValueError(f"Invalid atom data at line {i + 3}")
112
- symbol = parts[0].capitalize()
113
- try:
114
- Chem.Atom(symbol)
115
- except (RuntimeError, ValueError):
116
- settings = self.host.init_manager.settings
117
- if settings.get("skip_chemistry_checks", False):
118
- symbol = "C"
119
- else:
120
- raise ValueError(f"Unrecognized element symbol: {parts[0]}")
121
- atoms_data.append(
122
- (symbol, float(parts[1]), float(parts[2]), float(parts[3]))
123
- )
124
-
125
- if not atoms_data:
126
- raise ValueError("No valid atoms found in XYZ file")
143
+ if not atoms_data:
144
+ raise ValueError("No valid atoms found in XYZ file")
127
145
 
128
- mol = Chem.RWMol()
129
- conf = Chem.Conformer(len(atoms_data))
130
- for i, (symbol, x, y, z) in enumerate(atoms_data):
131
- atom = Chem.Atom(symbol)
132
- atom.SetIntProp("xyz_unique_id", i)
133
- mol.AddAtom(atom)
134
- conf.SetAtomPosition(i, rdGeometry.Point3D(x, y, z))
135
- mol.AddConformer(conf)
146
+ mol = Chem.RWMol()
147
+ conf = Chem.Conformer(len(atoms_data))
148
+ for i, (symbol, x, y, z) in enumerate(atoms_data):
149
+ atom = Chem.Atom(symbol)
150
+ atom.SetIntProp("xyz_unique_id", i)
151
+ if atom.GetAtomicNum() == 0:
152
+ atom.SetProp("xyz_original_symbol", atom_lines[i].split()[0])
153
+ mol.AddAtom(atom)
154
+ conf.SetAtomPosition(i, rdGeometry.Point3D(x, y, z))
155
+ mol.AddConformer(conf)
136
156
 
137
- settings = self.host.init_manager.settings
138
- skip_checks = bool(settings.get("skip_chemistry_checks", False))
157
+ settings = self.host.init_manager.settings
158
+ skip_checks = bool(settings.get("skip_chemistry_checks", False))
139
159
 
140
- def _set_prop(m: Chem.Mol, key: str, val: Any) -> None:
160
+ def _set_prop(m: Chem.Mol, key: str, val: Any) -> None:
161
+ try:
162
+ if isinstance(val, int):
163
+ m.SetIntProp(key, val)
164
+ elif isinstance(val, float):
165
+ m.SetDoubleProp(key, val)
166
+ except (RuntimeError, TypeError, ValueError):
167
+ # Safe defensive fallback catching RuntimeError, TypeError, ValueError
168
+ pass
169
+
170
+ def _process(charge_val: int, use_rd_determine: bool = True) -> Any:
171
+ if use_rd_determine:
141
172
  try:
142
- if isinstance(val, int):
143
- m.SetIntProp(key, val)
144
- elif isinstance(val, float):
145
- m.SetDoubleProp(key, val)
146
- except (RuntimeError, TypeError, ValueError):
147
- # Safe defensive fallback catching RuntimeError, TypeError, ValueError
148
- pass
149
-
150
- def _process(charge_val: int, use_rd_determine: bool = True) -> Any:
151
- if use_rd_determine:
152
- try:
153
- from rdkit.Chem import rdDetermineBonds
173
+ from rdkit.Chem import rdDetermineBonds
154
174
 
155
- mol_copy = Chem.RWMol(mol)
156
- rdDetermineBonds.DetermineBonds(mol_copy, charge=charge_val)
157
- candidate = mol_copy.GetMol()
158
- _set_prop(candidate, "_xyz_charge", charge_val)
159
- return candidate
160
- except (RuntimeError, ValueError, TypeError) as e:
161
- raise e
162
- else:
163
- self.estimate_bonds_from_distances(mol)
164
- candidate = mol.GetMol()
175
+ mol_copy = Chem.RWMol(mol)
176
+ rdDetermineBonds.DetermineBonds(mol_copy, charge=charge_val)
177
+ candidate = mol_copy.GetMol()
165
178
  _set_prop(candidate, "_xyz_charge", charge_val)
166
179
  return candidate
167
-
168
- if skip_checks:
169
- final_mol = _process(0, use_rd_determine=False)
170
- _set_prop(final_mol, "_xyz_skip_checks", 1)
180
+ except (RuntimeError, ValueError, TypeError):
181
+ raise
171
182
  else:
172
- final_mol = None
173
- settings = self.host.init_manager.settings
174
- # First try with charge 0 (per user's 'first try with 0 then ask' requirement)
175
- # but only if "Always ask" is not explicitly enabled in settings.
176
- if not settings.get("always_ask_charge", False):
177
- try:
178
- final_mol = _process(0, use_rd_determine=True)
179
- except (RuntimeError, ValueError, TypeError):
180
- final_mol = None
181
-
182
- # If still no final_mol (because always_ask is True, or charge 0 failed)
183
- if final_mol is None:
184
- while True:
185
- prompt_fn = getattr(self, "prompt_for_charge", None)
186
- if callable(prompt_fn):
187
- result = prompt_fn()
188
- if isinstance(result, tuple) and len(result) == 3:
189
- charge_val, ok, skip_flag = result
190
- else:
191
- charge_val, ok, skip_flag = 0, True, False
183
+ self.estimate_bonds_from_distances(mol)
184
+ candidate = mol.GetMol()
185
+ _set_prop(candidate, "_xyz_charge", charge_val)
186
+ return candidate
187
+
188
+ if skip_checks or has_dummy_atoms:
189
+ final_mol = _process(0, use_rd_determine=False)
190
+ _set_prop(final_mol, "_xyz_skip_checks", 1)
191
+ else:
192
+ final_mol = None
193
+ # First try with charge 0 (per user's 'first try with 0 then ask' requirement)
194
+ # but only if "Always ask" is not explicitly enabled in settings.
195
+ if not settings.get("always_ask_charge", False):
196
+ try:
197
+ final_mol = _process(0, use_rd_determine=True)
198
+ except (RuntimeError, ValueError, TypeError):
199
+ final_mol = None
200
+
201
+ # If still no final_mol (because always_ask is True, or charge 0 failed)
202
+ if final_mol is None:
203
+ while True:
204
+ prompt_fn = getattr(self, "prompt_for_charge", None)
205
+ if callable(prompt_fn):
206
+ result = prompt_fn()
207
+ if isinstance(result, tuple) and len(result) == 3:
208
+ charge_val, ok, skip_flag = result
192
209
  else:
193
210
  charge_val, ok, skip_flag = 0, True, False
211
+ else:
212
+ charge_val, ok, skip_flag = 0, True, False
213
+
214
+ if not ok:
215
+ return None
216
+ if skip_flag:
217
+ final_mol = _process(0, use_rd_determine=False)
218
+ _set_prop(final_mol, "_xyz_skip_checks", 1)
219
+ break
220
+ try:
221
+ final_mol = _process(charge_val, use_rd_determine=True)
222
+ break
223
+ except (RuntimeError, ValueError, TypeError) as e:
224
+ if self.host.statusBar():
225
+ self.host.statusBar().showMessage(
226
+ f"Chemistry failed for charge {charge_val}: {e}. Try a different charge or skip."
227
+ )
228
+ if not callable(prompt_fn):
229
+ raise e
194
230
 
195
- if not ok:
196
- return None
197
- if skip_flag:
198
- final_mol = _process(0, use_rd_determine=False)
199
- _set_prop(final_mol, "_xyz_skip_checks", 1)
200
- break
201
- try:
202
- final_mol = _process(charge_val, use_rd_determine=True)
203
- break
204
- except (RuntimeError, ValueError, TypeError) as e:
205
- if self.host.statusBar():
206
- self.host.statusBar().showMessage(
207
- f"Chemistry failed for charge {charge_val}: {e}. Try a different charge or skip."
208
- )
209
- if not callable(prompt_fn):
210
- raise e
211
-
212
- if final_mol:
213
- final_mol.xyz_atom_data = atoms_data
214
- return final_mol
231
+ if final_mol:
232
+ final_mol.xyz_atom_data = atoms_data
233
+ return final_mol
234
+
235
+ def load_xyz_file(self, file_path: str) -> Optional[Any]:
236
+ """Load XYZ file and create RDKit Mol with charge prompt and bond determination."""
237
+ if not self.host.state_manager.check_unsaved_changes():
238
+ return None
215
239
 
240
+ try:
241
+ with open(file_path, "r", encoding="utf-8") as f:
242
+ return self._mol_from_xyz_lines(f.readlines())
216
243
  except (RuntimeError, TypeError, ValueError, UnicodeDecodeError) as e:
217
244
  self.host.statusBar().showMessage(f"Error parsing XYZ file: {e}")
218
245
  return None
219
246
 
247
+ def load_xyz_block(self, xyz_text: str) -> Optional[Any]:
248
+ """Load XYZ text and create an RDKit Mol without opening a file dialog."""
249
+ try:
250
+ return self._mol_from_xyz_lines(xyz_text.splitlines())
251
+ except (RuntimeError, TypeError, ValueError, UnicodeDecodeError) as e:
252
+ self.host.statusBar().showMessage(f"Error parsing XYZ data: {e}")
253
+ return None
254
+
255
+ def show_xyz_data(
256
+ self, xyz_text: str, source_name: str = "XYZ data"
257
+ ) -> Optional[Any]:
258
+ """Load XYZ text, set it as the current molecule, and draw it in 3D."""
259
+ try:
260
+ mol = self.load_xyz_block(xyz_text)
261
+ if mol is None:
262
+ return None
263
+
264
+ self.host.edit_actions_manager.clear_all(skip_check=True)
265
+ self.host.set_current_molecule(mol)
266
+ self.host.set_atom_id_to_rdkit_idx_map({})
267
+
268
+ skip_flag = False
269
+ if mol.HasProp("_xyz_skip_checks"):
270
+ skip_flag = bool(mol.GetIntProp("_xyz_skip_checks"))
271
+ self.host.is_xyz_derived = skip_flag or (mol.GetNumBonds() == 0)
272
+
273
+ self.host.view_3d_manager.draw_molecule_3d(mol)
274
+ self.host.ui_manager.enter_3d_viewer_mode()
275
+ self.host.ui_manager.enable_3d_features(True)
276
+ self.host.view_3d_manager.update_atom_id_menu_text()
277
+ self.host.view_3d_manager.update_atom_id_menu_state()
278
+
279
+ if self.host.statusBar():
280
+ self.host.statusBar().showMessage(
281
+ f"3D Viewer Mode: Loaded {source_name}"
282
+ )
283
+ self.host.set_has_unsaved_changes(False)
284
+ self.host.state_manager.update_window_title()
285
+ return mol
286
+ except (RuntimeError, TypeError, ValueError, AttributeError) as e:
287
+ if self.host.statusBar():
288
+ self.host.statusBar().showMessage(f"XYZ display failed: {e}")
289
+ return None
290
+
220
291
  def prompt_for_charge(self) -> Tuple[Optional[int], bool, bool]:
221
292
  """Show dialog to prompt user for molecular charge when loading XYZ files."""
222
293
  dialog = QDialog(self.host)
@@ -261,6 +332,8 @@ class IOManager:
261
332
  for j in range(i + 1, num_atoms):
262
333
  atom_i = mol.GetAtomWithIdx(i)
263
334
  atom_j = mol.GetAtomWithIdx(j)
335
+ if atom_i.GetAtomicNum() == 0 or atom_j.GetAtomicNum() == 0:
336
+ continue
264
337
  distance = rdMolTransforms.GetBondLength(conf, i, j)
265
338
  symbol_i = atom_i.GetSymbol()
266
339
  symbol_j = atom_j.GetSymbol()
@@ -55,6 +55,11 @@ BOND_OFFSET = 3.5
55
55
  DEFAULT_BOND_LENGTH = 75 # Standard bond length used in templates
56
56
  CLIPBOARD_MIME_TYPE = "application/x-moleditpy-fragment"
57
57
 
58
+ # XYZ dummy/pseudo-atom labels that map to RDKit wildcard atom (*)
59
+ DUMMY_XYZ_SYMBOLS: frozenset[str] = frozenset(
60
+ {"*", "-", "X", "DA", "DU", "DUM", "DUMMY", "Q", "BQ", "LP"}
61
+ )
62
+
58
63
  # Physical bond length (approximate) used to convert scene pixels to angstroms.
59
64
  # DEFAULT_BOND_LENGTH is the length in pixels used in the editor UI for a typical bond.
60
65
  # Many molecular file formats expect coordinates in angstroms; use ~1.5 Å as a typical single-bond length.
File without changes