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