MoleditPy-linux 4.1.1__tar.gz → 4.1.3__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 (81) hide show
  1. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/__init__.py +0 -2
  5. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/plugins/plugin_interface.py +14 -5
  6. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/plugins/plugin_manager.py +2 -1
  7. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/io_logic.py +184 -111
  8. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/main_window.py +1 -2
  9. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +102 -0
  10. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/view_3d_logic.py +1 -1
  11. moleditpy_linux-4.1.3/src/moleditpy_linux/utils/__init__.py +11 -0
  12. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/utils/constants.py +5 -0
  13. moleditpy_linux-4.1.1/src/moleditpy_linux/core/__init__.py +0 -0
  14. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/LICENSE +0 -0
  15. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/README.md +0 -0
  16. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/setup.cfg +0 -0
  17. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  18. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  19. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  20. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  21. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  22. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/__main__.py +0 -0
  23. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  24. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/assets/icon.icns +0 -0
  25. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/assets/icon.ico +0 -0
  26. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/assets/icon.png +0 -0
  27. {moleditpy_linux-4.1.1/src/moleditpy_linux/plugins → moleditpy_linux-4.1.3/src/moleditpy_linux/core}/__init__.py +0 -0
  28. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  29. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/core/molecular_data.py +0 -0
  30. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/main.py +0 -0
  31. {moleditpy_linux-4.1.1/src/moleditpy_linux/ui/settings_tabs → moleditpy_linux-4.1.3/src/moleditpy_linux/plugins}/__init__.py +0 -0
  32. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  33. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/__init__.py +0 -0
  34. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  35. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  36. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  37. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  38. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  39. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/app_state.py +0 -0
  40. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/atom_item.py +0 -0
  41. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  42. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  43. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/bond_item.py +0 -0
  44. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  45. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  46. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  47. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  48. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  49. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  50. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  51. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  52. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  53. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  54. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  55. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  56. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/export_logic.py +0 -0
  57. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  58. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  59. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  60. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  61. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  62. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  63. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  64. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  65. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  66. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  67. {moleditpy_linux-4.1.1/src/moleditpy_linux/utils → moleditpy_linux-4.1.3/src/moleditpy_linux/ui/settings_tabs}/__init__.py +0 -0
  68. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  69. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  70. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  71. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  72. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/string_importers.py +0 -0
  73. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  74. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  75. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  76. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  77. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  78. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  79. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/utils/default_settings.py +0 -0
  80. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  81. {moleditpy_linux-4.1.1 → moleditpy_linux-4.1.3}/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.3
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.3"
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.3
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
@@ -8,8 +8,6 @@ Author: Hiromichi Yokoyama
8
8
  License: GPL-3.0 license
9
9
  Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
-
12
- Top-level package for moleditpy_linux.
13
11
  """
14
12
 
15
13
  import importlib.util # noqa: F401
@@ -190,11 +190,9 @@ class PluginContext:
190
190
  Get or set the current molecule (RDKit Mol object). Shortcut for current_molecule.
191
191
  """
192
192
  mw = self.get_main_window()
193
- return (
194
- mw.view_3d_manager.current_mol
195
- if mw and hasattr(mw, "view_3d_manager")
196
- else None
197
- )
193
+ if mw and hasattr(mw, "view_3d_manager"):
194
+ return mw.view_3d_manager.current_mol
195
+ return None
198
196
 
199
197
  @current_mol.setter
200
198
  def current_mol(self, mol: Any) -> None:
@@ -499,6 +497,17 @@ class PluginContext:
499
497
  if mw and hasattr(mw, "string_importer_manager"):
500
498
  mw.string_importer_manager.load_from_smiles(smiles)
501
499
 
500
+ def show_xyz_data(
501
+ self, xyz_text: str, source_name: str = "XYZ data"
502
+ ) -> Optional[Any]:
503
+ """Display XYZ text in the 3D viewer and return the loaded RDKit Mol."""
504
+ mw = self.get_main_window()
505
+ if mw and hasattr(mw, "io_manager"):
506
+ show = getattr(mw.io_manager, "show_xyz_data", None)
507
+ if show is not None:
508
+ return show(xyz_text, source_name=source_name)
509
+ return None
510
+
502
511
  def to_xyz_block(self) -> Optional[str]:
503
512
  """Return the current 3D structure as an XYZ block (only element x y z lines)."""
504
513
  mol = self.current_mol
@@ -588,7 +588,8 @@ class PluginManager:
588
588
  selected_atom_ids.add(item.atom_id)
589
589
 
590
590
  # Now map these editor IDs to RDKit indices
591
- mol = getattr(self.main_window, "current_mol", None)
591
+ v3d = getattr(self.main_window, "view_3d_manager", None)
592
+ mol = v3d.current_mol if v3d else None
592
593
  if mol and selected_atom_ids:
593
594
  for i in range(mol.GetNumAtoms()):
594
595
  atom = mol.GetAtomWithIdx(i)
@@ -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()
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
19
19
  from rdkit import Chem
20
20
 
21
21
  # PyQt6 Modules
22
+ import copy
22
23
  from PyQt6.QtCore import pyqtSignal
23
24
  from PyQt6.QtWidgets import QMainWindow, QMessageBox
24
25
 
@@ -254,8 +255,6 @@ class MainWindow(QMainWindow):
254
255
 
255
256
  def save_state_snapshot(self) -> None:
256
257
  """Create a deep copy snapshot of the current state for undo/redo comparison."""
257
- import copy
258
-
259
258
  try:
260
259
  self.state_manager.saved_state = copy.deepcopy(
261
260
  self.state_manager.get_current_state()
@@ -56,6 +56,8 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
56
56
  self.mouse_moved_during_drag: bool = False
57
57
  self._consume_next_left_release: bool = False
58
58
  self.highlight_actor: Optional[pv.Actor] = None
59
+ self.original_style: Optional[Any] = None
60
+ self.rubber_band_style: Optional[Any] = None
59
61
 
60
62
  self.widgets: dict[str, Any] = {}
61
63
 
@@ -211,6 +213,13 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
211
213
  def _init_buttons_ui(self, layout: QVBoxLayout) -> None:
212
214
  """Initialize bottom buttons."""
213
215
  button_layout = QHBoxLayout()
216
+
217
+ box_select_btn = QPushButton("Box Selection: OFF")
218
+ box_select_btn.setCheckable(True)
219
+ box_select_btn.clicked.connect(self.toggle_box_selection)
220
+ self.widgets["box_select_btn"] = box_select_btn
221
+ button_layout.addWidget(box_select_btn)
222
+
214
223
  clear_btn = QPushButton("Clear Selection")
215
224
  clear_btn.clicked.connect(self.clear_selection)
216
225
  self.widgets["clear_button"] = clear_btn
@@ -238,6 +247,24 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
238
247
 
239
248
  e_type = event.type()
240
249
 
250
+ btn = self.widgets.get("box_select_btn")
251
+ box_selection_on = btn is not None and btn.isChecked()
252
+
253
+ if e_type == QEvent.Type.MouseButtonPress:
254
+ self._click_press_pos = getattr(event, "pos", lambda: None)()
255
+ elif e_type == QEvent.Type.MouseButtonRelease:
256
+ if hasattr(self, "_click_press_pos") and self._click_press_pos is not None:
257
+ pos = getattr(event, "pos", lambda: None)()
258
+ if pos:
259
+ diff = pos - self._click_press_pos
260
+ if diff.manhattanLength() < 3:
261
+ if box_selection_on:
262
+ self.clear_selection()
263
+ self._click_press_pos = None
264
+
265
+ if box_selection_on:
266
+ return False
267
+
241
268
  if e_type == QEvent.Type.MouseButtonDblClick:
242
269
  # Ignore double clicks and reset state
243
270
  self.is_dragging_group = False
@@ -645,3 +672,78 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
645
672
  self.update_display()
646
673
  self.is_dragging_group = False
647
674
  self.drag_start_pos = None
675
+
676
+ def toggle_box_selection(self, checked: bool) -> None:
677
+ """Toggle Box Selection mode using PyVista's rectangle picker."""
678
+ plotter = self.main_window.view_3d_manager.plotter
679
+ if plotter is None or plotter.interactor is None:
680
+ return
681
+
682
+ btn = self.widgets["box_select_btn"]
683
+ if checked:
684
+ btn.setText("Box Selection: ON")
685
+ # Save original style if not saved
686
+ if self.original_style is None:
687
+ self.original_style = plotter.interactor.GetInteractorStyle()
688
+
689
+ # Using PyVista's built-in picking which reliably draws the box correctly
690
+ plotter.enable_rectangle_picking(
691
+ callback=self.on_rectangle_picked,
692
+ show_message=False,
693
+ start=True,
694
+ color="red",
695
+ )
696
+ else:
697
+ btn.setText("Box Selection: OFF")
698
+ plotter.disable_picking()
699
+ # Restore original style
700
+ if self.original_style is not None:
701
+ plotter.interactor.SetInteractorStyle(self.original_style)
702
+ self.rubber_band_style = None
703
+
704
+ def on_rectangle_picked(self, selection: Any) -> None:
705
+ """Handle PyVista rectangle picking callback."""
706
+ if not hasattr(selection, "viewport"):
707
+ return
708
+
709
+ x0, y0, x1, y1 = selection.viewport
710
+ x_min = min(x0, x1)
711
+ x_max = max(x0, x1)
712
+ y_min = min(y0, y1)
713
+ y_max = max(y0, y1)
714
+
715
+ # If the drag box is small, treat it as a single click to clear selection
716
+ if abs(x_max - x_min) < 15 and abs(y_max - y_min) < 15:
717
+ self.clear_selection()
718
+ return
719
+
720
+ plotter = self.main_window.view_3d_manager.plotter
721
+ if plotter is None:
722
+ return
723
+ renderer = plotter.renderer
724
+
725
+ positions = self.main_window.view_3d_manager.atom_positions_3d
726
+ if positions is not None:
727
+ added = False
728
+ for atom_idx, pos in enumerate(positions):
729
+ renderer.SetWorldPoint(float(pos[0]), float(pos[1]), float(pos[2]), 1.0)
730
+ renderer.WorldToDisplay()
731
+ display = renderer.GetDisplayPoint()
732
+
733
+ if x_min <= display[0] <= x_max and y_min <= display[1] <= y_max:
734
+ if atom_idx not in self.selected_atoms:
735
+ self.selected_atoms.add(atom_idx)
736
+ added = True
737
+
738
+ if added:
739
+ self.show_atom_labels()
740
+ self.update_display()
741
+
742
+ def reject(self) -> None:
743
+ """Clean up when dialog closes."""
744
+ # Ensure we restore the interactor style if it was left in Box Selection mode
745
+ btn = self.widgets.get("box_select_btn")
746
+ if btn and btn.isChecked():
747
+ btn.setChecked(False)
748
+ self.toggle_box_selection(False)
749
+ super().reject()
@@ -2,7 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
 
4
4
  """
5
- MoleditPy  EA Python-based molecular editing software
5
+ MoleditPy — A Python-based molecular editing software
6
6
 
7
7
  Author: Hiromichi Yokoyama
8
8
  License: GPL-3.0 license
@@ -0,0 +1,11 @@
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
+ """
@@ -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