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,1203 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ main_window_compute.py
6
+ MainWindow (main_window.py) から分離されたモジュール
7
+ 機能クラス: MainWindowCompute
8
+ """
9
+
10
+
11
+
12
+
13
+ # RDKit imports (explicit to satisfy flake8 and used features)
14
+ from rdkit import Chem
15
+ from rdkit.Chem import AllChem
16
+ try:
17
+ pass
18
+ except Exception:
19
+ pass
20
+
21
+ # PyQt6 Modules
22
+ from PyQt6.QtWidgets import (
23
+ QApplication, QMenu
24
+ )
25
+
26
+ from PyQt6.QtGui import (
27
+ QColor, QAction
28
+ )
29
+
30
+
31
+ from PyQt6.QtCore import (
32
+ QThread, QTimer
33
+ )
34
+
35
+
36
+ # Use centralized Open Babel availability from package-level __init__
37
+ # Use per-package modules availability (local __init__).
38
+ try:
39
+ from . import OBABEL_AVAILABLE
40
+ except Exception:
41
+ from modules import OBABEL_AVAILABLE
42
+ # Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
43
+ if OBABEL_AVAILABLE:
44
+ try:
45
+ from openbabel import pybel
46
+ except Exception:
47
+ # If import fails here, disable OBABEL locally; avoid raising
48
+ pybel = None
49
+ OBABEL_AVAILABLE = False
50
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
51
+ else:
52
+ pybel = None
53
+
54
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
55
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
56
+ # it once at module import time and expose a small, robust wrapper so callers
57
+ # can avoid re-importing sip repeatedly and so we centralize exception
58
+ # handling (this reduces crash risk during teardown and deletion operations).
59
+ try:
60
+ import sip as _sip # type: ignore
61
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
62
+ except Exception:
63
+ _sip = None
64
+ _sip_isdeleted = None
65
+
66
+ try:
67
+ # package relative imports (preferred when running as `python -m moleditpy`)
68
+ from .calculation_worker import CalculationWorker
69
+ except Exception:
70
+ # Fallback to absolute imports for script-style execution
71
+ from modules.calculation_worker import CalculationWorker
72
+
73
+
74
+ # --- クラス定義 ---
75
+ class MainWindowCompute(object):
76
+ """ main_window.py から分離された機能クラス """
77
+
78
+ def __init__(self, main_window):
79
+ """ クラスの初期化 """
80
+ self.mw = main_window
81
+
82
+
83
+ def set_optimization_method(self, method_name):
84
+ """Set preferred 3D optimization method and persist to settings.
85
+
86
+ Supported values: 'GAFF', 'MMFF'
87
+ """
88
+ # Normalize input and validate
89
+ if not method_name:
90
+ return
91
+ method = str(method_name).strip().upper()
92
+ valid_methods = (
93
+ 'MMFF_RDKIT', 'MMFF94_RDKIT', 'UFF_RDKIT',
94
+ 'UFF_OBABEL', 'GAFF_OBABEL', 'MMFF94_OBABEL', 'GHEMICAL_OBABEL'
95
+ )
96
+ if method not in valid_methods:
97
+ # Unknown method: ignore but notify
98
+ self.statusBar().showMessage(f"Unknown 3D optimization method: {method_name}")
99
+ return
100
+
101
+ # Update internal state (store canonical uppercase key)
102
+ self.optimization_method = method
103
+
104
+ # Persist to settings
105
+ try:
106
+ self.settings['optimization_method'] = self.optimization_method
107
+ try:
108
+ self.settings_dirty = True
109
+ except Exception:
110
+ pass
111
+ except Exception:
112
+ pass
113
+
114
+ # Update menu checked state if actions mapping exists
115
+ try:
116
+ if hasattr(self, 'opt3d_actions') and self.opt3d_actions:
117
+ for k, act in self.opt3d_actions.items():
118
+ try:
119
+ # keys in opt3d_actions may be mixed-case; compare uppercased
120
+ act.setChecked(k.upper() == method)
121
+ except Exception:
122
+ pass
123
+ except Exception:
124
+ pass
125
+
126
+ # Also show user-friendly label if available
127
+ try:
128
+ label = self.opt3d_method_labels.get(self.optimization_method, self.optimization_method)
129
+ except Exception:
130
+ label = self.optimization_method
131
+ self.statusBar().showMessage(f"3D optimization method set to: {label}")
132
+
133
+
134
+
135
+ def show_convert_menu(self, pos):
136
+ """右クリックで表示する一時的な3D変換メニュー。
137
+ 選択したモードは一時フラグとして保持され、その後の変換で使用されます(永続化しません)。
138
+ """
139
+ try:
140
+ menu = QMenu(self)
141
+ conv_options = [
142
+ ("RDKit -> Open Babel (fallback)", 'fallback'),
143
+ ("RDKit only", 'rdkit'),
144
+ ("Open Babel only", 'obabel'),
145
+ ("Direct (use 2D coords + add H)", 'direct')
146
+ ]
147
+ for label, key in conv_options:
148
+ a = QAction(label, self)
149
+ # If Open Babel is not available, disable actions that depend on it
150
+ if key in ('obabel', 'fallback') and not globals().get('OBABEL_AVAILABLE', False):
151
+ a.setEnabled(False)
152
+ a.triggered.connect(lambda checked=False, k=key: self._trigger_conversion_with_temp_mode(k))
153
+ menu.addAction(a)
154
+
155
+ # Show menu at button position
156
+ menu.exec_(self.convert_button.mapToGlobal(pos))
157
+ except Exception as e:
158
+ print(f"Error showing convert menu: {e}")
159
+
160
+
161
+
162
+
163
+ def _trigger_conversion_with_temp_mode(self, mode_key):
164
+ try:
165
+ # store temporary override and invoke conversion
166
+ self._temp_conv_mode = mode_key
167
+ # Call the normal conversion entry point (it will consume the temp)
168
+ QTimer.singleShot(0, self.trigger_conversion)
169
+ except Exception as e:
170
+ print(f"Failed to start conversion with temp mode {mode_key}: {e}")
171
+
172
+
173
+
174
+
175
+ def show_optimize_menu(self, pos):
176
+ """右クリックで表示する一時的な3D最適化メニュー。
177
+ 選択したメソッドは一時フラグとして保持され、その後の最適化で使用されます(永続化しません)。
178
+ """
179
+ try:
180
+ menu = QMenu(self)
181
+ opt_list = [
182
+ ("MMFF94s", 'MMFF_RDKIT'),
183
+ ("MMFF94", 'MMFF94_RDKIT'),
184
+ ("UFF", 'UFF_RDKIT')
185
+ ]
186
+ for label, key in opt_list:
187
+ a = QAction(label, self)
188
+ # If opt3d_actions exist, reflect their enabled state
189
+ try:
190
+ if hasattr(self, 'opt3d_actions') and key in self.opt3d_actions:
191
+ a.setEnabled(self.opt3d_actions[key].isEnabled())
192
+ except Exception:
193
+ pass
194
+ a.triggered.connect(lambda checked=False, k=key: self._trigger_optimize_with_temp_method(k))
195
+ menu.addAction(a)
196
+
197
+ menu.exec_(self.optimize_3d_button.mapToGlobal(pos))
198
+ except Exception as e:
199
+ print(f"Error showing optimize menu: {e}")
200
+
201
+
202
+
203
+
204
+ def _trigger_optimize_with_temp_method(self, method_key):
205
+ try:
206
+ # store temporary override and invoke optimization
207
+ self._temp_optimization_method = method_key
208
+ # Run optimize on next event loop turn so UI updates first
209
+ QTimer.singleShot(0, self.optimize_3d_structure)
210
+ except Exception as e:
211
+ print(f"Failed to start optimization with temp method {method_key}: {e}")
212
+
213
+
214
+
215
+ def trigger_conversion(self):
216
+ # Reset last successful optimization method at start of new conversion
217
+ self.last_successful_optimization_method = None
218
+
219
+ # 3D変換時に既存の3D制約をクリア
220
+ self.constraints_3d = []
221
+
222
+ # 2Dエディタに原子が存在しない場合は3Dビューをクリア
223
+ if not self.data.atoms:
224
+ self.plotter.clear()
225
+ self.current_mol = None
226
+ self.analysis_action.setEnabled(False)
227
+ self.statusBar().showMessage("3D view cleared.")
228
+ self.view_2d.setFocus()
229
+ return
230
+
231
+ # 描画モード変更時に測定モードと3D編集モードをリセット
232
+ if self.measurement_mode:
233
+ self.measurement_action.setChecked(False)
234
+ self.toggle_measurement_mode(False) # 測定モードを無効化
235
+ if self.is_3d_edit_mode:
236
+ self.edit_3d_action.setChecked(False)
237
+ self.toggle_3d_edit_mode(False) # 3D編集モードを無効化
238
+
239
+ mol = self.data.to_rdkit_mol(use_2d_stereo=False)
240
+
241
+ # 分子オブジェクトが作成できない場合でも化学的問題をチェック
242
+ if not mol or mol.GetNumAtoms() == 0:
243
+ # RDKitでの変換に失敗した場合は、独自の化学的問題チェックを実行
244
+ self.check_chemistry_problems_fallback()
245
+ return
246
+
247
+ # 原子プロパティを保存(ワーカープロセスで失われるため)
248
+ self.original_atom_properties = {}
249
+ for i in range(mol.GetNumAtoms()):
250
+ atom = mol.GetAtomWithIdx(i)
251
+ try:
252
+ original_id = atom.GetIntProp("_original_atom_id")
253
+ self.original_atom_properties[i] = original_id
254
+ except KeyError:
255
+ pass
256
+
257
+ problems = Chem.DetectChemistryProblems(mol)
258
+ if problems:
259
+ # 化学的問題が見つかった場合は既存のフラグをクリアしてから新しい問題を表示
260
+ self.scene.clear_all_problem_flags()
261
+ self.statusBar().showMessage(f"Error: {len(problems)} chemistry problem(s) found.")
262
+ # 既存の選択状態をクリア
263
+ self.scene.clearSelection()
264
+
265
+ # 問題のある原子に赤枠フラグを立てる
266
+ for prob in problems:
267
+ atom_idx = prob.GetAtomIdx()
268
+ rdkit_atom = mol.GetAtomWithIdx(atom_idx)
269
+ # エディタ側での原子IDの取得と存在確認
270
+ if rdkit_atom.HasProp("_original_atom_id"):
271
+ original_id = rdkit_atom.GetIntProp("_original_atom_id")
272
+ if original_id in self.data.atoms and self.data.atoms[original_id]['item']:
273
+ item = self.data.atoms[original_id]['item']
274
+ item.has_problem = True
275
+ item.update()
276
+
277
+ self.view_2d.setFocus()
278
+ return
279
+
280
+ # 化学的問題がない場合のみフラグをクリアして3D変換を実行
281
+ self.scene.clear_all_problem_flags()
282
+
283
+ try:
284
+ Chem.SanitizeMol(mol)
285
+ except Exception:
286
+ self.statusBar().showMessage("Error: Invalid chemical structure.")
287
+ self.view_2d.setFocus()
288
+ return
289
+
290
+ # 複数分子の処理に対応
291
+ num_frags = len(Chem.GetMolFrags(mol))
292
+ if num_frags > 1:
293
+ self.statusBar().showMessage(f"Converting {num_frags} molecules to 3D with collision detection...")
294
+ else:
295
+ self.statusBar().showMessage("Calculating 3D structure...")
296
+
297
+ # CRITICAL FIX: Use the 2D editor's MOL block instead of RDKit's to preserve
298
+ # wedge/dash stereo information that is stored in the 2D editor data.
299
+ # RDKit's MolToMolBlock() doesn't preserve this information.
300
+ mol_block = self.data.to_mol_block()
301
+ if not mol_block:
302
+ mol_block = Chem.MolToMolBlock(mol, includeStereo=True)
303
+
304
+ # Additional E/Z stereo enhancement: add M CFG lines for explicit E/Z bonds
305
+ mol_lines = mol_block.split('\n')
306
+
307
+ # Find bonds with explicit E/Z labels from our data and map to RDKit bond indices
308
+ ez_bond_info = {}
309
+ for (id1, id2), bond_data in self.data.bonds.items():
310
+ if bond_data.get('stereo') in [3, 4]: # E/Z labels
311
+ # Find corresponding atoms in RDKit molecule by _original_atom_id property
312
+ rdkit_idx1 = None
313
+ rdkit_idx2 = None
314
+ for atom in mol.GetAtoms():
315
+ if atom.HasProp("_original_atom_id"):
316
+ orig_id = atom.GetIntProp("_original_atom_id")
317
+ if orig_id == id1:
318
+ rdkit_idx1 = atom.GetIdx()
319
+ elif orig_id == id2:
320
+ rdkit_idx2 = atom.GetIdx()
321
+
322
+ if rdkit_idx1 is not None and rdkit_idx2 is not None:
323
+ rdkit_bond = mol.GetBondBetweenAtoms(rdkit_idx1, rdkit_idx2)
324
+ if rdkit_bond and rdkit_bond.GetBondType() == Chem.BondType.DOUBLE:
325
+ ez_bond_info[rdkit_bond.GetIdx()] = bond_data['stereo']
326
+
327
+ # Add M CFG lines for E/Z stereo if needed
328
+ if ez_bond_info:
329
+ insert_idx = len(mol_lines) - 1 # Before M END
330
+ for bond_idx, stereo_type in ez_bond_info.items():
331
+ cfg_value = 1 if stereo_type == 3 else 2 # 1=Z, 2=E in MOL format
332
+ cfg_line = f"M CFG 1 {bond_idx + 1:3d} {cfg_value}"
333
+ mol_lines.insert(insert_idx, cfg_line)
334
+ insert_idx += 1
335
+ mol_block = '\n'.join(mol_lines)
336
+
337
+ # Assign a unique ID for this conversion run so it can be halted/validated
338
+ try:
339
+ run_id = int(self.next_conversion_id)
340
+ except Exception:
341
+ run_id = 1
342
+ try:
343
+ self.next_conversion_id = run_id + 1
344
+ except Exception:
345
+ self.next_conversion_id = getattr(self, 'next_conversion_id', 1) + 1
346
+
347
+ # Record this run as active. Use a set to track all active worker ids
348
+ # so a Halt request can target every running conversion.
349
+ try:
350
+ self.active_worker_ids.add(run_id)
351
+ except Exception:
352
+ # Ensure attribute exists in case of weird states
353
+ self.active_worker_ids = set([run_id])
354
+
355
+ # Change the convert button to a Halt button so user can cancel
356
+ try:
357
+ # keep it enabled so the user can click Halt
358
+ self.convert_button.setText("Halt conversion")
359
+ try:
360
+ self.convert_button.clicked.disconnect()
361
+ except Exception:
362
+ pass
363
+ self.convert_button.clicked.connect(self.halt_conversion)
364
+ except Exception:
365
+ pass
366
+
367
+ # Keep cleanup disabled while conversion is in progress
368
+ self.cleanup_button.setEnabled(False)
369
+ # Disable 3D features during calculation
370
+ self._enable_3d_features(False)
371
+ self.statusBar().showMessage("Calculating 3D structure...")
372
+ self.plotter.clear()
373
+ bg_color_hex = self.settings.get('background_color', '#919191')
374
+ bg_qcolor = QColor(bg_color_hex)
375
+
376
+ if bg_qcolor.isValid():
377
+ luminance = bg_qcolor.toHsl().lightness()
378
+ text_color = 'black' if luminance > 128 else 'white'
379
+ else:
380
+ text_color = 'white'
381
+
382
+ text_actor = self.plotter.add_text(
383
+ "Calculating...",
384
+ position='lower_right',
385
+ font_size=15,
386
+ color=text_color,
387
+ name='calculating_text'
388
+ )
389
+ # Keep a reference so we can reliably remove the text actor later
390
+ try:
391
+ self._calculating_text_actor = text_actor
392
+ except Exception:
393
+ # Best-effort: if storing fails, ignore — cleanup will still attempt renderer removal
394
+ pass
395
+ text_actor.GetTextProperty().SetOpacity(1)
396
+ self.plotter.render()
397
+ # Emit skip flag so the worker can ignore sanitization errors if user requested
398
+ # Determine conversion_mode from settings (default: 'fallback').
399
+ # If the user invoked conversion via the right-click menu, a temporary
400
+ # override may be set on self._temp_conv_mode and should be used once.
401
+ conv_mode = getattr(self, '_temp_conv_mode', None)
402
+ if conv_mode:
403
+ try:
404
+ del self._temp_conv_mode
405
+ except Exception:
406
+ try:
407
+ delattr(self, '_temp_conv_mode')
408
+ except Exception:
409
+ pass
410
+ else:
411
+ conv_mode = self.settings.get('3d_conversion_mode', 'fallback')
412
+
413
+ # Allow a temporary optimization method override as well (used when
414
+ # Optimize 3D is invoked via right-click menu). Do not persist here.
415
+ opt_method = getattr(self, '_temp_optimization_method', None) or self.optimization_method
416
+ if hasattr(self, '_temp_optimization_method'):
417
+ try:
418
+ del self._temp_optimization_method
419
+ except Exception:
420
+ try:
421
+ delattr(self, '_temp_optimization_method')
422
+ except Exception:
423
+ pass
424
+
425
+ options = {'conversion_mode': conv_mode, 'optimization_method': opt_method}
426
+ # Attach the run id so the worker and main thread can correlate
427
+ try:
428
+ # Attach the concrete run id rather than the single waiting id
429
+ options['worker_id'] = run_id
430
+ except Exception:
431
+ pass
432
+
433
+ # Create a fresh CalculationWorker + QThread for this run so multiple
434
+ # conversions can execute in parallel. The worker will be cleaned up
435
+ # automatically after it finishes/errors.
436
+ try:
437
+ thread = QThread()
438
+ worker = CalculationWorker()
439
+ # Share the halt_ids set so user can request cancellation
440
+ try:
441
+ worker.halt_ids = self.halt_ids
442
+ except Exception:
443
+ pass
444
+
445
+ worker.moveToThread(thread)
446
+
447
+ # Forward status signals to main window handlers
448
+ try:
449
+ worker.status_update.connect(self.update_status_bar)
450
+ except Exception:
451
+ pass
452
+
453
+ # When the worker finishes, call existing handler and then clean up
454
+ def _on_worker_finished(result, w=worker, t=thread):
455
+ try:
456
+ # deliver result to existing handler
457
+ self.on_calculation_finished(result)
458
+ finally:
459
+ # Clean up signal connections to avoid stale references
460
+ # worker used its own start_work signal; no shared-signal
461
+ # disconnect necessary here.
462
+ # Remove thread from active threads list
463
+ try:
464
+ self._active_calc_threads.remove(t)
465
+ except Exception:
466
+ pass
467
+ try:
468
+ # ask thread to quit; it will finish as worker returns
469
+ t.quit()
470
+ except Exception:
471
+ pass
472
+ try:
473
+ # ensure thread object is deleted when finished
474
+ t.finished.connect(t.deleteLater)
475
+ except Exception:
476
+ pass
477
+ try:
478
+ # schedule worker deletion
479
+ w.deleteLater()
480
+ except Exception:
481
+ pass
482
+
483
+ # When the worker errors (or halts), call existing handler and then clean up
484
+ def _on_worker_error(error_msg, w=worker, t=thread):
485
+ try:
486
+ # deliver error to existing handler
487
+ self.on_calculation_error(error_msg)
488
+ finally:
489
+ # Clean up signal connections to avoid stale references
490
+ # worker used its own start_work signal; no shared-signal
491
+ # disconnect necessary here.
492
+ # Remove thread from active threads list
493
+ try:
494
+ self._active_calc_threads.remove(t)
495
+ except Exception:
496
+ pass
497
+ try:
498
+ # ask thread to quit; it will finish as worker returns
499
+ t.quit()
500
+ except Exception:
501
+ pass
502
+ try:
503
+ # ensure thread object is deleted when finished
504
+ t.finished.connect(t.deleteLater)
505
+ except Exception:
506
+ pass
507
+ try:
508
+ # schedule worker deletion
509
+ w.deleteLater()
510
+ except Exception:
511
+ pass
512
+
513
+ try:
514
+ worker.error.connect(_on_worker_error)
515
+ except Exception:
516
+ pass
517
+
518
+ try:
519
+ worker.finished.connect(_on_worker_finished)
520
+ except Exception:
521
+ pass
522
+
523
+ # Start the thread
524
+ thread.start()
525
+
526
+ # Start the worker calculation via the worker's own start_work signal
527
+ # (queued to the worker thread). Capture variables into lambda defaults
528
+ # to avoid late-binding issues.
529
+ QTimer.singleShot(10, lambda w=worker, m=mol_block, o=options: w.start_work.emit(m, o))
530
+
531
+ # Track the thread so it isn't immediately garbage-collected (diagnostics)
532
+ try:
533
+ self._active_calc_threads.append(thread)
534
+ except Exception:
535
+ pass
536
+ except Exception as e:
537
+ # Fall back: if thread/worker creation failed, create a local
538
+ # worker and start it (runs in main thread). This preserves
539
+ # functionality without relying on the shared MainWindow signal.
540
+ try:
541
+ fallback_worker = CalculationWorker()
542
+ QTimer.singleShot(10, lambda w=fallback_worker, m=mol_block, o=options: w.start_work.emit(m, o))
543
+ except Exception:
544
+ # surface the original error via existing UI path
545
+ self.on_calculation_error(str(e))
546
+
547
+ # 状態をUndo履歴に保存
548
+ self.push_undo_state()
549
+ self.update_chiral_labels()
550
+
551
+ self.view_2d.setFocus()
552
+
553
+
554
+
555
+ def halt_conversion(self):
556
+ """User requested to halt the in-progress conversion.
557
+
558
+ This will mark the current waiting_worker_id as halted (added to halt_ids),
559
+ clear the waiting_worker_id, and immediately restore the UI (button text
560
+ and handlers). The worker thread will observe halt_ids and should stop.
561
+ """
562
+ try:
563
+ # Halt all currently-active workers by adding their ids to halt_ids
564
+ wids_to_halt = set(getattr(self, 'active_worker_ids', set()))
565
+ if wids_to_halt:
566
+ try:
567
+ self.halt_ids.update(wids_to_halt)
568
+ except Exception:
569
+ pass
570
+
571
+ # Clear the active set immediately so UI reflects cancellation
572
+ try:
573
+ if hasattr(self, 'active_worker_ids'):
574
+ self.active_worker_ids.clear()
575
+ except Exception:
576
+ pass
577
+
578
+ # Restore UI immediately
579
+ try:
580
+ try:
581
+ self.convert_button.clicked.disconnect()
582
+ except Exception:
583
+ pass
584
+ self.convert_button.setText("Convert 2D to 3D")
585
+ self.convert_button.clicked.connect(self.trigger_conversion)
586
+ self.convert_button.setEnabled(True)
587
+ except Exception:
588
+ pass
589
+
590
+ try:
591
+ self.cleanup_button.setEnabled(True)
592
+ except Exception:
593
+ pass
594
+
595
+ # Remove any calculating text actor if present
596
+ try:
597
+ actor = getattr(self, '_calculating_text_actor', None)
598
+ if actor is not None:
599
+ if hasattr(self.plotter, 'remove_actor'):
600
+ try:
601
+ self.plotter.remove_actor(actor)
602
+ except Exception:
603
+ pass
604
+ else:
605
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
606
+ try:
607
+ self.plotter.renderer.RemoveActor(actor)
608
+ except Exception:
609
+ pass
610
+ try:
611
+ delattr(self, '_calculating_text_actor')
612
+ except Exception:
613
+ try:
614
+ del self._calculating_text_actor
615
+ except Exception:
616
+ pass
617
+ except Exception:
618
+ pass
619
+
620
+ # Give immediate feedback
621
+ self.statusBar().showMessage("3D conversion halted. Waiting for the thread to finish")
622
+ except Exception:
623
+ pass
624
+
625
+
626
+
627
+ def check_chemistry_problems_fallback(self):
628
+ """RDKit変換が失敗した場合の化学的問題チェック(独自実装)"""
629
+ try:
630
+ # 既存のフラグをクリア
631
+ self.scene.clear_all_problem_flags()
632
+
633
+ # 簡易的な化学的問題チェック
634
+ problem_atoms = []
635
+
636
+ for atom_id, atom_data in self.data.atoms.items():
637
+ atom_item = atom_data.get('item')
638
+ if not atom_item:
639
+ continue
640
+
641
+ symbol = atom_data['symbol']
642
+ charge = atom_data.get('charge', 0)
643
+
644
+ # 結合数を計算
645
+ bond_count = 0
646
+ for (id1, id2), bond_data in self.data.bonds.items():
647
+ if id1 == atom_id or id2 == atom_id:
648
+ bond_count += bond_data.get('order', 1)
649
+
650
+ # 基本的な価数チェック
651
+ is_problematic = False
652
+ if symbol == 'C' and bond_count > 4:
653
+ is_problematic = True
654
+ elif symbol == 'N' and bond_count > 3 and charge == 0:
655
+ is_problematic = True
656
+ elif symbol == 'O' and bond_count > 2 and charge == 0:
657
+ is_problematic = True
658
+ elif symbol == 'H' and bond_count > 1:
659
+ is_problematic = True
660
+ elif symbol in ['F', 'Cl', 'Br', 'I'] and bond_count > 1 and charge == 0:
661
+ is_problematic = True
662
+
663
+ if is_problematic:
664
+ problem_atoms.append(atom_item)
665
+
666
+ if problem_atoms:
667
+ # 問題のある原子に赤枠を設定
668
+ for atom_item in problem_atoms:
669
+ atom_item.has_problem = True
670
+ atom_item.update()
671
+
672
+ self.statusBar().showMessage(f"Error: {len(problem_atoms)} chemistry problem(s) found (valence issues).")
673
+ else:
674
+ self.statusBar().showMessage("Error: Invalid chemical structure (RDKit conversion failed).")
675
+
676
+ self.scene.clearSelection()
677
+ self.view_2d.setFocus()
678
+
679
+ except Exception as e:
680
+ print(f"Error in fallback chemistry check: {e}")
681
+ self.statusBar().showMessage("Error: Invalid chemical structure.")
682
+ self.view_2d.setFocus()
683
+
684
+
685
+
686
+ def optimize_3d_structure(self):
687
+ """現在の3D分子構造を力場で最適化する"""
688
+ if not self.current_mol:
689
+ self.statusBar().showMessage("No 3D molecule to optimize.")
690
+ return
691
+
692
+ # If a prior chemical/sanitization check was attempted and failed, do not run optimization
693
+ if getattr(self, 'chem_check_tried', False) and getattr(self, 'chem_check_failed', False):
694
+ self.statusBar().showMessage("3D optimization disabled: molecule failed chemical sanitization.")
695
+ # Ensure the Optimize 3D button is disabled to reflect this
696
+ if hasattr(self, 'optimize_3d_button'):
697
+ try:
698
+ self.optimize_3d_button.setEnabled(False)
699
+ except Exception:
700
+ pass
701
+ return
702
+
703
+ self.statusBar().showMessage("Optimizing 3D structure...")
704
+ QApplication.processEvents() # UIの更新を確実に行う
705
+
706
+ try:
707
+ # Allow a temporary optimization method override (right-click menu)
708
+ method = getattr(self, '_temp_optimization_method', None) or getattr(self, 'optimization_method', 'MMFF_RDKIT')
709
+ # Clear temporary override if present
710
+ if hasattr(self, '_temp_optimization_method'):
711
+ try:
712
+ del self._temp_optimization_method
713
+ except Exception:
714
+ try:
715
+ delattr(self, '_temp_optimization_method')
716
+ except Exception:
717
+ pass
718
+ method = method.upper() if method else 'MMFF_RDKIT'
719
+ # 事前チェック:コンフォーマがあるか
720
+ if self.current_mol.GetNumConformers() == 0:
721
+ self.statusBar().showMessage("No conformer found: cannot optimize. Embed molecule first.")
722
+ return
723
+ if method in ('MMFF_RDKIT', 'MMFF94_RDKIT'):
724
+ try:
725
+ # Choose concrete mmffVariant string
726
+ mmff_variant = "MMFF94s" if method == 'MMFF_RDKIT' else "MMFF94"
727
+ res = AllChem.MMFFOptimizeMolecule(self.current_mol, maxIters=4000, mmffVariant=mmff_variant)
728
+ if res != 0:
729
+ # 非収束や何らかの問題が起きた可能性 -> ForceField API で詳細に試す
730
+ try:
731
+ mmff_props = AllChem.MMFFGetMoleculeProperties(self.current_mol)
732
+ ff = AllChem.MMFFGetMoleculeForceField(self.current_mol, mmff_props, confId=0)
733
+ ff_ret = ff.Minimize(maxIts=4000)
734
+ if ff_ret != 0:
735
+ self.statusBar().showMessage(f"{mmff_variant} minimize returned non-zero status: {ff_ret}")
736
+ return
737
+ except Exception as e:
738
+ self.statusBar().showMessage(f"{mmff_variant} parameterization/minimize failed: {e}")
739
+ return
740
+ except Exception as e:
741
+ self.statusBar().showMessage(f"{mmff_variant} (RDKit) optimization error: {e}")
742
+ return
743
+ elif method == 'UFF_RDKIT':
744
+ try:
745
+ res = AllChem.UFFOptimizeMolecule(self.current_mol, maxIters=4000)
746
+ if res != 0:
747
+ try:
748
+ ff = AllChem.UFFGetMoleculeForceField(self.current_mol, confId=0)
749
+ ff_ret = ff.Minimize(maxIts=4000)
750
+ if ff_ret != 0:
751
+ self.statusBar().showMessage(f"UFF minimize returned non-zero status: {ff_ret}")
752
+ return
753
+ except Exception as e:
754
+ self.statusBar().showMessage(f"UFF parameterization/minimize failed: {e}")
755
+ return
756
+ except Exception as e:
757
+ self.statusBar().showMessage(f"UFF (RDKit) optimization error: {e}")
758
+ return
759
+ else:
760
+ self.statusBar().showMessage("Selected optimization method is not available. Use MMFF94 (RDKit) or UFF (RDKit).")
761
+ return
762
+ except Exception as e:
763
+ self.statusBar().showMessage(f"3D optimization error: {e}")
764
+
765
+ # 最適化後の構造で3Dビューを再描画
766
+ try:
767
+ # Remember which concrete optimizer variant succeeded so it
768
+ # can be saved with the project. Normalize internal flags to
769
+ # a human-friendly label: MMFF94s, MMFF94, or UFF.
770
+ try:
771
+ norm_method = None
772
+ m = method.upper() if method else None
773
+ if m in ('MMFF_RDKIT', 'MMFF94_RDKIT'):
774
+ # The code above uses mmffVariant="MMFF94s" when
775
+ # method == 'MMFF_RDKIT' and "MMFF94" otherwise.
776
+ norm_method = 'MMFF94s' if m == 'MMFF_RDKIT' else 'MMFF94'
777
+ elif m == 'UFF_RDKIT' or m == 'UFF':
778
+ norm_method = 'UFF'
779
+ else:
780
+ norm_method = getattr(self, 'optimization_method', None)
781
+
782
+ # store for later serialization
783
+ if norm_method:
784
+ self.last_successful_optimization_method = norm_method
785
+ except Exception:
786
+ pass
787
+ # 3D最適化後は3D座標から立体化学を再計算(2回目以降は3D優先)
788
+ if self.current_mol.GetNumConformers() > 0:
789
+ Chem.AssignAtomChiralTagsFromStructure(self.current_mol, confId=0)
790
+ self.update_chiral_labels() # キラル中心のラベルも更新
791
+ except Exception:
792
+ pass
793
+
794
+ self.draw_molecule_3d(self.current_mol)
795
+
796
+ # Show which method was used in the status bar (prefer human-readable label).
797
+ # Prefer the actual method used during this run (last_successful_optimization_method
798
+ # set earlier), then any temporary/local override used for this call (method),
799
+ # and finally the persisted preference (self.optimization_method).
800
+ try:
801
+ used_method = (
802
+ getattr(self, 'last_successful_optimization_method', None)
803
+ or locals().get('method', None)
804
+ or getattr(self, 'optimization_method', None)
805
+ )
806
+ used_label = None
807
+ if used_method:
808
+ # opt3d_method_labels keys are stored upper-case; normalize for lookup
809
+ used_label = (getattr(self, 'opt3d_method_labels', {}) or {}).get(str(used_method).upper(), used_method)
810
+ except Exception:
811
+ used_label = None
812
+
813
+ if used_label:
814
+ self.statusBar().showMessage(f"3D structure optimization successful. Method: {used_label}")
815
+ else:
816
+ self.statusBar().showMessage("3D structure optimization successful.")
817
+ self.push_undo_state() # Undo履歴に保存
818
+ self.view_2d.setFocus()
819
+
820
+
821
+
822
+ def on_calculation_finished(self, result):
823
+ # Accept either (worker_id, mol) tuple or legacy single mol arg
824
+ worker_id = None
825
+ mol = None
826
+ try:
827
+ if isinstance(result, tuple) and len(result) == 2:
828
+ worker_id, mol = result
829
+ else:
830
+ mol = result
831
+ except Exception:
832
+ mol = result
833
+
834
+ # If this finished result is from a stale/halting run, discard it
835
+ try:
836
+ if worker_id is not None:
837
+ # If this worker_id is not in the active set, it's stale/halting
838
+ if worker_id not in getattr(self, 'active_worker_ids', set()):
839
+ # Cleanup calculating UI and ignore
840
+ try:
841
+ actor = getattr(self, '_calculating_text_actor', None)
842
+ if actor is not None:
843
+ if hasattr(self.plotter, 'remove_actor'):
844
+ try:
845
+ self.plotter.remove_actor(actor)
846
+ except Exception:
847
+ pass
848
+ else:
849
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
850
+ try:
851
+ self.plotter.renderer.RemoveActor(actor)
852
+ except Exception:
853
+ pass
854
+ try:
855
+ delattr(self, '_calculating_text_actor')
856
+ except Exception:
857
+ try:
858
+ del self._calculating_text_actor
859
+ except Exception:
860
+ pass
861
+ except Exception:
862
+ pass
863
+ # Ensure Convert button is restored
864
+ try:
865
+ try:
866
+ self.convert_button.clicked.disconnect()
867
+ except Exception:
868
+ pass
869
+ self.convert_button.setText("Convert 2D to 3D")
870
+ self.convert_button.clicked.connect(self.trigger_conversion)
871
+ self.convert_button.setEnabled(True)
872
+ except Exception:
873
+ pass
874
+ try:
875
+ self.cleanup_button.setEnabled(True)
876
+ except Exception:
877
+ pass
878
+ self.statusBar().showMessage("Ignored result from stale conversion.")
879
+ return
880
+ except Exception:
881
+ pass
882
+
883
+ # Remove the finished worker id from the active set and any halt set
884
+ try:
885
+ if worker_id is not None:
886
+ try:
887
+ self.active_worker_ids.discard(worker_id)
888
+ except Exception:
889
+ pass
890
+ # Also remove id from halt set if present
891
+ if worker_id is not None:
892
+ try:
893
+ if worker_id in getattr(self, 'halt_ids', set()):
894
+ try:
895
+ self.halt_ids.discard(worker_id)
896
+ except Exception:
897
+ pass
898
+ except Exception:
899
+ pass
900
+ except Exception:
901
+ pass
902
+
903
+ self.dragged_atom_info = None
904
+ self.current_mol = mol
905
+ self.is_xyz_derived = False # 2Dから生成した3D構造はXYZ由来ではない
906
+ # Record the optimization method used for this conversion if available.
907
+ try:
908
+ opt_method = None
909
+ try:
910
+ # Worker or molecule may have attached a prop with the used method
911
+ if hasattr(mol, 'HasProp') and mol is not None:
912
+ try:
913
+ if mol.HasProp('_pme_optimization_method'):
914
+ opt_method = mol.GetProp('_pme_optimization_method')
915
+ except Exception:
916
+ # not all Mol objects support HasProp/GetProp safely
917
+ pass
918
+ except Exception:
919
+ pass
920
+ if not opt_method:
921
+ opt_method = getattr(self, 'optimization_method', None)
922
+ # normalize common forms
923
+ if opt_method:
924
+ om = str(opt_method).upper()
925
+ if 'MMFF94S' in om or 'MMFF_RDKIT' in om:
926
+ self.last_successful_optimization_method = 'MMFF94s'
927
+ elif 'MMFF94' in om:
928
+ self.last_successful_optimization_method = 'MMFF94'
929
+ elif 'UFF' in om:
930
+ self.last_successful_optimization_method = 'UFF'
931
+ else:
932
+ # store raw value otherwise
933
+ self.last_successful_optimization_method = opt_method
934
+ except Exception:
935
+ # non-fatal
936
+ pass
937
+
938
+ # 原子プロパティを復元(ワーカープロセスで失われたため)
939
+ if hasattr(self, 'original_atom_properties'):
940
+ for i, original_id in self.original_atom_properties.items():
941
+ if i < mol.GetNumAtoms():
942
+ atom = mol.GetAtomWithIdx(i)
943
+ atom.SetIntProp("_original_atom_id", original_id)
944
+
945
+ # 原子IDマッピングを作成
946
+ self.create_atom_id_mapping()
947
+
948
+ # キラル中心を初回変換時は2Dの立体情報を考慮して設定
949
+ try:
950
+ if mol.GetNumConformers() > 0:
951
+ # 初回変換では、2Dで設定したwedge/dashボンドの立体情報を保持
952
+ # 立体化学の割り当てを行うが、既存の2D立体情報を尊重
953
+ Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
954
+
955
+ self.update_chiral_labels()
956
+ except Exception:
957
+ # 念のためエラーを握り潰して UI を壊さない
958
+ pass
959
+
960
+ self.draw_molecule_3d(mol)
961
+
962
+ # 複数分子の場合、衝突検出と配置調整を実行
963
+ try:
964
+ frags = Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=False)
965
+ if len(frags) > 1:
966
+ self.statusBar().showMessage(f"Detecting collisions among {len(frags)} molecules...")
967
+ QApplication.processEvents()
968
+ self.adjust_molecule_positions_to_avoid_collisions(mol, frags)
969
+ self.draw_molecule_3d(mol)
970
+ self.update_chiral_labels()
971
+ self.statusBar().showMessage(f"{len(frags)} molecules converted with collision avoidance.")
972
+ except Exception as e:
973
+ print(f"Warning: Collision detection failed: {e}")
974
+ # 衝突検出に失敗してもエラーにはしない
975
+
976
+ # Ensure any 'Calculating...' text is removed and the plotter is refreshed
977
+ try:
978
+ actor = getattr(self, '_calculating_text_actor', None)
979
+ if actor is not None:
980
+ try:
981
+ # Prefer plotter API if available
982
+ if hasattr(self.plotter, 'remove_actor'):
983
+ try:
984
+ self.plotter.remove_actor(actor)
985
+ except Exception:
986
+ # Some pyvista versions use renderer.RemoveActor
987
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
988
+ try:
989
+ self.plotter.renderer.RemoveActor(actor)
990
+ except Exception:
991
+ pass
992
+ else:
993
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
994
+ try:
995
+ self.plotter.renderer.RemoveActor(actor)
996
+ except Exception:
997
+ pass
998
+ finally:
999
+ try:
1000
+ delattr(self, '_calculating_text_actor')
1001
+ except Exception:
1002
+ try:
1003
+ del self._calculating_text_actor
1004
+ except Exception:
1005
+ pass
1006
+ # Re-render to ensure the UI updates immediately
1007
+ try:
1008
+ self.plotter.render()
1009
+ except Exception:
1010
+ pass
1011
+ except Exception:
1012
+ pass
1013
+
1014
+ #self.statusBar().showMessage("3D conversion successful.")
1015
+ self.convert_button.setEnabled(True)
1016
+ # Restore Convert button text/handler in case it was changed to Halt
1017
+ try:
1018
+ try:
1019
+ self.convert_button.clicked.disconnect()
1020
+ except Exception:
1021
+ pass
1022
+ self.convert_button.setText("Convert 2D to 3D")
1023
+ self.convert_button.clicked.connect(self.trigger_conversion)
1024
+ except Exception:
1025
+ pass
1026
+ self.push_undo_state()
1027
+ self.view_2d.setFocus()
1028
+ self.cleanup_button.setEnabled(True)
1029
+
1030
+ # 3D関連機能を統一的に有効化
1031
+ self._enable_3d_features(True)
1032
+
1033
+ self.plotter.reset_camera()
1034
+
1035
+ # 3D原子情報ホバー表示を再設定
1036
+ self.setup_3d_hover()
1037
+
1038
+ # メニューテキストと状態を更新
1039
+ self.update_atom_id_menu_text()
1040
+ self.update_atom_id_menu_state()
1041
+
1042
+
1043
+
1044
+ def create_atom_id_mapping(self):
1045
+ """2D原子IDから3D RDKit原子インデックスへのマッピングを作成する(RDKitの原子プロパティ使用)"""
1046
+ if not self.current_mol:
1047
+ return
1048
+
1049
+ self.atom_id_to_rdkit_idx_map = {}
1050
+
1051
+ # RDKitの原子プロパティから直接マッピングを作成
1052
+ for i in range(self.current_mol.GetNumAtoms()):
1053
+ rdkit_atom = self.current_mol.GetAtomWithIdx(i)
1054
+ try:
1055
+ original_atom_id = rdkit_atom.GetIntProp("_original_atom_id")
1056
+ self.atom_id_to_rdkit_idx_map[original_atom_id] = i
1057
+ except KeyError:
1058
+ # プロパティが設定されていない場合(外部ファイル読み込み時など)
1059
+ continue
1060
+
1061
+
1062
+
1063
+ def on_calculation_error(self, result):
1064
+ """ワーカースレッドからのエラー(またはHalt)を処理する"""
1065
+ worker_id = None
1066
+ error_message = ""
1067
+ try:
1068
+ if isinstance(result, tuple) and len(result) == 2:
1069
+ worker_id, error_message = result
1070
+ else:
1071
+ error_message = str(result)
1072
+ except Exception:
1073
+ error_message = str(result)
1074
+
1075
+ # If this error is from a stale/previous worker (not in active set), ignore it.
1076
+ if worker_id is not None and worker_id not in getattr(self, 'active_worker_ids', set()):
1077
+ # Stale/late error from a previously-halted worker; ignore to avoid clobbering newer runs
1078
+ print(f"Ignored stale error from worker {worker_id}: {error_message}")
1079
+ return
1080
+
1081
+ # Clear temporary plotter content and remove calculating text if present
1082
+ try:
1083
+ self.plotter.clear()
1084
+ except Exception:
1085
+ pass
1086
+
1087
+ # Also attempt to explicitly remove the calculating text actor if it was stored
1088
+ try:
1089
+ actor = getattr(self, '_calculating_text_actor', None)
1090
+ if actor is not None:
1091
+ try:
1092
+ if hasattr(self.plotter, 'remove_actor'):
1093
+ try:
1094
+ self.plotter.remove_actor(actor)
1095
+ except Exception:
1096
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
1097
+ try:
1098
+ self.plotter.renderer.RemoveActor(actor)
1099
+ except Exception:
1100
+ pass
1101
+ else:
1102
+ if hasattr(self.plotter, 'renderer') and self.plotter.renderer:
1103
+ try:
1104
+ self.plotter.renderer.RemoveActor(actor)
1105
+ except Exception:
1106
+ pass
1107
+ finally:
1108
+ try:
1109
+ delattr(self, '_calculating_text_actor')
1110
+ except Exception:
1111
+ try:
1112
+ del self._calculating_text_actor
1113
+ except Exception:
1114
+ pass
1115
+ except Exception:
1116
+ pass
1117
+
1118
+ self.dragged_atom_info = None
1119
+ # Remove this worker id from active set (error belongs to this worker)
1120
+ try:
1121
+ if worker_id is not None:
1122
+ try:
1123
+ self.active_worker_ids.discard(worker_id)
1124
+ except Exception:
1125
+ pass
1126
+ except Exception:
1127
+ pass
1128
+
1129
+ # If this error was caused by an intentional halt and the main thread
1130
+ # already cleared waiting_worker_id earlier for other reasons, suppress the error noise.
1131
+ try:
1132
+ low = (error_message or '').lower()
1133
+ # If a halt message and there are no active workers left, the user
1134
+ # already saw the halt message — suppress duplicate noise.
1135
+ if 'halt' in low and not getattr(self, 'active_worker_ids', set()):
1136
+ return
1137
+ except Exception:
1138
+ pass
1139
+
1140
+ self.statusBar().showMessage(f"Error: {error_message}")
1141
+
1142
+ try:
1143
+ self.cleanup_button.setEnabled(True)
1144
+ except Exception:
1145
+ pass
1146
+ try:
1147
+ # Restore Convert button text/handler
1148
+ try:
1149
+ self.convert_button.clicked.disconnect()
1150
+ except Exception:
1151
+ pass
1152
+ self.convert_button.setText("Convert 2D to 3D")
1153
+ self.convert_button.clicked.connect(self.trigger_conversion)
1154
+ self.convert_button.setEnabled(True)
1155
+ except Exception:
1156
+ pass
1157
+
1158
+ # On calculation error we should NOT enable 3D-only features.
1159
+ # Explicitly disable Optimize and Export so the user can't try to operate
1160
+ # on an invalid or missing 3D molecule.
1161
+ try:
1162
+ if hasattr(self, 'optimize_3d_button'):
1163
+ self.optimize_3d_button.setEnabled(False)
1164
+ except Exception:
1165
+ pass
1166
+ try:
1167
+ if hasattr(self, 'export_button'):
1168
+ self.export_button.setEnabled(False)
1169
+ except Exception:
1170
+ pass
1171
+
1172
+ # Keep 3D feature buttons disabled to avoid inconsistent UI state
1173
+ try:
1174
+ self._enable_3d_features(False)
1175
+ except Exception:
1176
+ pass
1177
+
1178
+ # Keep 3D edit actions disabled (no molecule to edit)
1179
+ try:
1180
+ self._enable_3d_edit_actions(False)
1181
+ except Exception:
1182
+ pass
1183
+ # Some menu items are explicitly disabled on error
1184
+ try:
1185
+ if hasattr(self, 'analysis_action'):
1186
+ self.analysis_action.setEnabled(False)
1187
+ except Exception:
1188
+ pass
1189
+ try:
1190
+ if hasattr(self, 'edit_3d_action'):
1191
+ self.edit_3d_action.setEnabled(False)
1192
+ except Exception:
1193
+ pass
1194
+
1195
+ # Force a UI refresh
1196
+ try:
1197
+ self.plotter.render()
1198
+ except Exception:
1199
+ pass
1200
+
1201
+ # Ensure focus returns to 2D editor
1202
+ self.view_2d.setFocus()
1203
+