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.
Files changed (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. 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))