MoleditPy-linux 2.4.1__py3-none-any.whl
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.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,766 @@
|
|
|
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 PyQt6.QtCore import QObject
|
|
14
|
+
|
|
15
|
+
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
|
16
|
+
|
|
17
|
+
# RDKit
|
|
18
|
+
from rdkit import Chem
|
|
19
|
+
from rdkit.Chem import AllChem
|
|
20
|
+
from rdkit.Chem import rdGeometry
|
|
21
|
+
from rdkit.DistanceGeometry import DoTriangleSmoothing
|
|
22
|
+
import math
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Use centralized Open Babel availability from package-level __init__
|
|
27
|
+
# Use per-package modules availability (local __init__).
|
|
28
|
+
# Prefer package-relative import when running as `python -m moleditpy` and
|
|
29
|
+
# fall back to a top-level import when running as a script. This mirrors the
|
|
30
|
+
# import style used in other modules and keeps the package robust.
|
|
31
|
+
try:
|
|
32
|
+
from . import OBABEL_AVAILABLE
|
|
33
|
+
except Exception:
|
|
34
|
+
from modules import OBABEL_AVAILABLE
|
|
35
|
+
# Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
|
|
36
|
+
if OBABEL_AVAILABLE:
|
|
37
|
+
try:
|
|
38
|
+
from openbabel import pybel
|
|
39
|
+
except Exception:
|
|
40
|
+
# If import fails here, disable OBABEL locally; avoid raising
|
|
41
|
+
pybel = None
|
|
42
|
+
OBABEL_AVAILABLE = False
|
|
43
|
+
print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
|
|
44
|
+
else:
|
|
45
|
+
pybel = None
|
|
46
|
+
|
|
47
|
+
class CalculationWorker(QObject):
|
|
48
|
+
status_update = pyqtSignal(str)
|
|
49
|
+
finished = pyqtSignal(object)
|
|
50
|
+
error = pyqtSignal(object) # emit (worker_id, msg) tuples for robustness
|
|
51
|
+
# Per-worker start signal to avoid sharing a single MainWindow signal
|
|
52
|
+
# among many worker instances (which causes race conditions and stale
|
|
53
|
+
# workers being started on a single emission).
|
|
54
|
+
start_work = pyqtSignal(str, object)
|
|
55
|
+
|
|
56
|
+
def __init__(self, parent=None):
|
|
57
|
+
super().__init__(parent)
|
|
58
|
+
# Connect the worker's own start signal to its run slot. This
|
|
59
|
+
# guarantees that only this worker will respond when start_work
|
|
60
|
+
# is emitted (prevents cross-talk between workers).
|
|
61
|
+
try:
|
|
62
|
+
self.start_work.connect(self.run_calculation)
|
|
63
|
+
except Exception:
|
|
64
|
+
# Be defensive: if connection fails, continue; the caller may
|
|
65
|
+
# fallback to emitting directly.
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@pyqtSlot(str, object)
|
|
69
|
+
def run_calculation(self, mol_block, options=None):
|
|
70
|
+
try:
|
|
71
|
+
# The worker may be asked to halt via a shared set `halt_ids` and
|
|
72
|
+
# identifies its own run by options['worker_id'] (int).
|
|
73
|
+
worker_id = None
|
|
74
|
+
try:
|
|
75
|
+
worker_id = options.get('worker_id') if options else None
|
|
76
|
+
except Exception:
|
|
77
|
+
worker_id = None
|
|
78
|
+
|
|
79
|
+
# If a caller starts a worker without providing a worker_id, treat
|
|
80
|
+
# it as a "global" worker that can still be halted via a global
|
|
81
|
+
# halt flag. Emit a single status warning so callers know that
|
|
82
|
+
# the worker was started without an identifier.
|
|
83
|
+
_warned_no_worker_id = False
|
|
84
|
+
if worker_id is None:
|
|
85
|
+
try:
|
|
86
|
+
# best-effort, swallow any errors (signals may not be connected)
|
|
87
|
+
self.status_update.emit("Warning: worker started without 'worker_id'; will listen for global halt signals.")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
_warned_no_worker_id = True
|
|
91
|
+
|
|
92
|
+
def _check_halted():
|
|
93
|
+
try:
|
|
94
|
+
halt_ids = getattr(self, 'halt_ids', None)
|
|
95
|
+
# If worker_id is None, allow halting via a global mechanism:
|
|
96
|
+
# - an explicit attribute `halt_all` set to True on the worker
|
|
97
|
+
# - the shared `halt_ids` set containing None or the sentinel 'ALL'
|
|
98
|
+
if worker_id is None:
|
|
99
|
+
if getattr(self, 'halt_all', False):
|
|
100
|
+
return True
|
|
101
|
+
if halt_ids is None:
|
|
102
|
+
return False
|
|
103
|
+
# Support both None-in-set and string sentinel for compatibility
|
|
104
|
+
return (None in halt_ids) or ('ALL' in halt_ids)
|
|
105
|
+
|
|
106
|
+
if halt_ids is None:
|
|
107
|
+
return False
|
|
108
|
+
return (worker_id in halt_ids)
|
|
109
|
+
except Exception:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Safe-emission helpers: do nothing if this worker has been halted.
|
|
113
|
+
def _safe_status(msg):
|
|
114
|
+
try:
|
|
115
|
+
if _check_halted():
|
|
116
|
+
return
|
|
117
|
+
self.status_update.emit(msg)
|
|
118
|
+
except Exception:
|
|
119
|
+
# Swallow any signal-emission errors to avoid crashing the worker
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def _safe_finished(payload):
|
|
123
|
+
try:
|
|
124
|
+
# Attempt to emit the payload; preserve existing fallback behavior
|
|
125
|
+
try:
|
|
126
|
+
self.finished.emit(payload)
|
|
127
|
+
except TypeError:
|
|
128
|
+
# Some slots/old code may expect a single-molecule arg; try that too
|
|
129
|
+
try:
|
|
130
|
+
# If payload was a tuple like (worker_id, mol), try sending the second element
|
|
131
|
+
if isinstance(payload, (list, tuple)) and len(payload) >= 2:
|
|
132
|
+
self.finished.emit(payload[1])
|
|
133
|
+
else:
|
|
134
|
+
self.finished.emit(payload)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def _safe_error(msg):
|
|
141
|
+
try:
|
|
142
|
+
# Emit a tuple containing the worker_id (may be None) and the message
|
|
143
|
+
try:
|
|
144
|
+
self.error.emit((worker_id, msg))
|
|
145
|
+
except Exception:
|
|
146
|
+
# Fallback to emitting the raw message if tuple emission fails for any reason
|
|
147
|
+
try:
|
|
148
|
+
self.error.emit(msg)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
# options: dict-like with keys: 'conversion_mode' -> 'fallback'|'rdkit'|'obabel'|'direct'
|
|
155
|
+
if options is None:
|
|
156
|
+
options = {}
|
|
157
|
+
conversion_mode = options.get('conversion_mode', 'fallback')
|
|
158
|
+
# Ensure params exists in all code paths (some RDKit calls below
|
|
159
|
+
# reference `params` and earlier editing introduced a path where
|
|
160
|
+
# it might not be defined). Initialize to None here and assign
|
|
161
|
+
# a proper ETKDG params object later where needed.
|
|
162
|
+
params = None
|
|
163
|
+
if not mol_block:
|
|
164
|
+
raise ValueError("No atoms to convert.")
|
|
165
|
+
|
|
166
|
+
_safe_status("Creating 3D structure...")
|
|
167
|
+
|
|
168
|
+
mol = Chem.MolFromMolBlock(mol_block, removeHs=False)
|
|
169
|
+
if mol is None:
|
|
170
|
+
raise ValueError("Failed to create molecule from MOL block.")
|
|
171
|
+
|
|
172
|
+
# Check early whether this run has been requested to halt
|
|
173
|
+
if _check_halted():
|
|
174
|
+
raise RuntimeError("Halted")
|
|
175
|
+
|
|
176
|
+
# CRITICAL FIX: Extract and restore explicit E/Z labels from MOL block
|
|
177
|
+
# Parse M CFG lines to get explicit stereo labels
|
|
178
|
+
explicit_stereo = {}
|
|
179
|
+
mol_lines = mol_block.split('\n')
|
|
180
|
+
for line in mol_lines:
|
|
181
|
+
if line.startswith('M CFG'):
|
|
182
|
+
parts = line.split()
|
|
183
|
+
if len(parts) >= 4:
|
|
184
|
+
try:
|
|
185
|
+
bond_idx = int(parts[3]) - 1 # MOL format is 1-indexed
|
|
186
|
+
cfg_value = int(parts[4])
|
|
187
|
+
# cfg_value: 1=Z, 2=E in MOL format
|
|
188
|
+
if cfg_value == 1:
|
|
189
|
+
explicit_stereo[bond_idx] = Chem.BondStereo.STEREOZ
|
|
190
|
+
elif cfg_value == 2:
|
|
191
|
+
explicit_stereo[bond_idx] = Chem.BondStereo.STEREOE
|
|
192
|
+
except (ValueError, IndexError):
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
# Force explicit stereo labels regardless of coordinates
|
|
196
|
+
for bond_idx, stereo_type in explicit_stereo.items():
|
|
197
|
+
if bond_idx < mol.GetNumBonds():
|
|
198
|
+
bond = mol.GetBondWithIdx(bond_idx)
|
|
199
|
+
if bond.GetBondType() == Chem.BondType.DOUBLE:
|
|
200
|
+
# Find suitable stereo atoms
|
|
201
|
+
begin_atom = bond.GetBeginAtom()
|
|
202
|
+
end_atom = bond.GetEndAtom()
|
|
203
|
+
|
|
204
|
+
# Pick heavy atom neighbors preferentially
|
|
205
|
+
begin_neighbors = [nbr for nbr in begin_atom.GetNeighbors() if nbr.GetIdx() != end_atom.GetIdx()]
|
|
206
|
+
end_neighbors = [nbr for nbr in end_atom.GetNeighbors() if nbr.GetIdx() != begin_atom.GetIdx()]
|
|
207
|
+
|
|
208
|
+
if begin_neighbors and end_neighbors:
|
|
209
|
+
# Prefer heavy atoms
|
|
210
|
+
begin_heavy = [n for n in begin_neighbors if n.GetAtomicNum() > 1]
|
|
211
|
+
end_heavy = [n for n in end_neighbors if n.GetAtomicNum() > 1]
|
|
212
|
+
|
|
213
|
+
stereo_atom1 = (begin_heavy[0] if begin_heavy else begin_neighbors[0]).GetIdx()
|
|
214
|
+
stereo_atom2 = (end_heavy[0] if end_heavy else end_neighbors[0]).GetIdx()
|
|
215
|
+
|
|
216
|
+
bond.SetStereoAtoms(stereo_atom1, stereo_atom2)
|
|
217
|
+
bond.SetStereo(stereo_type)
|
|
218
|
+
|
|
219
|
+
# Do NOT call AssignStereochemistry here as it overrides our explicit labels
|
|
220
|
+
|
|
221
|
+
mol = Chem.AddHs(mol)
|
|
222
|
+
|
|
223
|
+
# Check after adding Hs (may be a long operation)
|
|
224
|
+
if _check_halted():
|
|
225
|
+
raise RuntimeError("Halted")
|
|
226
|
+
|
|
227
|
+
# CRITICAL: Re-apply explicit stereo after AddHs which may renumber atoms
|
|
228
|
+
for bond_idx, stereo_type in explicit_stereo.items():
|
|
229
|
+
if bond_idx < mol.GetNumBonds():
|
|
230
|
+
bond = mol.GetBondWithIdx(bond_idx)
|
|
231
|
+
if bond.GetBondType() == Chem.BondType.DOUBLE:
|
|
232
|
+
# Re-find suitable stereo atoms after hydrogen addition
|
|
233
|
+
begin_atom = bond.GetBeginAtom()
|
|
234
|
+
end_atom = bond.GetEndAtom()
|
|
235
|
+
|
|
236
|
+
# Pick heavy atom neighbors preferentially
|
|
237
|
+
begin_neighbors = [nbr for nbr in begin_atom.GetNeighbors() if nbr.GetIdx() != end_atom.GetIdx()]
|
|
238
|
+
end_neighbors = [nbr for nbr in end_atom.GetNeighbors() if nbr.GetIdx() != begin_atom.GetIdx()]
|
|
239
|
+
|
|
240
|
+
if begin_neighbors and end_neighbors:
|
|
241
|
+
# Prefer heavy atoms
|
|
242
|
+
begin_heavy = [n for n in begin_neighbors if n.GetAtomicNum() > 1]
|
|
243
|
+
end_heavy = [n for n in end_neighbors if n.GetAtomicNum() > 1]
|
|
244
|
+
|
|
245
|
+
stereo_atom1 = (begin_heavy[0] if begin_heavy else begin_neighbors[0]).GetIdx()
|
|
246
|
+
stereo_atom2 = (end_heavy[0] if end_heavy else end_neighbors[0]).GetIdx()
|
|
247
|
+
|
|
248
|
+
bond.SetStereoAtoms(stereo_atom1, stereo_atom2)
|
|
249
|
+
bond.SetStereo(stereo_type)
|
|
250
|
+
|
|
251
|
+
# Direct mode: construct a 3D conformer without embedding by using
|
|
252
|
+
# the 2D coordinates from the MOL block (z=0) and placing added
|
|
253
|
+
# hydrogens close to their parent heavy atoms with a small z offset.
|
|
254
|
+
# This avoids 3D embedding entirely and is useful for quick viewing
|
|
255
|
+
# when stereochemistry/geometry refinement is not desired.
|
|
256
|
+
if conversion_mode == 'direct':
|
|
257
|
+
_safe_status("Direct conversion: using 2D coordinates + adding missing H (no embedding).")
|
|
258
|
+
try:
|
|
259
|
+
# 1) Parse MOL block *with* existing hydrogens (removeHs=False)
|
|
260
|
+
# to get coordinates for *all existing* atoms.
|
|
261
|
+
parsed_coords = [] # all-atom coordinates (x, y, z)
|
|
262
|
+
stereo_dirs = [] # list of (begin_idx, end_idx, stereo_flag)
|
|
263
|
+
|
|
264
|
+
base2d_all = None
|
|
265
|
+
try:
|
|
266
|
+
# H原子を含めてパース
|
|
267
|
+
base2d_all = Chem.MolFromMolBlock(mol_block, removeHs=False, sanitize=True)
|
|
268
|
+
except Exception:
|
|
269
|
+
try:
|
|
270
|
+
base2d_all = Chem.MolFromMolBlock(mol_block, removeHs=False, sanitize=False)
|
|
271
|
+
except Exception:
|
|
272
|
+
base2d_all = None
|
|
273
|
+
|
|
274
|
+
if base2d_all is not None and base2d_all.GetNumConformers() > 0:
|
|
275
|
+
oconf = base2d_all.GetConformer()
|
|
276
|
+
for i in range(base2d_all.GetNumAtoms()):
|
|
277
|
+
p = oconf.GetAtomPosition(i)
|
|
278
|
+
parsed_coords.append((float(p.x), float(p.y), 0.0))
|
|
279
|
+
|
|
280
|
+
# 2) Parse wedge/dash bond information (using all atoms)
|
|
281
|
+
try:
|
|
282
|
+
lines = mol_block.splitlines()
|
|
283
|
+
counts_idx = None
|
|
284
|
+
|
|
285
|
+
for i, ln in enumerate(lines[:40]):
|
|
286
|
+
if re.match(r"^\s*\d+\s+\d+", ln):
|
|
287
|
+
counts_idx = i
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if counts_idx is not None:
|
|
291
|
+
parts = lines[counts_idx].split()
|
|
292
|
+
try:
|
|
293
|
+
natoms = int(parts[0])
|
|
294
|
+
nbonds = int(parts[1])
|
|
295
|
+
except Exception:
|
|
296
|
+
natoms = nbonds = 0
|
|
297
|
+
|
|
298
|
+
# 全原子マップ (MOL 1-based index -> 0-based index)
|
|
299
|
+
atom_map = {i + 1: i for i in range(natoms)}
|
|
300
|
+
|
|
301
|
+
bond_start = counts_idx + 1 + natoms
|
|
302
|
+
for j in range(min(nbonds, max(0, len(lines) - bond_start))):
|
|
303
|
+
bond_line = lines[bond_start + j]
|
|
304
|
+
try:
|
|
305
|
+
m = re.match(r"^\s*(\d+)\s+(\d+)\s+(\d+)(?:\s+(-?\d+))?", bond_line)
|
|
306
|
+
if m:
|
|
307
|
+
try:
|
|
308
|
+
atom1_mol = int(m.group(1)) # 1-based MOL index
|
|
309
|
+
atom2_mol = int(m.group(2)) # 1-based MOL index
|
|
310
|
+
except Exception:
|
|
311
|
+
continue
|
|
312
|
+
try:
|
|
313
|
+
stereo_raw = int(m.group(4)) if m.group(4) is not None else 0
|
|
314
|
+
except Exception:
|
|
315
|
+
stereo_raw = 0
|
|
316
|
+
else:
|
|
317
|
+
fields = bond_line.split()
|
|
318
|
+
if len(fields) >= 4:
|
|
319
|
+
try:
|
|
320
|
+
atom1_mol = int(fields[0]) # 1-based MOL index
|
|
321
|
+
atom2_mol = int(fields[1]) # 1-based MOL index
|
|
322
|
+
except Exception:
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
stereo_raw = int(fields[3]) if len(fields) > 3 else 0
|
|
326
|
+
except Exception:
|
|
327
|
+
stereo_raw = 0
|
|
328
|
+
else:
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
# V2000の立体表記を正規化
|
|
332
|
+
if stereo_raw == 1:
|
|
333
|
+
stereo_flag = 1 # Wedge
|
|
334
|
+
elif stereo_raw == 2:
|
|
335
|
+
stereo_flag = 6 # Dash (V2000では 6 がDash)
|
|
336
|
+
else:
|
|
337
|
+
stereo_flag = stereo_raw
|
|
338
|
+
|
|
339
|
+
# 全原子マップでチェック
|
|
340
|
+
if atom1_mol in atom_map and atom2_mol in atom_map:
|
|
341
|
+
idx1 = atom_map[atom1_mol]
|
|
342
|
+
idx2 = atom_map[atom2_mol]
|
|
343
|
+
if stereo_flag in (1, 6): # Wedge (1) or Dash (6)
|
|
344
|
+
stereo_dirs.append((idx1, idx2, stereo_flag))
|
|
345
|
+
except Exception:
|
|
346
|
+
continue
|
|
347
|
+
except Exception:
|
|
348
|
+
stereo_dirs = []
|
|
349
|
+
|
|
350
|
+
# Fallback for parsed_coords (if RDKit parse failed)
|
|
351
|
+
if not parsed_coords:
|
|
352
|
+
try:
|
|
353
|
+
lines = mol_block.splitlines()
|
|
354
|
+
counts_idx = None
|
|
355
|
+
for i, ln in enumerate(lines[:40]):
|
|
356
|
+
if re.match(r"^\s*\d+\s+\d+", ln):
|
|
357
|
+
counts_idx = i
|
|
358
|
+
break
|
|
359
|
+
if counts_idx is not None:
|
|
360
|
+
parts = lines[counts_idx].split()
|
|
361
|
+
try:
|
|
362
|
+
natoms = int(parts[0])
|
|
363
|
+
except Exception:
|
|
364
|
+
natoms = 0
|
|
365
|
+
atom_start = counts_idx + 1
|
|
366
|
+
for j in range(min(natoms, max(0, len(lines) - atom_start))):
|
|
367
|
+
atom_line = lines[atom_start + j]
|
|
368
|
+
try:
|
|
369
|
+
x = float(atom_line[0:10].strip()); y = float(atom_line[10:20].strip()); z = float(atom_line[20:30].strip())
|
|
370
|
+
except Exception:
|
|
371
|
+
fields = atom_line.split()
|
|
372
|
+
if len(fields) >= 4:
|
|
373
|
+
try:
|
|
374
|
+
x = float(fields[0]); y = float(fields[1]); z = float(fields[2])
|
|
375
|
+
except Exception:
|
|
376
|
+
continue
|
|
377
|
+
else:
|
|
378
|
+
continue
|
|
379
|
+
# H原子もスキップしない
|
|
380
|
+
parsed_coords.append((x, y, z))
|
|
381
|
+
except Exception:
|
|
382
|
+
parsed_coords = []
|
|
383
|
+
|
|
384
|
+
if not parsed_coords:
|
|
385
|
+
raise ValueError("Failed to parse coordinates from MOL block for direct conversion.")
|
|
386
|
+
|
|
387
|
+
# 3) `mol` は既に AddHs された状態
|
|
388
|
+
# 元の原子数 (H含む) を parsed_coords の長さから取得
|
|
389
|
+
num_existing_atoms = len(parsed_coords)
|
|
390
|
+
|
|
391
|
+
# 4) コンフォーマを作成
|
|
392
|
+
conf = Chem.Conformer(mol.GetNumAtoms())
|
|
393
|
+
|
|
394
|
+
for i in range(mol.GetNumAtoms()):
|
|
395
|
+
if i < num_existing_atoms:
|
|
396
|
+
# 既存原子 (H含む): 2D座標 (z=0) を設定
|
|
397
|
+
x, y, z_ignored = parsed_coords[i]
|
|
398
|
+
try:
|
|
399
|
+
conf.SetAtomPosition(i, rdGeometry.Point3D(float(x), float(y), 0.0))
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
else:
|
|
403
|
+
# 新規追加されたH原子: 親原子の近くに配置
|
|
404
|
+
atom = mol.GetAtomWithIdx(i)
|
|
405
|
+
if atom.GetAtomicNum() == 1:
|
|
406
|
+
neighs = [n for n in atom.GetNeighbors() if n.GetIdx() < num_existing_atoms]
|
|
407
|
+
heavy_pos_found = False
|
|
408
|
+
for nb in neighs: # 親原子 (重原子または既存H)
|
|
409
|
+
try:
|
|
410
|
+
nb_idx = nb.GetIdx()
|
|
411
|
+
# if nb_idx < num_existing_atoms: # チェックは不要 (neighs で既にフィルタ済み)
|
|
412
|
+
nbpos = conf.GetAtomPosition(nb_idx)
|
|
413
|
+
# Geometry-based placement:
|
|
414
|
+
# Compute an "empty" direction around the parent atom by
|
|
415
|
+
# summing existing bond unit vectors and taking the
|
|
416
|
+
# opposite. If degenerate, pick a perpendicular or
|
|
417
|
+
# fallback vector. Rotate slightly if multiple Hs already
|
|
418
|
+
# attached to avoid overlap.
|
|
419
|
+
parent_idx = nb_idx
|
|
420
|
+
try:
|
|
421
|
+
parent_pos = conf.GetAtomPosition(parent_idx)
|
|
422
|
+
parent_atom = mol.GetAtomWithIdx(parent_idx)
|
|
423
|
+
# collect unit vectors to already-placed neighbors (idx < i)
|
|
424
|
+
vecs = []
|
|
425
|
+
for nbr in parent_atom.GetNeighbors():
|
|
426
|
+
nidx = nbr.GetIdx()
|
|
427
|
+
if nidx == i:
|
|
428
|
+
continue
|
|
429
|
+
# only consider neighbors whose positions are already set
|
|
430
|
+
if nidx < i:
|
|
431
|
+
try:
|
|
432
|
+
p = conf.GetAtomPosition(nidx)
|
|
433
|
+
vx = float(p.x) - float(parent_pos.x)
|
|
434
|
+
vy = float(p.y) - float(parent_pos.y)
|
|
435
|
+
nrm = math.hypot(vx, vy)
|
|
436
|
+
if nrm > 1e-6:
|
|
437
|
+
vecs.append((vx / nrm, vy / nrm))
|
|
438
|
+
except Exception:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
if vecs:
|
|
442
|
+
sx = sum(v[0] for v in vecs)
|
|
443
|
+
sy = sum(v[1] for v in vecs)
|
|
444
|
+
fx = -sx
|
|
445
|
+
fy = -sy
|
|
446
|
+
fn = math.hypot(fx, fy)
|
|
447
|
+
if fn < 1e-6:
|
|
448
|
+
# degenerate: pick a perpendicular to first bond
|
|
449
|
+
fx = -vecs[0][1]
|
|
450
|
+
fy = vecs[0][0]
|
|
451
|
+
fn = math.hypot(fx, fy)
|
|
452
|
+
fx /= fn; fy /= fn
|
|
453
|
+
|
|
454
|
+
# Avoid placing multiple Hs at identical directions
|
|
455
|
+
existing_h_count = sum(1 for nbr in parent_atom.GetNeighbors()
|
|
456
|
+
if nbr.GetIdx() < i and nbr.GetAtomicNum() == 1)
|
|
457
|
+
angle = existing_h_count * (math.pi / 6.0) # 30deg steps
|
|
458
|
+
cos_a = math.cos(angle); sin_a = math.sin(angle)
|
|
459
|
+
rx = fx * cos_a - fy * sin_a
|
|
460
|
+
ry = fx * sin_a + fy * cos_a
|
|
461
|
+
|
|
462
|
+
bond_length = 1.0
|
|
463
|
+
conf.SetAtomPosition(i, rdGeometry.Point3D(
|
|
464
|
+
float(parent_pos.x) + rx * bond_length,
|
|
465
|
+
float(parent_pos.y) + ry * bond_length,
|
|
466
|
+
0.3
|
|
467
|
+
))
|
|
468
|
+
else:
|
|
469
|
+
# No existing placed neighbors: fallback to small offset
|
|
470
|
+
conf.SetAtomPosition(i, rdGeometry.Point3D(
|
|
471
|
+
float(parent_pos.x) + 0.5,
|
|
472
|
+
float(parent_pos.y) + 0.5,
|
|
473
|
+
0.3
|
|
474
|
+
))
|
|
475
|
+
|
|
476
|
+
heavy_pos_found = True
|
|
477
|
+
break
|
|
478
|
+
except Exception:
|
|
479
|
+
# fall back to trying the next neighbor if any
|
|
480
|
+
continue
|
|
481
|
+
except Exception:
|
|
482
|
+
continue
|
|
483
|
+
if not heavy_pos_found:
|
|
484
|
+
# フォールバック (原点近く)
|
|
485
|
+
try:
|
|
486
|
+
conf.SetAtomPosition(i, rdGeometry.Point3D(0.0, 0.0, 0.10))
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
# 5) Wedge/Dash の Zオフセットを適用
|
|
491
|
+
try:
|
|
492
|
+
stereo_z_offset = 1.5 # wedge -> +1.5, dash -> -1.5
|
|
493
|
+
for begin_idx, end_idx, stereo_flag in stereo_dirs:
|
|
494
|
+
try:
|
|
495
|
+
# インデックスは既存原子内のはず
|
|
496
|
+
if begin_idx >= num_existing_atoms or end_idx >= num_existing_atoms:
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
if stereo_flag not in (1, 6):
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
sign = 1.0 if stereo_flag == 1 else -1.0
|
|
503
|
+
|
|
504
|
+
# end_idx (立体表記の終点側原子) にZオフセットを適用
|
|
505
|
+
pos = conf.GetAtomPosition(end_idx)
|
|
506
|
+
newz = float(pos.z) + (stereo_z_offset * sign) # 既存のZ=0にオフセットを加算
|
|
507
|
+
conf.SetAtomPosition(end_idx, rdGeometry.Point3D(float(pos.x), float(pos.y), float(newz)))
|
|
508
|
+
except Exception:
|
|
509
|
+
continue
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
# コンフォーマを入れ替えて終了
|
|
514
|
+
try:
|
|
515
|
+
mol.RemoveAllConformers()
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
mol.AddConformer(conf, assignId=True)
|
|
519
|
+
|
|
520
|
+
if _check_halted():
|
|
521
|
+
raise RuntimeError("Halted (after optimization)")
|
|
522
|
+
try:
|
|
523
|
+
_safe_finished((worker_id, mol))
|
|
524
|
+
except Exception:
|
|
525
|
+
_safe_finished(mol)
|
|
526
|
+
_safe_status("Direct conversion completed.")
|
|
527
|
+
return
|
|
528
|
+
except Exception as e:
|
|
529
|
+
_safe_status(f"Direct conversion failed: {e}")
|
|
530
|
+
|
|
531
|
+
params = AllChem.ETKDGv2()
|
|
532
|
+
params.randomSeed = 42
|
|
533
|
+
# CRITICAL: Force ETKDG to respect the existing stereochemistry
|
|
534
|
+
params.useExpTorsionAnglePrefs = True
|
|
535
|
+
params.useBasicKnowledge = True
|
|
536
|
+
params.enforceChirality = True # This is critical for stereo preservation
|
|
537
|
+
|
|
538
|
+
# Store original stereochemistry before embedding (prioritizing explicit labels)
|
|
539
|
+
original_stereo_info = []
|
|
540
|
+
for bond_idx, stereo_type in explicit_stereo.items():
|
|
541
|
+
if bond_idx < mol.GetNumBonds():
|
|
542
|
+
bond = mol.GetBondWithIdx(bond_idx)
|
|
543
|
+
if bond.GetBondType() == Chem.BondType.DOUBLE:
|
|
544
|
+
stereo_atoms = bond.GetStereoAtoms()
|
|
545
|
+
original_stereo_info.append((bond.GetIdx(), stereo_type, stereo_atoms))
|
|
546
|
+
|
|
547
|
+
# Also store any other stereo bonds not in explicit_stereo
|
|
548
|
+
for bond in mol.GetBonds():
|
|
549
|
+
if (bond.GetBondType() == Chem.BondType.DOUBLE and
|
|
550
|
+
bond.GetStereo() != Chem.BondStereo.STEREONONE and
|
|
551
|
+
bond.GetIdx() not in explicit_stereo):
|
|
552
|
+
stereo_atoms = bond.GetStereoAtoms()
|
|
553
|
+
original_stereo_info.append((bond.GetIdx(), bond.GetStereo(), stereo_atoms))
|
|
554
|
+
|
|
555
|
+
# Only report RDKit-specific messages when RDKit embedding will be
|
|
556
|
+
# attempted. For other conversion modes, emit clearer, non-misleading
|
|
557
|
+
# status messages so the UI doesn't show "RDKit" when e.g. direct
|
|
558
|
+
# coordinates or Open Babel will be used.
|
|
559
|
+
if conversion_mode in ('fallback', 'rdkit'):
|
|
560
|
+
_safe_status("RDKit: Embedding 3D coordinates...")
|
|
561
|
+
elif conversion_mode == 'obabel':
|
|
562
|
+
pass
|
|
563
|
+
else:
|
|
564
|
+
# direct mode (or any other explicit non-RDKit mode)
|
|
565
|
+
pass
|
|
566
|
+
if _check_halted():
|
|
567
|
+
raise RuntimeError("Halted")
|
|
568
|
+
|
|
569
|
+
# Try multiple times with different approaches if needed
|
|
570
|
+
conf_id = -1
|
|
571
|
+
|
|
572
|
+
# First attempt: Standard ETKDG with stereo enforcement
|
|
573
|
+
try:
|
|
574
|
+
# Only attempt RDKit embedding if mode allows
|
|
575
|
+
if conversion_mode in ('fallback', 'rdkit'):
|
|
576
|
+
conf_id = AllChem.EmbedMolecule(mol, params)
|
|
577
|
+
else:
|
|
578
|
+
conf_id = -1
|
|
579
|
+
# Final check before returning success
|
|
580
|
+
if _check_halted():
|
|
581
|
+
raise RuntimeError("Halted")
|
|
582
|
+
except Exception as e:
|
|
583
|
+
# Standard embedding failed; report and continue to fallback attempts
|
|
584
|
+
_safe_status(f"Standard embedding failed: {e}")
|
|
585
|
+
|
|
586
|
+
# Second attempt: Use constraint embedding if available (only when RDKit is allowed)
|
|
587
|
+
if conf_id == -1 and conversion_mode in ('fallback', 'rdkit'):
|
|
588
|
+
try:
|
|
589
|
+
# Create distance constraints for double bonds to enforce E/Z geometry
|
|
590
|
+
bounds_matrix = AllChem.GetMoleculeBoundsMatrix(mol)
|
|
591
|
+
|
|
592
|
+
# Add constraints for E/Z bonds
|
|
593
|
+
for bond_idx, stereo, stereo_atoms in original_stereo_info:
|
|
594
|
+
bond = mol.GetBondWithIdx(bond_idx)
|
|
595
|
+
if len(stereo_atoms) == 2:
|
|
596
|
+
atom1_idx = bond.GetBeginAtomIdx()
|
|
597
|
+
atom2_idx = bond.GetEndAtomIdx()
|
|
598
|
+
neighbor1_idx = stereo_atoms[0]
|
|
599
|
+
neighbor2_idx = stereo_atoms[1]
|
|
600
|
+
|
|
601
|
+
# For Z (cis): neighbors should be closer
|
|
602
|
+
# For E (trans): neighbors should be farther
|
|
603
|
+
if stereo == Chem.BondStereo.STEREOZ:
|
|
604
|
+
# Z configuration: set shorter distance constraint
|
|
605
|
+
target_dist = 3.0 # Angstroms
|
|
606
|
+
bounds_matrix[neighbor1_idx][neighbor2_idx] = min(bounds_matrix[neighbor1_idx][neighbor2_idx], target_dist)
|
|
607
|
+
bounds_matrix[neighbor2_idx][neighbor1_idx] = min(bounds_matrix[neighbor2_idx][neighbor1_idx], target_dist)
|
|
608
|
+
elif stereo == Chem.BondStereo.STEREOE:
|
|
609
|
+
# E configuration: set longer distance constraint
|
|
610
|
+
target_dist = 5.0 # Angstroms
|
|
611
|
+
bounds_matrix[neighbor1_idx][neighbor2_idx] = max(bounds_matrix[neighbor1_idx][neighbor2_idx], target_dist)
|
|
612
|
+
bounds_matrix[neighbor2_idx][neighbor1_idx] = max(bounds_matrix[neighbor2_idx][neighbor1_idx], target_dist)
|
|
613
|
+
|
|
614
|
+
DoTriangleSmoothing(bounds_matrix)
|
|
615
|
+
conf_id = AllChem.EmbedMolecule(mol, bounds_matrix, params)
|
|
616
|
+
_safe_status("Constraint-based embedding succeeded")
|
|
617
|
+
except Exception:
|
|
618
|
+
# Constraint embedding failed: only raise error if mode is 'rdkit', otherwise allow fallback
|
|
619
|
+
_safe_status("RDKit: Constraint embedding failed")
|
|
620
|
+
if conversion_mode == 'rdkit':
|
|
621
|
+
raise RuntimeError("RDKit: Constraint embedding failed")
|
|
622
|
+
conf_id = -1
|
|
623
|
+
|
|
624
|
+
# Fallback: Try basic embedding
|
|
625
|
+
if conf_id == -1:
|
|
626
|
+
try:
|
|
627
|
+
if conversion_mode in ('fallback', 'rdkit'):
|
|
628
|
+
basic_params = AllChem.ETKDGv2()
|
|
629
|
+
basic_params.randomSeed = 42
|
|
630
|
+
conf_id = AllChem.EmbedMolecule(mol, basic_params)
|
|
631
|
+
else:
|
|
632
|
+
conf_id = -1
|
|
633
|
+
except Exception:
|
|
634
|
+
pass
|
|
635
|
+
'''
|
|
636
|
+
if conf_id == -1:
|
|
637
|
+
_safe_status("Initial embedding failed, retrying with ignoreSmoothingFailures=True...")
|
|
638
|
+
# Try again with ignoreSmoothingFailures instead of random-seed retries
|
|
639
|
+
params.ignoreSmoothingFailures = True
|
|
640
|
+
# Use a deterministic seed to avoid random-coordinate behavior here
|
|
641
|
+
params.randomSeed = 0
|
|
642
|
+
conf_id = AllChem.EmbedMolecule(mol, params)
|
|
643
|
+
|
|
644
|
+
if conf_id == -1:
|
|
645
|
+
self.status_update.emit("Random-seed retry failed, attempting with random coordinates...")
|
|
646
|
+
try:
|
|
647
|
+
conf_id = AllChem.EmbedMolecule(mol, useRandomCoords=True, ignoreSmoothingFailures=True)
|
|
648
|
+
except TypeError:
|
|
649
|
+
# Some RDKit versions expect useRandomCoords in params
|
|
650
|
+
params.useRandomCoords = True
|
|
651
|
+
conf_id = AllChem.EmbedMolecule(mol, params)
|
|
652
|
+
'''
|
|
653
|
+
|
|
654
|
+
# Determine requested MMFF variant from options (fall back to MMFF94s)
|
|
655
|
+
opt_method = None
|
|
656
|
+
try:
|
|
657
|
+
opt_method = options.get('optimization_method') if options else None
|
|
658
|
+
except Exception:
|
|
659
|
+
opt_method = None
|
|
660
|
+
|
|
661
|
+
if conf_id != -1:
|
|
662
|
+
# Success with RDKit: optimize and finish
|
|
663
|
+
# CRITICAL: Restore original stereochemistry after embedding (explicit labels first)
|
|
664
|
+
for bond_idx, stereo, stereo_atoms in original_stereo_info:
|
|
665
|
+
bond = mol.GetBondWithIdx(bond_idx)
|
|
666
|
+
if len(stereo_atoms) == 2:
|
|
667
|
+
bond.SetStereoAtoms(stereo_atoms[0], stereo_atoms[1])
|
|
668
|
+
bond.SetStereo(stereo)
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
mmff_variant = "MMFF94s"
|
|
672
|
+
if opt_method and str(opt_method).upper() == 'MMFF94_RDKIT':
|
|
673
|
+
mmff_variant = "MMFF94"
|
|
674
|
+
if _check_halted():
|
|
675
|
+
raise RuntimeError("Halted")
|
|
676
|
+
AllChem.MMFFOptimizeMolecule(mol, mmffVariant=mmff_variant)
|
|
677
|
+
except Exception:
|
|
678
|
+
# fallback to UFF if MMFF fails
|
|
679
|
+
try:
|
|
680
|
+
if _check_halted():
|
|
681
|
+
raise RuntimeError("Halted")
|
|
682
|
+
AllChem.UFFOptimizeMolecule(mol)
|
|
683
|
+
except Exception:
|
|
684
|
+
pass
|
|
685
|
+
|
|
686
|
+
# CRITICAL: Restore stereochemistry again after optimization (explicit labels priority)
|
|
687
|
+
for bond_idx, stereo, stereo_atoms in original_stereo_info:
|
|
688
|
+
bond = mol.GetBondWithIdx(bond_idx)
|
|
689
|
+
if len(stereo_atoms) == 2:
|
|
690
|
+
bond.SetStereoAtoms(stereo_atoms[0], stereo_atoms[1])
|
|
691
|
+
bond.SetStereo(stereo)
|
|
692
|
+
|
|
693
|
+
# Do NOT call AssignStereochemistry here as it would override our explicit labels
|
|
694
|
+
# Include worker_id so the main thread can ignore stale results
|
|
695
|
+
# CRITICAL: Check for halt *before* emitting finished signal
|
|
696
|
+
if _check_halted():
|
|
697
|
+
raise RuntimeError("Halted (after optimization)")
|
|
698
|
+
try:
|
|
699
|
+
_safe_finished((worker_id, mol))
|
|
700
|
+
except Exception:
|
|
701
|
+
# Fallback to legacy single-arg emit
|
|
702
|
+
_safe_finished(mol)
|
|
703
|
+
_safe_status("RDKit 3D conversion succeeded.")
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
# If RDKit did not produce a conf and OBabel is allowed, try Open Babel
|
|
707
|
+
if conf_id == -1 and conversion_mode in ('fallback', 'obabel'):
|
|
708
|
+
_safe_status("RDKit embedding failed or disabled. Attempting Open Babel...")
|
|
709
|
+
try:
|
|
710
|
+
if not OBABEL_AVAILABLE:
|
|
711
|
+
raise RuntimeError("Open Babel (pybel) is not available in this Python environment.")
|
|
712
|
+
ob_mol = pybel.readstring("mol", mol_block)
|
|
713
|
+
try:
|
|
714
|
+
ob_mol.addh()
|
|
715
|
+
except Exception:
|
|
716
|
+
pass
|
|
717
|
+
ob_mol.make3D()
|
|
718
|
+
try:
|
|
719
|
+
_safe_status("Optimizing with Open Babel (MMFF94)...")
|
|
720
|
+
if _check_halted():
|
|
721
|
+
raise RuntimeError("Halted")
|
|
722
|
+
ob_mol.localopt(forcefield='mmff94', steps=500)
|
|
723
|
+
except Exception:
|
|
724
|
+
try:
|
|
725
|
+
_safe_status("MMFF94 failed, falling back to UFF...")
|
|
726
|
+
if _check_halted():
|
|
727
|
+
raise RuntimeError("Halted")
|
|
728
|
+
ob_mol.localopt(forcefield='uff', steps=500)
|
|
729
|
+
except Exception:
|
|
730
|
+
_safe_status("UFF optimization also failed.")
|
|
731
|
+
molblock_ob = ob_mol.write("mol")
|
|
732
|
+
rd_mol = Chem.MolFromMolBlock(molblock_ob, removeHs=False)
|
|
733
|
+
if rd_mol is None:
|
|
734
|
+
raise ValueError("Open Babel produced invalid MOL block.")
|
|
735
|
+
rd_mol = Chem.AddHs(rd_mol)
|
|
736
|
+
try:
|
|
737
|
+
mmff_variant = "MMFF94s"
|
|
738
|
+
if opt_method and str(opt_method).upper() == 'MMFF94_RDKIT':
|
|
739
|
+
mmff_variant = "MMFF94"
|
|
740
|
+
if _check_halted():
|
|
741
|
+
raise RuntimeError("Halted")
|
|
742
|
+
AllChem.MMFFOptimizeMolecule(rd_mol, mmffVariant=mmff_variant)
|
|
743
|
+
except Exception:
|
|
744
|
+
try:
|
|
745
|
+
if _check_halted():
|
|
746
|
+
raise RuntimeError("Halted")
|
|
747
|
+
AllChem.UFFOptimizeMolecule(rd_mol)
|
|
748
|
+
except Exception:
|
|
749
|
+
pass
|
|
750
|
+
_safe_status("Open Babel embedding succeeded. Warning: Conformation accuracy may be limited.")
|
|
751
|
+
# CRITICAL: Check for halt *before* emitting finished signal
|
|
752
|
+
if _check_halted():
|
|
753
|
+
raise RuntimeError("Halted (after optimization)")
|
|
754
|
+
try:
|
|
755
|
+
_safe_finished((worker_id, rd_mol))
|
|
756
|
+
except Exception:
|
|
757
|
+
_safe_finished(rd_mol)
|
|
758
|
+
return
|
|
759
|
+
except Exception as ob_err:
|
|
760
|
+
raise RuntimeError(f"Open Babel 3D conversion failed: {ob_err}")
|
|
761
|
+
|
|
762
|
+
if conf_id == -1 and conversion_mode == 'rdkit':
|
|
763
|
+
raise RuntimeError("RDKit 3D conversion failed (rdkit-only mode)")
|
|
764
|
+
|
|
765
|
+
except Exception as e:
|
|
766
|
+
_safe_error(str(e))
|