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,1668 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ main_window_main_init.py
6
+ MainWindow (main_window.py) から分離されたモジュール
7
+ 機能クラス: MainWindowMainInit
8
+ """
9
+
10
+
11
+ import math
12
+ import os
13
+ import json
14
+
15
+
16
+ # RDKit imports (explicit to satisfy flake8 and used features)
17
+ from rdkit import Chem
18
+ try:
19
+ pass
20
+ except Exception:
21
+ pass
22
+
23
+ # PyQt6 Modules
24
+ from PyQt6.QtWidgets import (
25
+ QApplication, QWidget, QVBoxLayout, QHBoxLayout,
26
+ QPushButton, QSplitter, QToolBar, QSizePolicy, QLabel, QToolButton, QMenu, QMessageBox
27
+ )
28
+
29
+ from PyQt6.QtGui import (
30
+ QPen, QBrush, QColor, QPainter, QAction, QActionGroup, QFont, QPolygonF,
31
+ QKeySequence,
32
+ QPixmap, QIcon, QShortcut, QDesktopServices
33
+ )
34
+
35
+
36
+ from PyQt6.QtCore import (
37
+ Qt, QPointF, QRectF, QLineF, QUrl, QTimer
38
+ )
39
+ import platform
40
+ import subprocess
41
+ try:
42
+ import winreg
43
+ except Exception:
44
+ winreg = None
45
+
46
+
47
+ def detect_system_dark_mode():
48
+ """Return True if the OS prefers dark app theme, False if light, or None if unknown.
49
+
50
+ This is a best-effort, cross-platform check supporting Windows (registry),
51
+ macOS (defaults read), and GNOME/GTK-based Linux (gsettings). Return
52
+ None if no reliable information is available.
53
+ """
54
+ # Delegate detailed OS detection to `detect_system_theme` and map
55
+ # 'dark' -> True, 'light' -> False. This avoids duplicating the
56
+ # registry and subprocess calls in two places.
57
+ theme = detect_system_theme()
58
+ if theme == 'dark':
59
+ return True
60
+ if theme == 'light':
61
+ return False
62
+ return None
63
+
64
+ def detect_system_theme():
65
+ """OSの優先テーマ設定を 'dark', 'light', または None として返す。
66
+
67
+ This is a best-effort, cross-platform check.
68
+ """
69
+ try:
70
+ # Windows: AppsUseLightTheme (0 = dark, 1 = light)
71
+ if platform.system() == 'Windows' and winreg is not None:
72
+ try:
73
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
74
+ r'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize') as k:
75
+ val, _ = winreg.QueryValueEx(k, 'AppsUseLightTheme')
76
+ return 'dark' if int(val) == 0 else 'light'
77
+ except Exception:
78
+ pass
79
+
80
+ # macOS: 'defaults read -g AppleInterfaceStyle'
81
+ if platform.system() == 'Darwin':
82
+ return 'light'
83
+ '''
84
+ try:
85
+ # 'defaults read ...' が成功すればダークモード
86
+ p = subprocess.run(
87
+ ['defaults', 'read', '-g', 'AppleInterfaceStyle'],
88
+ capture_output=True, text=True, check=True, encoding='utf-8'
89
+ )
90
+ if p.stdout.strip().lower() == 'dark':
91
+ return 'dark'
92
+
93
+ except subprocess.CalledProcessError:
94
+ # コマンド失敗 (キーが存在しない) = ライトモード
95
+ return 'light'
96
+ except Exception:
97
+ # その他のエラー
98
+ pass
99
+ '''
100
+
101
+ # Linux / GNOME: try color-scheme gsetting; fallback to gtk-theme detection
102
+ if platform.system() == 'Linux':
103
+ try:
104
+ p = subprocess.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], capture_output=True, text=True)
105
+ if p.returncode == 0:
106
+ out = p.stdout.strip().strip("'\n ")
107
+ if 'dark' in out.lower():
108
+ return 'dark'
109
+ if 'light' in out.lower():
110
+ return 'light'
111
+ except Exception:
112
+ pass
113
+
114
+ try:
115
+ p = subprocess.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], capture_output=True, text=True)
116
+ if p.returncode == 0 and '-dark' in p.stdout.lower():
117
+ return 'dark'
118
+ except Exception:
119
+ pass
120
+ except Exception:
121
+ pass
122
+ return None
123
+
124
+
125
+ # Use centralized Open Babel availability from package-level __init__
126
+ # Use per-package modules availability (local __init__).
127
+ try:
128
+ from . import OBABEL_AVAILABLE
129
+ except Exception:
130
+ from modules import OBABEL_AVAILABLE
131
+ # Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
132
+ if OBABEL_AVAILABLE:
133
+ try:
134
+ from openbabel import pybel
135
+ except Exception:
136
+ # If import fails here, disable OBABEL locally; avoid raising
137
+ pybel = None
138
+ OBABEL_AVAILABLE = False
139
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
140
+ else:
141
+ pybel = None
142
+
143
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
144
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
145
+ # it once at module import time and expose a small, robust wrapper so callers
146
+ # can avoid re-importing sip repeatedly and so we centralize exception
147
+ # handling (this reduces crash risk during teardown and deletion operations).
148
+ try:
149
+ import sip as _sip # type: ignore
150
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
151
+ except Exception:
152
+ _sip = None
153
+ _sip_isdeleted = None
154
+
155
+ try:
156
+ # package relative imports (preferred when running as `python -m moleditpy`)
157
+ from .constants import NUM_DASHES, VERSION
158
+ from .molecular_data import MolecularData
159
+ from .molecule_scene import MoleculeScene
160
+ from .zoomable_view import ZoomableView
161
+ from .color_settings_dialog import ColorSettingsDialog
162
+ from .settings_dialog import SettingsDialog
163
+ from .custom_qt_interactor import CustomQtInteractor
164
+ except Exception:
165
+ # Fallback to absolute imports for script-style execution
166
+ from modules.constants import NUM_DASHES, VERSION
167
+ from modules.molecular_data import MolecularData
168
+ from modules.molecule_scene import MoleculeScene
169
+ from modules.zoomable_view import ZoomableView
170
+ from modules.color_settings_dialog import ColorSettingsDialog
171
+ from modules.settings_dialog import SettingsDialog
172
+ from modules.custom_qt_interactor import CustomQtInteractor
173
+
174
+
175
+
176
+ # --- クラス定義 ---
177
+ class MainWindowMainInit(object):
178
+ """ main_window.py から分離された機能クラス """
179
+
180
+ # __init__ は main_window.py からコピーされます
181
+
182
+
183
+ def __init__(self, initial_file=None):
184
+ # This helper is not used as a mixin in this project; initialization
185
+ # happens on the `MainWindow` instance. Avoid calling super() here
186
+ # because we initialize the `QMainWindow` base class in
187
+ # `MainWindow.__init__` directly.
188
+ self.setAcceptDrops(True)
189
+ self.settings_dir = os.path.join(os.path.expanduser('~'), '.moleditpy')
190
+ self.settings_file = os.path.join(self.settings_dir, 'settings.json')
191
+ self.settings = {}
192
+ self.load_settings()
193
+ self.initial_settings = self.settings.copy()
194
+ self.setWindowTitle("MoleditPy Ver. " + VERSION); self.setGeometry(100, 100, 1400, 800)
195
+ self.data = MolecularData(); self.current_mol = None
196
+ self.current_3d_style = 'ball_and_stick'
197
+ self.show_chiral_labels = False
198
+ self.atom_info_display_mode = None # 'id', 'coords', 'symbol', or None
199
+ self.current_atom_info_labels = None # 現在の原子情報ラベル
200
+ self.is_3d_edit_mode = False
201
+ self.dragged_atom_info = None
202
+ self.atom_actor = None
203
+ self.is_2d_editable = True
204
+ self.is_xyz_derived = False # XYZ由来の分子かどうかのフラグ
205
+ # Chemical check flags: whether a chemical/sanitization check was attempted and whether it failed
206
+ self.chem_check_tried = False
207
+ self.chem_check_failed = False
208
+ # 3D最適化のデフォルト手法
209
+ self.optimization_method = self.settings.get('optimization_method', 'MMFF_RDKIT')
210
+ self.axes_actor = None
211
+ self.axes_widget = None
212
+ self._template_dialog = None # テンプレートダイアログの参照
213
+ self.undo_stack = []
214
+ self.redo_stack = []
215
+ self.constraints_3d = []
216
+ self.mode_actions = {}
217
+
218
+ # 保存状態を追跡する変数
219
+ self.has_unsaved_changes = False
220
+ # 設定ファイルのディスク書き込みを遅延するフラグ
221
+ # True に設定された場合、設定はメモリ上で更新され、アプリ終了時にまとめて保存されます。
222
+ self.settings_dirty = True
223
+ self.current_file_path = None # 現在開いているファイルのパス
224
+ self.initialization_complete = False # 初期化完了フラグ
225
+ # Token to invalidate pending implicit-hydrogen UI updates
226
+ self._ih_update_counter = 0
227
+
228
+ # 測定機能用の変数
229
+ self.measurement_mode = False
230
+ self.selected_atoms_for_measurement = []
231
+ self.measurement_labels = [] # (atom_idx, label_text) のタプルのリスト
232
+ self.measurement_text_actor = None
233
+ self.measurement_label_items_2d = [] # 2Dビューの測定ラベルアイテム
234
+ self.atom_id_to_rdkit_idx_map = {} # 2D原子IDから3D RDKit原子インデックスへのマッピング
235
+
236
+ # 3D原子選択用の変数
237
+ self.selected_atoms_3d = set()
238
+ self.atom_selection_mode = False
239
+ self.selected_atom_actors = []
240
+
241
+ # 3D編集用の原子選択状態 (3Dビューで選択された原子のインデックス)
242
+ self.selected_atoms_3d = set()
243
+
244
+ # 3D編集ダイアログの参照を保持
245
+ self.active_3d_dialogs = []
246
+
247
+ self.init_ui()
248
+ self.init_worker_thread()
249
+ self._setup_3d_picker()
250
+
251
+ # --- RDKit初回実行コストの事前読み込み(ウォームアップ)---
252
+ try:
253
+ # Create a molecule with a variety of common atoms to ensure
254
+ # the valence/H-count machinery is fully initialized.
255
+ warmup_smiles = "OC(N)C(S)P"
256
+ warmup_mol = Chem.MolFromSmiles(warmup_smiles)
257
+ if warmup_mol:
258
+ for atom in warmup_mol.GetAtoms():
259
+ atom.GetNumImplicitHs()
260
+ except Exception as e:
261
+ print(f"RDKit warm-up failed: {e}")
262
+
263
+ self.reset_undo_stack()
264
+ self.scene.selectionChanged.connect(self.update_edit_menu_actions)
265
+ QApplication.clipboard().dataChanged.connect(self.update_edit_menu_actions)
266
+
267
+ self.update_edit_menu_actions()
268
+
269
+ if initial_file:
270
+ self.load_command_line_file(initial_file)
271
+
272
+ QTimer.singleShot(0, self.apply_initial_settings)
273
+ # カメラ初期化フラグ(初回描画時のみリセットを許可する)
274
+ self._camera_initialized = False
275
+
276
+ # 初期メニューテキストと状態を設定
277
+ self.update_atom_id_menu_text()
278
+ self.update_atom_id_menu_state()
279
+
280
+ # 初期化完了を設定
281
+ self.initialization_complete = True
282
+ self.update_window_title() # 初期化完了後にタイトルを更新
283
+ # Ensure initial keyboard/mouse focus is placed on the 2D view
284
+ # when opening a file or starting the application. This avoids
285
+ # accidental focus landing on toolbar/buttons (e.g. Optimize 2D).
286
+ try:
287
+ QTimer.singleShot(0, self.view_2d.setFocus)
288
+ except Exception:
289
+ pass
290
+
291
+
292
+
293
+ def init_ui(self):
294
+ # 1. 現在のスクリプトがあるディレクトリのパスを取得
295
+ script_dir = os.path.dirname(os.path.abspath(__file__))
296
+
297
+ # 2. 'assets'フォルダ内のアイコンファイルへのフルパスを構築
298
+ icon_path = os.path.join(script_dir, 'assets', 'icon.png')
299
+
300
+ # 3. ファイルパスから直接QIconオブジェクトを作成
301
+ if os.path.exists(icon_path): # ファイルが存在するか確認
302
+ app_icon = QIcon(icon_path)
303
+
304
+ # 4. ウィンドウにアイコンを設定
305
+ self.setWindowIcon(app_icon)
306
+ else:
307
+ print(f"警告: アイコンファイルが見つかりません: {icon_path}")
308
+
309
+ self.init_menu_bar()
310
+
311
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
312
+ # スプリッターハンドルを太くして視認性を向上
313
+ self.splitter.setHandleWidth(8)
314
+ # スプリッターハンドルのスタイルを改善
315
+ self.splitter.setStyleSheet("""
316
+ QSplitter::handle {
317
+ background-color: #ccc;
318
+ border: 1px solid #999;
319
+ border-radius: 4px;
320
+ margin: 2px;
321
+ }
322
+ QSplitter::handle:hover {
323
+ background-color: #aaa;
324
+ }
325
+ QSplitter::handle:pressed {
326
+ background-color: #888;
327
+ }
328
+ """)
329
+ self.setCentralWidget(self.splitter)
330
+
331
+ left_pane=QWidget()
332
+ left_pane.setAcceptDrops(True)
333
+ left_layout=QVBoxLayout(left_pane)
334
+
335
+ self.scene=MoleculeScene(self.data,self)
336
+ self.scene.setSceneRect(-4000,-4000,4000,4000)
337
+ self.scene.setBackgroundBrush(QColor("#FFFFFF"))
338
+
339
+ self.view_2d=ZoomableView(self.scene, self)
340
+ self.view_2d.setRenderHint(QPainter.RenderHint.Antialiasing)
341
+ self.view_2d.setSizePolicy(
342
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
343
+ )
344
+ left_layout.addWidget(self.view_2d, 1)
345
+
346
+ self.view_2d.scale(0.75, 0.75)
347
+
348
+ # --- 左パネルのボタンレイアウト ---
349
+ left_buttons_layout = QHBoxLayout()
350
+ self.cleanup_button = QPushButton("Optimize 2D")
351
+ self.cleanup_button.clicked.connect(self.clean_up_2d_structure)
352
+ left_buttons_layout.addWidget(self.cleanup_button)
353
+
354
+ self.convert_button = QPushButton("Convert 2D to 3D")
355
+ self.convert_button.clicked.connect(self.trigger_conversion)
356
+ # Allow right-click to open a temporary conversion-mode menu
357
+ try:
358
+ self.convert_button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
359
+ self.convert_button.customContextMenuRequested.connect(self.show_convert_menu)
360
+ except Exception:
361
+ pass
362
+ left_buttons_layout.addWidget(self.convert_button)
363
+
364
+ left_layout.addLayout(left_buttons_layout)
365
+ self.splitter.addWidget(left_pane)
366
+
367
+ # --- 右パネルとボタンレイアウト ---
368
+ right_pane = QWidget()
369
+ # 1. 右パネル全体は「垂直」レイアウトにする
370
+ right_layout = QVBoxLayout(right_pane)
371
+ self.plotter = CustomQtInteractor(right_pane, main_window=self, lighting='none')
372
+ self.plotter.setAcceptDrops(False)
373
+ self.plotter.setSizePolicy(
374
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
375
+ )
376
+ # 2. 垂直レイアウトに3Dビューを追加
377
+ right_layout.addWidget(self.plotter, 1)
378
+ #self.plotter.installEventFilter(self)
379
+
380
+ # 3. ボタンをまとめるための「水平」レイアウトを作成
381
+ right_buttons_layout = QHBoxLayout()
382
+
383
+ # 3D最適化ボタン
384
+ self.optimize_3d_button = QPushButton("Optimize 3D")
385
+ self.optimize_3d_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
386
+ self.optimize_3d_button.clicked.connect(self.optimize_3d_structure)
387
+ self.optimize_3d_button.setEnabled(False)
388
+ # 初期状態は_enable_3d_features(False)で統一的に設定
389
+ # Allow right-click to open a temporary optimization-method menu
390
+ try:
391
+ self.optimize_3d_button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
392
+ self.optimize_3d_button.customContextMenuRequested.connect(self.show_optimize_menu)
393
+ except Exception:
394
+ pass
395
+ right_buttons_layout.addWidget(self.optimize_3d_button)
396
+
397
+ # エクスポートボタン (メニュー付き)
398
+ self.export_button = QToolButton()
399
+ self.export_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
400
+ self.export_button.setText("Export 3D")
401
+ self.export_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
402
+ self.export_button.setEnabled(False) # 初期状態は無効
403
+
404
+ export_menu = QMenu(self)
405
+ export_mol_action = QAction("Export as MOL...", self)
406
+ export_mol_action.triggered.connect(self.save_3d_as_mol)
407
+ export_menu.addAction(export_mol_action)
408
+
409
+ export_xyz_action = QAction("Export as XYZ...", self)
410
+ export_xyz_action.triggered.connect(self.save_as_xyz)
411
+ export_menu.addAction(export_xyz_action)
412
+
413
+ export_png_action = QAction("Export as PNG...", self)
414
+ export_png_action.triggered.connect(self.export_3d_png)
415
+ export_menu.addAction(export_png_action)
416
+
417
+ self.export_button.setMenu(export_menu)
418
+ right_buttons_layout.addWidget(self.export_button)
419
+
420
+ # 4. 水平のボタンレイアウトを、全体の垂直レイアウトに追加
421
+ right_layout.addLayout(right_buttons_layout)
422
+ self.splitter.addWidget(right_pane)
423
+
424
+ # スプリッターのサイズ変更をモニターして、フィードバックを提供
425
+ self.splitter.splitterMoved.connect(self.on_splitter_moved)
426
+
427
+ self.splitter.setSizes([600, 600])
428
+
429
+ # スプリッターハンドルにツールチップを設定
430
+ QTimer.singleShot(100, self.setup_splitter_tooltip)
431
+
432
+ # ステータスバーを左右に分離するための設定
433
+ self.status_bar = self.statusBar()
434
+ self.formula_label = QLabel("") # 右側に表示するラベルを作成
435
+ # 右端に余白を追加して見栄えを調整
436
+ self.formula_label.setStyleSheet("padding-right: 8px;")
437
+ # ラベルを右側に常時表示ウィジェットとして追加
438
+ self.status_bar.addPermanentWidget(self.formula_label)
439
+
440
+ #self.view_2d.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
441
+
442
+ # Top/main toolbar (keep 3D Edit controls on the right end of this toolbar)
443
+ toolbar = QToolBar("Main Toolbar")
444
+ self.addToolBar(toolbar)
445
+ # Keep a reference to the main toolbar for later updates
446
+ self.toolbar = toolbar
447
+
448
+ # Templates toolbar: place it directly below the main toolbar (second row at the top)
449
+ # Use addToolBarBreak to ensure this toolbar appears on the next row under the main toolbar.
450
+ # Some older PyQt/PySide versions may not have addToolBarBreak; fall back silently in that case.
451
+ try:
452
+ # Insert a toolbar break in the Top toolbar area to force the next toolbar onto a new row
453
+ self.addToolBarBreak(Qt.ToolBarArea.TopToolBarArea)
454
+ except Exception:
455
+ # If addToolBarBreak isn't available, continue without raising; placement may still work depending on the platform.
456
+ pass
457
+
458
+ toolbar_bottom = QToolBar("Templates Toolbar")
459
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar_bottom)
460
+ self.toolbar_bottom = toolbar_bottom
461
+ self.tool_group = QActionGroup(self)
462
+ self.tool_group.setExclusive(True)
463
+
464
+ actions_data = [
465
+ ("Select", 'select', 'Space'), ("C", 'atom_C', 'c'), ("H", 'atom_H', 'h'), ("B", 'atom_B', 'b'),
466
+ ("N", 'atom_N', 'n'), ("O", 'atom_O', 'o'), ("S", 'atom_S', 's'), ("Si", 'atom_Si', 'Shift+S'), ("P", 'atom_P', 'p'),
467
+ ("F", 'atom_F', 'f'), ("Cl", 'atom_Cl', 'Shift+C'), ("Br", 'atom_Br', 'Shift+B'), ("I", 'atom_I', 'i'),
468
+ ("Other...", 'atom_other', '')
469
+ ]
470
+
471
+ for text, mode, shortcut_text in actions_data:
472
+ if text == "C": toolbar.addSeparator()
473
+
474
+ action = QAction(text, self, checkable=(mode != 'atom_other'))
475
+ if shortcut_text: action.setToolTip(f"{text} ({shortcut_text})")
476
+
477
+ if mode == 'atom_other':
478
+ action.triggered.connect(self.open_periodic_table_dialog)
479
+ self.other_atom_action = action
480
+ else:
481
+ action.triggered.connect(lambda c, m=mode: self.set_mode(m))
482
+ self.mode_actions[mode] = action
483
+
484
+ toolbar.addAction(action)
485
+ if mode != 'atom_other': self.tool_group.addAction(action)
486
+
487
+ if text == "Select":
488
+ select_action = action
489
+
490
+ toolbar.addSeparator()
491
+
492
+ # --- アイコン前景色を決めるヘルパー(ダーク/ライトモード対応) ---
493
+ # Use module-level detector `detect_system_dark_mode()` so tests and other
494
+ # modules can reuse the logic.
495
+
496
+
497
+ def _icon_foreground_color():
498
+ """Return a QColor for icon foreground.
499
+
500
+ NOTE: choose icon foreground to contrast against the background
501
+ (i.e., white on dark backgrounds, black on light backgrounds). This
502
+ matches common conventions. Priority: explicit setting in
503
+ 'icon_foreground' -> OS theme preference -> configured 3D
504
+ theme preference -> configured 3D background -> application palette.
505
+ """
506
+ try:
507
+ fg_hex = self.settings.get('icon_foreground')
508
+ if fg_hex:
509
+ c = QColor(fg_hex)
510
+ if c.isValid():
511
+ return c
512
+ except Exception:
513
+ pass
514
+
515
+ # 1) Prefer the system/OS dark-mode preference if available.
516
+ try:
517
+ os_pref = detect_system_dark_mode()
518
+ # Standard mapping: dark -> white, light -> black
519
+ if os_pref is not None:
520
+ return QColor('#FFFFFF') if os_pref else QColor('#000000')
521
+ except Exception:
522
+ pass
523
+
524
+ try:
525
+ # Keep background_color as a fallback: if system preference isn't
526
+ # available we'll use the configured 3D view background from settings.
527
+ bg_hex = self.settings.get('background_color')
528
+ if bg_hex:
529
+ bg = QColor(bg_hex)
530
+ if bg.isValid():
531
+ lum = 0.2126 * bg.redF() + 0.7152 * bg.greenF() + 0.0722 * bg.blueF()
532
+ # Return white on dark (lum<0.5), black on light
533
+ return QColor('#FFFFFF') if lum < 0.5 else QColor('#000000')
534
+ except Exception:
535
+ pass
536
+
537
+ try:
538
+ pal = QApplication.palette()
539
+ # palette.window() returns a QBrush; call color()
540
+ window_bg = pal.window().color()
541
+ lum = 0.2126 * window_bg.redF() + 0.7152 * window_bg.greenF() + 0.0722 * window_bg.blueF()
542
+ # Palette-based mapping: white on dark palette background
543
+ return QColor('#FFFFFF') if lum < 0.5 else QColor('#000000')
544
+ except Exception:
545
+ return QColor('#000000')
546
+
547
+ # --- 結合ボタンのアイコンを生成するヘルパー関数 ---
548
+ def create_bond_icon(bond_type, size=32):
549
+ fg = _icon_foreground_color()
550
+ pixmap = QPixmap(size, size)
551
+ pixmap.fill(Qt.GlobalColor.transparent)
552
+ painter = QPainter(pixmap)
553
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
554
+
555
+ p1 = QPointF(6, size / 2)
556
+ p2 = QPointF(size - 6, size / 2)
557
+ line = QLineF(p1, p2)
558
+
559
+ pen = QPen(fg, 2)
560
+ painter.setPen(pen)
561
+ painter.setBrush(QBrush(fg))
562
+
563
+ if bond_type == 'single':
564
+ painter.drawLine(line)
565
+ elif bond_type == 'double':
566
+ v = line.unitVector().normalVector()
567
+ offset = QPointF(v.dx(), v.dy()) * 2.5
568
+ painter.drawLine(line.translated(offset))
569
+ painter.drawLine(line.translated(-offset))
570
+ elif bond_type == 'triple':
571
+ v = line.unitVector().normalVector()
572
+ offset = QPointF(v.dx(), v.dy()) * 3.0
573
+ painter.drawLine(line)
574
+ painter.drawLine(line.translated(offset))
575
+ painter.drawLine(line.translated(-offset))
576
+ elif bond_type == 'wedge':
577
+ vec = line.unitVector()
578
+ normal = vec.normalVector()
579
+ offset = QPointF(normal.dx(), normal.dy()) * 5.0
580
+ poly = QPolygonF([p1, p2 + offset, p2 - offset])
581
+ painter.drawPolygon(poly)
582
+ elif bond_type == 'dash':
583
+ vec = line.unitVector()
584
+ normal = vec.normalVector()
585
+
586
+ num_dashes = NUM_DASHES
587
+ for i in range(num_dashes + 1):
588
+ t = i / num_dashes
589
+ start_pt = p1 * (1 - t) + p2 * t
590
+ width = 10 * t
591
+ offset = QPointF(normal.dx(), normal.dy()) * width / 2.0
592
+ painter.setPen(QPen(fg, 1.5))
593
+ painter.drawLine(start_pt - offset, start_pt + offset)
594
+
595
+ elif bond_type == 'ez_toggle':
596
+ # アイコン下部に二重結合を描画
597
+ p1 = QPointF(6, size * 0.75)
598
+ p2 = QPointF(size - 6, size * 0.75)
599
+ line = QLineF(p1, p2)
600
+ v = line.unitVector().normalVector()
601
+ offset = QPointF(v.dx(), v.dy()) * 2.0
602
+ painter.setPen(QPen(fg, 2))
603
+ painter.drawLine(line.translated(offset))
604
+ painter.drawLine(line.translated(-offset))
605
+ # 上部に "Z⇌E" のテキストを描画
606
+ painter.setPen(QPen(fg, 1))
607
+ font = painter.font()
608
+ font.setPointSize(10)
609
+ font.setBold(True)
610
+ painter.setFont(font)
611
+ text_rect = QRectF(0, 0, size, size * 0.6)
612
+ # U+21CC は右向きと左向きのハープーンが重なった記号 (⇌)
613
+ painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, "Z⇌E")
614
+
615
+ painter.end()
616
+ return QIcon(pixmap)
617
+
618
+ # --- 結合ボタンをツールバーに追加 ---
619
+ bond_actions_data = [
620
+ ("Single Bond", 'bond_1_0', '1', 'single'),
621
+ ("Double Bond", 'bond_2_0', '2', 'double'),
622
+ ("Triple Bond", 'bond_3_0', '3', 'triple'),
623
+ ("Wedge Bond", 'bond_1_1', 'W', 'wedge'),
624
+ ("Dash Bond", 'bond_1_2', 'D', 'dash'),
625
+ ("Toggle E/Z", 'bond_2_5', 'E/Z', 'ez_toggle'),
626
+ ]
627
+
628
+ for text, mode, shortcut_text, icon_type in bond_actions_data:
629
+ action = QAction(self)
630
+ action.setIcon(create_bond_icon(icon_type))
631
+ action.setToolTip(f"{text} ({shortcut_text})")
632
+ action.setCheckable(True)
633
+ action.triggered.connect(lambda checked, m=mode: self.set_mode(m))
634
+ self.mode_actions[mode] = action
635
+ toolbar.addAction(action)
636
+ self.tool_group.addAction(action)
637
+
638
+ toolbar.addSeparator()
639
+
640
+ charge_plus_action = QAction("+ Charge", self, checkable=True)
641
+ charge_plus_action.setToolTip("Increase Atom Charge (+)")
642
+ charge_plus_action.triggered.connect(lambda c, m='charge_plus': self.set_mode(m))
643
+ self.mode_actions['charge_plus'] = charge_plus_action
644
+ toolbar.addAction(charge_plus_action)
645
+ self.tool_group.addAction(charge_plus_action)
646
+
647
+ charge_minus_action = QAction("- Charge", self, checkable=True)
648
+ charge_minus_action.setToolTip("Decrease Atom Charge (-)")
649
+ charge_minus_action.triggered.connect(lambda c, m='charge_minus': self.set_mode(m))
650
+ self.mode_actions['charge_minus'] = charge_minus_action
651
+ toolbar.addAction(charge_minus_action)
652
+ self.tool_group.addAction(charge_minus_action)
653
+
654
+ radical_action = QAction("Radical", self, checkable=True)
655
+ radical_action.setToolTip("Toggle Radical (0/1/2) (.)")
656
+ radical_action.triggered.connect(lambda c, m='radical': self.set_mode(m))
657
+ self.mode_actions['radical'] = radical_action
658
+ toolbar.addAction(radical_action)
659
+ self.tool_group.addAction(radical_action)
660
+
661
+ # We will show template controls in the bottom toolbar to improve layout.
662
+ # Add a small label to the bottom toolbar instead of the main toolbar.
663
+ toolbar_bottom.addWidget(QLabel(" Templates:"))
664
+
665
+ # --- アイコンを生成するヘルパー関数 ---
666
+ def create_template_icon(n, is_benzene=False):
667
+ size = 32
668
+ fg = _icon_foreground_color()
669
+ pixmap = QPixmap(size, size)
670
+ pixmap.fill(Qt.GlobalColor.transparent)
671
+ painter = QPainter(pixmap)
672
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
673
+ painter.setPen(QPen(fg, 2))
674
+
675
+ center = QPointF(size / 2, size / 2)
676
+ radius = size / 2 - 4 # アイコンの余白
677
+
678
+ points = []
679
+ angle_step = 2 * math.pi / n
680
+ # ポリゴンが直立するように開始角度を調整
681
+ start_angle = -math.pi / 2 if n % 2 != 0 else -math.pi / 2 - angle_step / 2
682
+
683
+ for i in range(n):
684
+ angle = start_angle + i * angle_step
685
+ x = center.x() + radius * math.cos(angle)
686
+ y = center.y() + radius * math.sin(angle)
687
+ points.append(QPointF(x, y))
688
+
689
+ painter.drawPolygon(QPolygonF(points))
690
+
691
+ if is_benzene:
692
+ painter.drawEllipse(center, radius * 0.6, radius * 0.6)
693
+
694
+ if n in [7, 8, 9]:
695
+ font = QFont("Arial", 10, QFont.Weight.Bold)
696
+ painter.setFont(font)
697
+ painter.setPen(QPen(fg, 1))
698
+ painter.drawText(QRectF(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, str(n))
699
+
700
+ painter.end()
701
+ return QIcon(pixmap)
702
+
703
+ # --- ヘルパー関数を使ってアイコン付きボタンを作成 ---
704
+ templates = [("Benzene", "template_benzene", 6)] + [(f"{i}-Ring", f"template_{i}", i) for i in range(3, 10)]
705
+ for text, mode, n in templates:
706
+ action = QAction(self) # テキストなしでアクションを作成
707
+ action.setCheckable(True)
708
+
709
+ is_benzene = (text == "Benzene")
710
+ icon = create_template_icon(n, is_benzene=is_benzene)
711
+ action.setIcon(icon) # アイコンを設定
712
+
713
+ if text == "Benzene":
714
+ action.setToolTip(f"{text} Template (4)")
715
+ else:
716
+ action.setToolTip(f"{text} Template")
717
+
718
+ action.triggered.connect(lambda c, m=mode: self.set_mode(m))
719
+ self.mode_actions[mode] = action
720
+ # Add template actions to the bottom toolbar so templates are on the second line
721
+ toolbar_bottom.addAction(action)
722
+ self.tool_group.addAction(action)
723
+
724
+ # Add USER button for user templates (placed in bottom toolbar)
725
+ user_template_action = QAction("USER", self)
726
+ user_template_action.setCheckable(True)
727
+ user_template_action.setToolTip("Open User Templates Dialog")
728
+ user_template_action.triggered.connect(self.open_template_dialog_and_activate)
729
+ self.mode_actions['template_user'] = user_template_action
730
+ toolbar_bottom.addAction(user_template_action)
731
+ self.tool_group.addAction(user_template_action)
732
+
733
+ # 初期モードを'select'から'atom_C'(炭素原子描画モード)に変更
734
+ self.set_mode('atom_C')
735
+ # 対応するツールバーの'C'ボタンを選択状態にする
736
+ if 'atom_C' in self.mode_actions:
737
+ self.mode_actions['atom_C'].setChecked(True)
738
+
739
+ # スペーサーを追加して、次のウィジェットを右端に配置する (keep on top toolbar)
740
+ spacer = QWidget()
741
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
742
+ toolbar.addWidget(spacer)
743
+
744
+ # 測定機能ボタンを追加("3D Select"に変更)
745
+ self.measurement_action = QAction("3D Select", self, checkable=True)
746
+ self.measurement_action.setToolTip("Enable distance, angle, and dihedral measurement in 3D view")
747
+ # 初期状態でも有効にする
748
+ self.measurement_action.triggered.connect(self.toggle_measurement_mode)
749
+ toolbar.addAction(self.measurement_action)
750
+
751
+ self.edit_3d_action = QAction("3D Drag", self, checkable=True)
752
+ self.edit_3d_action.setToolTip("Toggle 3D atom dragging mode (Hold Alt for temporary mode)")
753
+ # 初期状態でも有効にする
754
+ self.edit_3d_action.toggled.connect(self.toggle_3d_edit_mode)
755
+ toolbar.addAction(self.edit_3d_action)
756
+
757
+ # 3Dスタイル変更ボタンとメニューを作成
758
+
759
+ self.style_button = QToolButton()
760
+ self.style_button.setText("3D Style")
761
+ self.style_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
762
+ toolbar.addWidget(self.style_button)
763
+
764
+ style_menu = QMenu(self)
765
+ self.style_button.setMenu(style_menu)
766
+
767
+ style_group = QActionGroup(self)
768
+ style_group.setExclusive(True)
769
+
770
+ # Ball & Stick アクション
771
+ bs_action = QAction("Ball & Stick", self, checkable=True)
772
+ bs_action.setChecked(True)
773
+ bs_action.triggered.connect(lambda: self.set_3d_style('ball_and_stick'))
774
+ style_menu.addAction(bs_action)
775
+ style_group.addAction(bs_action)
776
+
777
+ # CPK アクション
778
+ cpk_action = QAction("CPK (Space-filling)", self, checkable=True)
779
+ cpk_action.triggered.connect(lambda: self.set_3d_style('cpk'))
780
+ style_menu.addAction(cpk_action)
781
+ style_group.addAction(cpk_action)
782
+
783
+ # Wireframe アクション
784
+ wireframe_action = QAction("Wireframe", self, checkable=True)
785
+ wireframe_action.triggered.connect(lambda: self.set_3d_style('wireframe'))
786
+ style_menu.addAction(wireframe_action)
787
+ style_group.addAction(wireframe_action)
788
+
789
+ # Stick アクション
790
+ stick_action = QAction("Stick", self, checkable=True)
791
+ stick_action.triggered.connect(lambda: self.set_3d_style('stick'))
792
+ style_menu.addAction(stick_action)
793
+ style_group.addAction(stick_action)
794
+
795
+ quit_shortcut = QShortcut(QKeySequence("Ctrl+Q"), self)
796
+ quit_shortcut.activated.connect(self.close)
797
+
798
+ self.view_2d.setFocus()
799
+
800
+
801
+
802
+ def init_menu_bar(self):
803
+ menu_bar = self.menuBar()
804
+
805
+ file_menu = menu_bar.addMenu("&File")
806
+
807
+ # === プロジェクト操作 ===
808
+ new_action = QAction("&New", self)
809
+ new_action.setShortcut("Ctrl+N")
810
+ new_action.triggered.connect(self.clear_all)
811
+ file_menu.addAction(new_action)
812
+
813
+ load_project_action = QAction("&Open Project...", self)
814
+ load_project_action.setShortcut("Ctrl+O")
815
+ load_project_action.triggered.connect(self.open_project_file)
816
+ file_menu.addAction(load_project_action)
817
+
818
+ save_action = QAction("&Save Project", self)
819
+ save_action.setShortcut("Ctrl+S")
820
+ save_action.triggered.connect(self.save_project)
821
+ file_menu.addAction(save_action)
822
+
823
+ save_as_action = QAction("Save Project &As...", self)
824
+ save_as_action.setShortcut("Ctrl+Shift+S")
825
+ save_as_action.triggered.connect(self.save_project_as)
826
+ file_menu.addAction(save_as_action)
827
+
828
+ save_template_action = QAction("Save 2D as Template...", self)
829
+ save_template_action.triggered.connect(self.save_2d_as_template)
830
+ file_menu.addAction(save_template_action)
831
+
832
+ file_menu.addSeparator()
833
+
834
+ # === インポート ===
835
+ import_menu = file_menu.addMenu("Import")
836
+
837
+ load_mol_action = QAction("MOL/SDF File...", self)
838
+ load_mol_action.triggered.connect(self.load_mol_file)
839
+ import_menu.addAction(load_mol_action)
840
+
841
+ import_smiles_action = QAction("SMILES...", self)
842
+ import_smiles_action.triggered.connect(self.import_smiles_dialog)
843
+ import_menu.addAction(import_smiles_action)
844
+
845
+ import_inchi_action = QAction("InChI...", self)
846
+ import_inchi_action.triggered.connect(self.import_inchi_dialog)
847
+ import_menu.addAction(import_inchi_action)
848
+
849
+ import_menu.addSeparator()
850
+
851
+ load_3d_mol_action = QAction("3D MOL/SDF (3D View Only)...", self)
852
+ load_3d_mol_action.triggered.connect(self.load_mol_file_for_3d_viewing)
853
+ import_menu.addAction(load_3d_mol_action)
854
+
855
+ load_3d_xyz_action = QAction("3D XYZ (3D View Only)...", self)
856
+ load_3d_xyz_action.triggered.connect(self.load_xyz_for_3d_viewing)
857
+ import_menu.addAction(load_3d_xyz_action)
858
+
859
+ # === エクスポート ===
860
+ export_menu = file_menu.addMenu("Export")
861
+
862
+ # プロジェクト形式エクスポート
863
+ export_pmeraw_action = QAction("PME Raw Format...", self)
864
+ export_pmeraw_action.triggered.connect(self.save_raw_data)
865
+ export_menu.addAction(export_pmeraw_action)
866
+
867
+ export_menu.addSeparator()
868
+
869
+ # 2D エクスポート
870
+ export_2d_menu = export_menu.addMenu("2D Formats")
871
+ save_mol_action = QAction("MOL File...", self)
872
+ save_mol_action.triggered.connect(self.save_as_mol)
873
+ export_2d_menu.addAction(save_mol_action)
874
+
875
+ export_2d_png_action = QAction("PNG Image...", self)
876
+ export_2d_png_action.triggered.connect(self.export_2d_png)
877
+ export_2d_menu.addAction(export_2d_png_action)
878
+
879
+ # 3D エクスポート
880
+ export_3d_menu = export_menu.addMenu("3D Formats")
881
+ save_3d_mol_action = QAction("MOL File...", self)
882
+ save_3d_mol_action.triggered.connect(self.save_3d_as_mol)
883
+ export_3d_menu.addAction(save_3d_mol_action)
884
+
885
+ save_xyz_action = QAction("XYZ File...", self)
886
+ save_xyz_action.triggered.connect(self.save_as_xyz)
887
+ export_3d_menu.addAction(save_xyz_action)
888
+
889
+ export_3d_png_action = QAction("PNG Image...", self)
890
+ export_3d_png_action.triggered.connect(self.export_3d_png)
891
+ export_3d_menu.addAction(export_3d_png_action)
892
+
893
+ export_3d_menu.addSeparator()
894
+
895
+ export_stl_action = QAction("STL File...", self)
896
+ export_stl_action.triggered.connect(self.export_stl)
897
+ export_3d_menu.addAction(export_stl_action)
898
+
899
+ export_obj_action = QAction("OBJ/MTL (with colors)...", self)
900
+ export_obj_action.triggered.connect(self.export_obj_mtl)
901
+ export_3d_menu.addAction(export_obj_action)
902
+
903
+ file_menu.addSeparator()
904
+ quit_action = QAction("Quit", self)
905
+ quit_action.triggered.connect(self.close)
906
+ file_menu.addAction(quit_action)
907
+
908
+ edit_menu = menu_bar.addMenu("&Edit")
909
+ self.undo_action = QAction("Undo", self); self.undo_action.setShortcut(QKeySequence.StandardKey.Undo)
910
+ self.undo_action.triggered.connect(self.undo); edit_menu.addAction(self.undo_action)
911
+
912
+ self.redo_action = QAction("Redo", self); self.redo_action.setShortcut(QKeySequence.StandardKey.Redo)
913
+ self.redo_action.triggered.connect(self.redo); edit_menu.addAction(self.redo_action)
914
+
915
+ edit_menu.addSeparator()
916
+
917
+ self.cut_action = QAction("Cut", self)
918
+ self.cut_action.setShortcut(QKeySequence.StandardKey.Cut)
919
+ self.cut_action.triggered.connect(self.cut_selection)
920
+ edit_menu.addAction(self.cut_action)
921
+
922
+ self.copy_action = QAction("Copy", self)
923
+ self.copy_action.setShortcut(QKeySequence.StandardKey.Copy)
924
+ self.copy_action.triggered.connect(self.copy_selection)
925
+ edit_menu.addAction(self.copy_action)
926
+
927
+ self.paste_action = QAction("Paste", self)
928
+ self.paste_action.setShortcut(QKeySequence.StandardKey.Paste)
929
+ self.paste_action.triggered.connect(self.paste_from_clipboard)
930
+ edit_menu.addAction(self.paste_action)
931
+
932
+ edit_menu.addSeparator()
933
+
934
+ add_hydrogen_action = QAction("Add Hydrogens", self)
935
+ add_hydrogen_action.setToolTip("Add explicit hydrogens based on RDKit implicit counts")
936
+ add_hydrogen_action.triggered.connect(self.add_hydrogen_atoms)
937
+ edit_menu.addAction(add_hydrogen_action)
938
+
939
+ remove_hydrogen_action = QAction("Remove Hydrogens", self)
940
+ remove_hydrogen_action.triggered.connect(self.remove_hydrogen_atoms)
941
+ edit_menu.addAction(remove_hydrogen_action)
942
+
943
+ edit_menu.addSeparator()
944
+
945
+ optimize_2d_action = QAction("Optimize 2D", self)
946
+ optimize_2d_action.setShortcut(QKeySequence("Ctrl+J"))
947
+ optimize_2d_action.triggered.connect(self.clean_up_2d_structure)
948
+ edit_menu.addAction(optimize_2d_action)
949
+
950
+ convert_3d_action = QAction("Convert 2D to 3D", self)
951
+ convert_3d_action.setShortcut(QKeySequence("Ctrl+K"))
952
+ convert_3d_action.triggered.connect(self.trigger_conversion)
953
+ edit_menu.addAction(convert_3d_action)
954
+
955
+ optimize_3d_action = QAction("Optimize 3D", self)
956
+ optimize_3d_action.setShortcut(QKeySequence("Ctrl+L"))
957
+ optimize_3d_action.triggered.connect(self.optimize_3d_structure)
958
+ edit_menu.addAction(optimize_3d_action)
959
+
960
+ # Note: 3D Optimization Settings moved to Settings -> "3D Optimization Settings"
961
+ # to avoid duplicating the same submenu in both Edit and Settings.
962
+
963
+ # Note: Open Babel-based optimization menu entries were intentionally
964
+ # removed above. Open Babel (pybel) is still available for conversion
965
+ # fallback elsewhere in the code, so we don't disable menu items here.
966
+
967
+ edit_menu.addSeparator()
968
+
969
+ select_all_action = QAction("Select All", self); select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
970
+ select_all_action.triggered.connect(self.select_all); edit_menu.addAction(select_all_action)
971
+
972
+ clear_all_action = QAction("Clear All", self)
973
+ clear_all_action.setShortcut(QKeySequence("Ctrl+Shift+C"))
974
+ clear_all_action.triggered.connect(self.clear_all); edit_menu.addAction(clear_all_action)
975
+
976
+ view_menu = menu_bar.addMenu("&View")
977
+
978
+ zoom_in_action = QAction("Zoom In", self)
979
+ zoom_in_action.setShortcut(QKeySequence.StandardKey.ZoomIn) # Ctrl +
980
+ zoom_in_action.triggered.connect(self.zoom_in)
981
+ view_menu.addAction(zoom_in_action)
982
+
983
+ zoom_out_action = QAction("Zoom Out", self)
984
+ zoom_out_action.setShortcut(QKeySequence.StandardKey.ZoomOut) # Ctrl -
985
+ zoom_out_action.triggered.connect(self.zoom_out)
986
+ view_menu.addAction(zoom_out_action)
987
+
988
+ reset_zoom_action = QAction("Reset Zoom", self)
989
+ reset_zoom_action.setShortcut(QKeySequence("Ctrl+0"))
990
+ reset_zoom_action.triggered.connect(self.reset_zoom)
991
+ view_menu.addAction(reset_zoom_action)
992
+
993
+ fit_action = QAction("Fit to View", self)
994
+ fit_action.setShortcut(QKeySequence("Ctrl+9"))
995
+ fit_action.triggered.connect(self.fit_to_view)
996
+ view_menu.addAction(fit_action)
997
+
998
+ view_menu.addSeparator()
999
+
1000
+ reset_3d_view_action = QAction("Reset 3D View", self)
1001
+ reset_3d_view_action.triggered.connect(lambda: self.plotter.reset_camera() if hasattr(self, 'plotter') else None)
1002
+ reset_3d_view_action.setShortcut(QKeySequence("Ctrl+R"))
1003
+ view_menu.addAction(reset_3d_view_action)
1004
+
1005
+ view_menu.addSeparator()
1006
+
1007
+ # Panel Layout submenu
1008
+ layout_menu = view_menu.addMenu("Panel Layout")
1009
+
1010
+ equal_panels_action = QAction("Equal Panels (50:50)", self)
1011
+ equal_panels_action.setShortcut(QKeySequence("Ctrl+1"))
1012
+ equal_panels_action.triggered.connect(lambda: self.set_panel_layout(50, 50))
1013
+ layout_menu.addAction(equal_panels_action)
1014
+
1015
+ layout_2d_focus_action = QAction("2D Focus (70:30)", self)
1016
+ layout_2d_focus_action.setShortcut(QKeySequence("Ctrl+2"))
1017
+ layout_2d_focus_action.triggered.connect(lambda: self.set_panel_layout(70, 30))
1018
+ layout_menu.addAction(layout_2d_focus_action)
1019
+
1020
+ layout_3d_focus_action = QAction("3D Focus (30:70)", self)
1021
+ layout_3d_focus_action.setShortcut(QKeySequence("Ctrl+3"))
1022
+ layout_3d_focus_action.triggered.connect(lambda: self.set_panel_layout(30, 70))
1023
+ layout_menu.addAction(layout_3d_focus_action)
1024
+
1025
+ layout_menu.addSeparator()
1026
+
1027
+ toggle_2d_panel_action = QAction("Toggle 2D Panel", self)
1028
+ toggle_2d_panel_action.setShortcut(QKeySequence("Ctrl+H"))
1029
+ toggle_2d_panel_action.triggered.connect(self.toggle_2d_panel)
1030
+ layout_menu.addAction(toggle_2d_panel_action)
1031
+
1032
+ view_menu.addSeparator()
1033
+
1034
+ self.toggle_chiral_action = QAction("Show Chiral Labels", self, checkable=True)
1035
+ self.toggle_chiral_action.setChecked(self.show_chiral_labels)
1036
+ self.toggle_chiral_action.triggered.connect(self.toggle_chiral_labels_display)
1037
+ view_menu.addAction(self.toggle_chiral_action)
1038
+
1039
+ view_menu.addSeparator()
1040
+
1041
+ # 3D Atom Info submenu
1042
+ atom_info_menu = view_menu.addMenu("3D Atom Info Display")
1043
+
1044
+ self.show_atom_id_action = QAction("Show Original ID / Index", self, checkable=True)
1045
+ self.show_atom_id_action.triggered.connect(lambda: self.toggle_atom_info_display('id'))
1046
+ atom_info_menu.addAction(self.show_atom_id_action)
1047
+
1048
+ self.show_rdkit_id_action = QAction("Show RDKit Index", self, checkable=True)
1049
+ self.show_rdkit_id_action.triggered.connect(lambda: self.toggle_atom_info_display('rdkit_id'))
1050
+ atom_info_menu.addAction(self.show_rdkit_id_action)
1051
+
1052
+ self.show_atom_coords_action = QAction("Show Coordinates (X,Y,Z)", self, checkable=True)
1053
+ self.show_atom_coords_action.triggered.connect(lambda: self.toggle_atom_info_display('coords'))
1054
+ atom_info_menu.addAction(self.show_atom_coords_action)
1055
+
1056
+ self.show_atom_symbol_action = QAction("Show Element Symbol", self, checkable=True)
1057
+ self.show_atom_symbol_action.triggered.connect(lambda: self.toggle_atom_info_display('symbol'))
1058
+ atom_info_menu.addAction(self.show_atom_symbol_action)
1059
+
1060
+ analysis_menu = menu_bar.addMenu("&Analysis")
1061
+ self.analysis_action = QAction("Show Analysis...", self)
1062
+ self.analysis_action.triggered.connect(self.open_analysis_window)
1063
+ self.analysis_action.setEnabled(False)
1064
+ analysis_menu.addAction(self.analysis_action)
1065
+
1066
+ # 3D Edit menu
1067
+ edit_3d_menu = menu_bar.addMenu("3D &Edit")
1068
+
1069
+ # Translation action
1070
+ translation_action = QAction("Translation...", self)
1071
+ translation_action.triggered.connect(self.open_translation_dialog)
1072
+ translation_action.setEnabled(False)
1073
+ edit_3d_menu.addAction(translation_action)
1074
+ self.translation_action = translation_action
1075
+
1076
+ # Move Group action
1077
+ move_group_action = QAction("Move Group...", self)
1078
+ move_group_action.triggered.connect(self.open_move_group_dialog)
1079
+ move_group_action.setEnabled(False)
1080
+ edit_3d_menu.addAction(move_group_action)
1081
+ self.move_group_action = move_group_action
1082
+
1083
+ edit_3d_menu.addSeparator()
1084
+
1085
+ # Alignment submenu (統合)
1086
+ align_menu = edit_3d_menu.addMenu("Align to")
1087
+ align_menu.setEnabled(False)
1088
+ self.align_menu = align_menu
1089
+
1090
+ # Axis alignment submenu
1091
+ axis_align_menu = align_menu.addMenu("Axis")
1092
+
1093
+ align_x_action = QAction("X-axis", self)
1094
+ align_x_action.triggered.connect(lambda: self.open_alignment_dialog('x'))
1095
+ align_x_action.setEnabled(False)
1096
+ axis_align_menu.addAction(align_x_action)
1097
+ self.align_x_action = align_x_action
1098
+
1099
+ align_y_action = QAction("Y-axis", self)
1100
+ align_y_action.triggered.connect(lambda: self.open_alignment_dialog('y'))
1101
+ align_y_action.setEnabled(False)
1102
+ axis_align_menu.addAction(align_y_action)
1103
+ self.align_y_action = align_y_action
1104
+
1105
+ align_z_action = QAction("Z-axis", self)
1106
+ align_z_action.triggered.connect(lambda: self.open_alignment_dialog('z'))
1107
+ align_z_action.setEnabled(False)
1108
+ axis_align_menu.addAction(align_z_action)
1109
+ self.align_z_action = align_z_action
1110
+
1111
+ # Plane alignment submenu (旧align)
1112
+ plane_align_menu = align_menu.addMenu("Plane")
1113
+
1114
+ alignplane_xy_action = QAction("XY-plane", self)
1115
+ alignplane_xy_action.triggered.connect(lambda: self.open_align_plane_dialog('xy'))
1116
+ alignplane_xy_action.setEnabled(False)
1117
+ plane_align_menu.addAction(alignplane_xy_action)
1118
+ self.alignplane_xy_action = alignplane_xy_action
1119
+
1120
+ alignplane_xz_action = QAction("XZ-plane", self)
1121
+ alignplane_xz_action.triggered.connect(lambda: self.open_align_plane_dialog('xz'))
1122
+ alignplane_xz_action.setEnabled(False)
1123
+ plane_align_menu.addAction(alignplane_xz_action)
1124
+ self.alignplane_xz_action = alignplane_xz_action
1125
+
1126
+ alignplane_yz_action = QAction("YZ-plane", self)
1127
+ alignplane_yz_action.triggered.connect(lambda: self.open_align_plane_dialog('yz'))
1128
+ alignplane_yz_action.setEnabled(False)
1129
+ plane_align_menu.addAction(alignplane_yz_action)
1130
+ self.alignplane_yz_action = alignplane_yz_action
1131
+
1132
+ edit_3d_menu.addSeparator()
1133
+
1134
+ # Mirror action
1135
+ mirror_action = QAction("Mirror...", self)
1136
+ mirror_action.triggered.connect(self.open_mirror_dialog)
1137
+ mirror_action.setEnabled(False)
1138
+ edit_3d_menu.addAction(mirror_action)
1139
+ self.mirror_action = mirror_action
1140
+
1141
+ edit_3d_menu.addSeparator()
1142
+
1143
+ # Planarize selection (best-fit plane)
1144
+ planarize_action = QAction("Planarize...", self)
1145
+ planarize_action.triggered.connect(lambda: self.open_planarize_dialog(None))
1146
+ planarize_action.setEnabled(False)
1147
+ edit_3d_menu.addAction(planarize_action)
1148
+ self.planarize_action = planarize_action
1149
+
1150
+ edit_3d_menu.addSeparator()
1151
+
1152
+ # Bond length conversion
1153
+ bond_length_action = QAction("Adjust Bond Length...", self)
1154
+ bond_length_action.triggered.connect(self.open_bond_length_dialog)
1155
+ bond_length_action.setEnabled(False)
1156
+ edit_3d_menu.addAction(bond_length_action)
1157
+ self.bond_length_action = bond_length_action
1158
+
1159
+ # Angle conversion
1160
+ angle_action = QAction("Adjust Angle...", self)
1161
+ angle_action.triggered.connect(self.open_angle_dialog)
1162
+ angle_action.setEnabled(False)
1163
+ edit_3d_menu.addAction(angle_action)
1164
+ self.angle_action = angle_action
1165
+
1166
+ # Dihedral angle conversion
1167
+ dihedral_action = QAction("Adjust Dihedral Angle...", self)
1168
+ dihedral_action.triggered.connect(self.open_dihedral_dialog)
1169
+ dihedral_action.setEnabled(False)
1170
+ edit_3d_menu.addAction(dihedral_action)
1171
+ self.dihedral_action = dihedral_action
1172
+
1173
+ edit_3d_menu.addSeparator()
1174
+
1175
+ # Constrained Optimization action
1176
+ constrained_opt_action = QAction("Constrained Optimization...", self)
1177
+ constrained_opt_action.triggered.connect(self.open_constrained_optimization_dialog)
1178
+ constrained_opt_action.setEnabled(False) # 3Dモデルロード時に有効化
1179
+ edit_3d_menu.addAction(constrained_opt_action)
1180
+ self.constrained_opt_action = constrained_opt_action
1181
+
1182
+ settings_menu = menu_bar.addMenu("&Settings")
1183
+ # 1) 3D View settings (existing)
1184
+ view_settings_action = QAction("3D View Settings...", self)
1185
+ view_settings_action.triggered.connect(self.open_settings_dialog)
1186
+ settings_menu.addAction(view_settings_action)
1187
+
1188
+ # Color settings (CPK/Bond) — keep with other settings
1189
+ color_action = QAction("CPK Colors...", self)
1190
+ color_action.triggered.connect(lambda: ColorSettingsDialog(self.settings, parent=self).exec_())
1191
+ settings_menu.addAction(color_action)
1192
+
1193
+ # 2) 3D Conversion settings — submenu with radio/check actions
1194
+ conversion_menu = settings_menu.addMenu("3D Conversion")
1195
+ conv_group = QActionGroup(self)
1196
+ conv_group.setExclusive(True)
1197
+ # helper to set conversion mode and persist
1198
+ def _set_conv_mode(mode):
1199
+ try:
1200
+ self.settings['3d_conversion_mode'] = mode
1201
+ # defer disk write
1202
+ try:
1203
+ self.settings_dirty = True
1204
+ except Exception:
1205
+ pass
1206
+ self.statusBar().showMessage(f"3D conversion mode set to: {mode}")
1207
+ except Exception:
1208
+ pass
1209
+
1210
+ conv_options = [
1211
+ ("RDKit -> Open Babel (fallback)", 'fallback'),
1212
+ ("RDKit only", 'rdkit'),
1213
+ ("Open Babel only", 'obabel'),
1214
+ ("Direct (use 2D coords + add H)", 'direct')
1215
+ ]
1216
+ self.conv_actions = {}
1217
+ for label, key in conv_options:
1218
+ a = QAction(label, self)
1219
+ a.setCheckable(True)
1220
+ # If Open Babel isn't available, disable the Open Babel-only option
1221
+ # and also disable the fallback option since it depends on Open Babel.
1222
+ if not OBABEL_AVAILABLE:
1223
+ if key == 'obabel' or key == 'fallback':
1224
+ a.setEnabled(False)
1225
+ a.triggered.connect(lambda checked, m=key: _set_conv_mode(m))
1226
+ conversion_menu.addAction(a)
1227
+ conv_group.addAction(a)
1228
+ self.conv_actions[key] = a
1229
+
1230
+ # Initialize checked state from settings (fallback default)
1231
+ # Determine saved conversion mode. If Open Babel is not available,
1232
+ # prefer 'rdkit' as the default rather than 'fallback'. Also ensure
1233
+ # the settings reflect the actual enabled choice.
1234
+ try:
1235
+ default_mode = 'rdkit' if not OBABEL_AVAILABLE else 'fallback'
1236
+ saved_conv = self.settings.get('3d_conversion_mode', default_mode)
1237
+ except Exception:
1238
+ saved_conv = 'rdkit' if not OBABEL_AVAILABLE else 'fallback'
1239
+
1240
+ # If the saved mode is disabled/unavailable, fall back to an enabled option.
1241
+ if saved_conv not in self.conv_actions or not self.conv_actions[saved_conv].isEnabled():
1242
+ # Prefer 'rdkit' if available, else pick whichever action is enabled
1243
+ preferred = 'rdkit' if 'rdkit' in self.conv_actions and self.conv_actions['rdkit'].isEnabled() else None
1244
+ if not preferred:
1245
+ for k, act in self.conv_actions.items():
1246
+ if act.isEnabled():
1247
+ preferred = k
1248
+ break
1249
+ saved_conv = preferred or 'rdkit'
1250
+
1251
+ # Set the checked state and persist the chosen conversion mode
1252
+ try:
1253
+ if saved_conv in self.conv_actions:
1254
+ try:
1255
+ self.conv_actions[saved_conv].setChecked(True)
1256
+ except Exception:
1257
+ pass
1258
+ self.settings['3d_conversion_mode'] = saved_conv
1259
+ try:
1260
+ self.settings_dirty = True
1261
+ except Exception:
1262
+ pass
1263
+ except Exception:
1264
+ pass
1265
+
1266
+ # 3) 3D Optimization Settings (single location under Settings menu)
1267
+ optimization_menu = settings_menu.addMenu("3D Optimization Settings")
1268
+
1269
+ # Only RDKit-backed optimization methods are offered here.
1270
+ opt_methods = [
1271
+ ("MMFF94s", "MMFF_RDKIT"),
1272
+ ("MMFF94", "MMFF94_RDKIT"),
1273
+ ("UFF", "UFF_RDKIT"),
1274
+ ]
1275
+
1276
+ # Map key -> human-readable label for status messages and later lookups
1277
+ try:
1278
+ self.opt3d_method_labels = {key.upper(): label for (label, key) in opt_methods}
1279
+ except Exception:
1280
+ self.opt3d_method_labels = {}
1281
+
1282
+ opt_group = QActionGroup(self)
1283
+ opt_group.setExclusive(True)
1284
+ opt_actions = {}
1285
+ for label, key in opt_methods:
1286
+ action = QAction(label, self)
1287
+ action.setCheckable(True)
1288
+ try:
1289
+ action.setActionGroup(opt_group)
1290
+ except Exception:
1291
+ pass
1292
+ action.triggered.connect(lambda checked, m=key: self.set_optimization_method(m))
1293
+ optimization_menu.addAction(action)
1294
+ opt_group.addAction(action)
1295
+ opt_actions[key] = action
1296
+
1297
+ # Persist the actions mapping so other methods can update the checked state
1298
+ self.opt3d_actions = opt_actions
1299
+
1300
+ # Determine the initial checked menu item from saved settings (fall back to MMFF_RDKIT)
1301
+ try:
1302
+ saved_opt = (self.settings.get('optimization_method') or self.optimization_method or 'MMFF_RDKIT').upper()
1303
+ except Exception:
1304
+ saved_opt = 'MMFF_RDKIT'
1305
+
1306
+ try:
1307
+ if saved_opt in self.opt3d_actions and self.opt3d_actions[saved_opt].isEnabled():
1308
+ self.opt3d_actions[saved_opt].setChecked(True)
1309
+ self.optimization_method = saved_opt
1310
+ else:
1311
+ if 'MMFF_RDKIT' in self.opt3d_actions:
1312
+ self.opt3d_actions['MMFF_RDKIT'].setChecked(True)
1313
+ self.optimization_method = 'MMFF_RDKIT'
1314
+ except Exception:
1315
+ pass
1316
+
1317
+ # 4) Reset all settings to defaults
1318
+ settings_menu.addSeparator()
1319
+ reset_settings_action = QAction("Reset All Settings", self)
1320
+ reset_settings_action.triggered.connect(self.reset_all_settings_menu)
1321
+ settings_menu.addAction(reset_settings_action)
1322
+
1323
+ help_menu = menu_bar.addMenu("&Help")
1324
+ about_action = QAction("About", self)
1325
+ about_action.triggered.connect(self.show_about_dialog)
1326
+ help_menu.addAction(about_action)
1327
+
1328
+ github_action = QAction("GitHub", self)
1329
+ github_action.triggered.connect(
1330
+ lambda: QDesktopServices.openUrl(QUrl("https://github.com/HiroYokoyama/python_molecular_editor"))
1331
+ )
1332
+ help_menu.addAction(github_action)
1333
+
1334
+ github_wiki_action = QAction("GitHub Wiki", self)
1335
+ github_wiki_action.triggered.connect(
1336
+ lambda: QDesktopServices.openUrl(QUrl("https://github.com/HiroYokoyama/python_molecular_editor/wiki"))
1337
+ )
1338
+ help_menu.addAction(github_wiki_action)
1339
+
1340
+ manual_action = QAction("User Manual", self)
1341
+ manual_action.triggered.connect(
1342
+ lambda: QDesktopServices.openUrl(QUrl("https://hiroyokoyama.github.io/python_molecular_editor/manual/manual"))
1343
+ )
1344
+ help_menu.addAction(manual_action)
1345
+
1346
+ # 3D関連機能の初期状態を統一的に設定
1347
+ self._enable_3d_features(False)
1348
+
1349
+
1350
+
1351
+ def init_worker_thread(self):
1352
+ # Initialize shared state for calculation runs.
1353
+ # NOTE: we no longer create a persistent worker/thread here. Instead,
1354
+ # each conversion run will create its own CalculationWorker + QThread
1355
+ # so multiple conversions may run in parallel.
1356
+ # Shared halt id set used to request early termination of specific worker runs
1357
+ self.halt_ids = set()
1358
+ # IDs used to correlate start/halt/finish
1359
+ self.next_conversion_id = 1
1360
+ # Track currently-active conversion worker IDs so Halt can target all
1361
+ # running conversions. Use a set because multiple conversions may run
1362
+ # concurrently.
1363
+ self.active_worker_ids = set()
1364
+ # Track active threads for diagnostics/cleanup (weak references ok)
1365
+ try:
1366
+ self._active_calc_threads = []
1367
+ except Exception:
1368
+ self._active_calc_threads = []
1369
+
1370
+
1371
+
1372
+
1373
+ def load_command_line_file(self, file_path):
1374
+ """コマンドライン引数で指定されたファイルを開く"""
1375
+ if not file_path or not os.path.exists(file_path):
1376
+ return
1377
+
1378
+ file_ext = file_path.lower().split('.')[-1]
1379
+
1380
+ if file_ext in ['mol', 'sdf']:
1381
+ self.load_mol_file_for_3d_viewing(file_path)
1382
+ elif file_ext == 'xyz':
1383
+ self.load_xyz_for_3d_viewing(file_path)
1384
+ elif file_ext in ['pmeraw', 'pmeprj']:
1385
+ self.open_project_file(file_path=file_path)
1386
+ else:
1387
+ self.statusBar().showMessage(f"Unsupported file type: {file_ext}")
1388
+
1389
+
1390
+
1391
+ def apply_initial_settings(self):
1392
+ """UIの初期化が完了した後に、保存された設定を3Dビューに適用する"""
1393
+
1394
+ try:
1395
+ self.update_cpk_colors_from_settings()
1396
+ except Exception:
1397
+ pass
1398
+
1399
+ if self.plotter and self.plotter.renderer:
1400
+ bg_color = self.settings.get('background_color', '#919191')
1401
+ self.plotter.set_background(bg_color)
1402
+ self.apply_3d_settings()
1403
+
1404
+ try:
1405
+ if hasattr(self, 'scene') and self.scene:
1406
+ for it in list(self.scene.items()):
1407
+ if hasattr(it, 'update_style'):
1408
+ it.update_style()
1409
+ self.scene.update()
1410
+ for v in list(self.scene.views()):
1411
+ v.viewport().update()
1412
+ except Exception:
1413
+ pass
1414
+
1415
+
1416
+
1417
+ def open_settings_dialog(self):
1418
+ dialog = SettingsDialog(self.settings, self)
1419
+ # accept()メソッドで設定の適用と3Dビューの更新を行うため、ここでは不要
1420
+ dialog.exec()
1421
+
1422
+
1423
+
1424
+
1425
+ def reset_all_settings_menu(self):
1426
+ # Expose the same functionality as SettingsDialog.reset_all_settings
1427
+ dlg = QMessageBox(self)
1428
+ dlg.setIcon(QMessageBox.Icon.Warning)
1429
+ dlg.setWindowTitle("Reset All Settings")
1430
+ dlg.setText("Are you sure you want to reset all settings to defaults?")
1431
+ dlg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1432
+ res = dlg.exec()
1433
+ if res == QMessageBox.StandardButton.Yes:
1434
+ try:
1435
+ # Remove settings file and reload defaults
1436
+ if os.path.exists(self.settings_file):
1437
+ os.remove(self.settings_file)
1438
+ self.load_settings()
1439
+ # Do not write to disk immediately; mark dirty so settings will be saved on exit
1440
+ try:
1441
+ self.settings_dirty = True
1442
+ except Exception:
1443
+ pass
1444
+ # If ColorSettingsDialog is open, refresh its UI to reflect the reset
1445
+ try:
1446
+ for w in QApplication.topLevelWidgets():
1447
+ try:
1448
+ if isinstance(w, ColorSettingsDialog):
1449
+ try:
1450
+ w.refresh_ui()
1451
+ except Exception:
1452
+ pass
1453
+ except Exception:
1454
+ pass
1455
+ except Exception:
1456
+ pass
1457
+ # Ensure global CPK mapping is rebuilt from defaults and UI is updated
1458
+ try:
1459
+ self.update_cpk_colors_from_settings()
1460
+ except Exception:
1461
+ pass
1462
+ # Refresh UI/menu state for conversion and optimization
1463
+ try:
1464
+ # update optimization method
1465
+ self.optimization_method = self.settings.get('optimization_method', 'MMFF_RDKIT')
1466
+ if hasattr(self, 'opt3d_actions') and self.optimization_method:
1467
+ key = (self.optimization_method or '').upper()
1468
+ if key in self.opt3d_actions:
1469
+ # uncheck all then check the saved one
1470
+ for act in self.opt3d_actions.values():
1471
+ act.setChecked(False)
1472
+ try:
1473
+ self.opt3d_actions[key].setChecked(True)
1474
+ except Exception:
1475
+ pass
1476
+
1477
+ # update conversion mode
1478
+ conv_mode = self.settings.get('3d_conversion_mode', 'fallback')
1479
+ if hasattr(self, 'conv_actions') and conv_mode in self.conv_actions:
1480
+ try:
1481
+ for act in self.conv_actions.values():
1482
+ act.setChecked(False)
1483
+ self.conv_actions[conv_mode].setChecked(True)
1484
+ except Exception:
1485
+ pass
1486
+
1487
+ # 3Dビューの設定を適用
1488
+ self.apply_3d_settings()
1489
+ # 現在の分子を再描画(設定変更を反映)
1490
+ if hasattr(self, 'current_mol') and self.current_mol:
1491
+ self.draw_molecule_3d(self.current_mol)
1492
+
1493
+ QMessageBox.information(self, "Reset Complete", "All settings have been reset to defaults.")
1494
+
1495
+ except Exception:
1496
+ pass
1497
+ # Update 2D scene styling to reflect default CPK colors
1498
+ try:
1499
+ if hasattr(self, 'scene') and self.scene:
1500
+ for it in list(self.scene.items()):
1501
+ try:
1502
+ if hasattr(it, 'update_style'):
1503
+ it.update_style()
1504
+ except Exception:
1505
+ pass
1506
+ try:
1507
+ # Force a full scene update and viewport repaint for all views
1508
+ self.scene.update()
1509
+ for v in list(self.scene.views()):
1510
+ try:
1511
+ v.viewport().update()
1512
+ except Exception:
1513
+ pass
1514
+ except Exception:
1515
+ pass
1516
+ except Exception:
1517
+ pass
1518
+ # Also refresh any open SettingsDialog instances so their UI matches
1519
+ try:
1520
+ for w in QApplication.topLevelWidgets():
1521
+ try:
1522
+ if isinstance(w, SettingsDialog):
1523
+ try:
1524
+ w.update_ui_from_settings(self.settings)
1525
+ except Exception:
1526
+ pass
1527
+ except Exception:
1528
+ pass
1529
+ except Exception:
1530
+ pass
1531
+ except Exception as e:
1532
+ QMessageBox.warning(self, "Reset Failed", f"Could not reset settings: {e}")
1533
+
1534
+
1535
+
1536
+
1537
+ def load_settings(self):
1538
+ default_settings = {
1539
+ 'background_color': '#919191',
1540
+ 'projection_mode': 'Perspective',
1541
+ 'lighting_enabled': True,
1542
+ 'specular': 0.2,
1543
+ 'specular_power': 20,
1544
+ 'light_intensity': 1.0,
1545
+ 'show_3d_axes': True,
1546
+ # Ball and Stick model parameters
1547
+ 'ball_stick_atom_scale': 1.0,
1548
+ 'ball_stick_bond_radius': 0.1,
1549
+ 'ball_stick_resolution': 16,
1550
+ # CPK (Space-filling) model parameters
1551
+ 'cpk_atom_scale': 1.0,
1552
+ 'cpk_resolution': 32,
1553
+ # Wireframe model parameters
1554
+ 'wireframe_bond_radius': 0.01,
1555
+ 'wireframe_resolution': 6,
1556
+ # Stick model parameters
1557
+ 'stick_atom_radius': 0.15,
1558
+ 'stick_bond_radius': 0.15,
1559
+ 'stick_resolution': 16,
1560
+ # Multiple bond offset parameters (per-model)
1561
+ 'ball_stick_double_bond_offset_factor': 2.0,
1562
+ 'ball_stick_triple_bond_offset_factor': 2.0,
1563
+ 'ball_stick_double_bond_radius_factor': 0.8,
1564
+ 'ball_stick_triple_bond_radius_factor': 0.75,
1565
+ 'wireframe_double_bond_offset_factor': 3.0,
1566
+ 'wireframe_triple_bond_offset_factor': 3.0,
1567
+ 'wireframe_double_bond_radius_factor': 1.0,
1568
+ 'wireframe_triple_bond_radius_factor': 0.75,
1569
+ 'stick_double_bond_offset_factor': 1.5,
1570
+ 'stick_triple_bond_offset_factor': 1.0,
1571
+ 'stick_double_bond_radius_factor': 0.60,
1572
+ 'stick_triple_bond_radius_factor': 0.40,
1573
+ # Ensure conversion/optimization defaults are present
1574
+ # If True, attempts to be permissive when RDKit raises chemical/sanitization errors
1575
+ # during file import (useful for viewing malformed XYZ/MOL files).
1576
+ 'skip_chemistry_checks': False,
1577
+ '3d_conversion_mode': 'fallback',
1578
+ 'optimization_method': 'MMFF_RDKIT',
1579
+ # Color overrides
1580
+ 'ball_stick_bond_color': '#7F7F7F',
1581
+ 'cpk_colors': {}, # symbol->hex overrides
1582
+ # Whether to kekulize aromatic systems for 3D display
1583
+ 'display_kekule_3d': False,
1584
+ 'always_ask_charge': False,
1585
+ }
1586
+
1587
+ try:
1588
+ if os.path.exists(self.settings_file):
1589
+ with open(self.settings_file, 'r') as f:
1590
+ loaded_settings = json.load(f)
1591
+ # Ensure any missing default keys are inserted and persisted.
1592
+ changed = False
1593
+ for key, value in default_settings.items():
1594
+ if key not in loaded_settings:
1595
+ loaded_settings[key] = value
1596
+ changed = True
1597
+
1598
+ self.settings = loaded_settings
1599
+
1600
+ # Migration: if older global multi-bond keys exist, copy them to per-model keys
1601
+ legacy_keys = ['double_bond_offset_factor', 'triple_bond_offset_factor', 'double_bond_radius_factor', 'triple_bond_radius_factor']
1602
+ migrated = False
1603
+ # If legacy keys exist, propagate to per-model keys when per-model keys missing
1604
+ if any(k in self.settings for k in legacy_keys):
1605
+ # For each per-model key, if missing, set from legacy fallback
1606
+ def copy_if_missing(new_key, legacy_key, default_val):
1607
+ nonlocal migrated
1608
+ if new_key not in self.settings:
1609
+ if legacy_key in self.settings:
1610
+ self.settings[new_key] = self.settings[legacy_key]
1611
+ migrated = True
1612
+ else:
1613
+ self.settings[new_key] = default_val
1614
+ migrated = True
1615
+
1616
+ per_model_map = [
1617
+ ('ball_stick_double_bond_offset_factor', 'double_bond_offset_factor', 2.0),
1618
+ ('ball_stick_triple_bond_offset_factor', 'triple_bond_offset_factor', 2.0),
1619
+ ('ball_stick_double_bond_radius_factor', 'double_bond_radius_factor', 0.8),
1620
+ ('ball_stick_triple_bond_radius_factor', 'triple_bond_radius_factor', 0.75),
1621
+ ('wireframe_double_bond_offset_factor', 'double_bond_offset_factor', 3.0),
1622
+ ('wireframe_triple_bond_offset_factor', 'triple_bond_offset_factor', 3.0),
1623
+ ('wireframe_double_bond_radius_factor', 'double_bond_radius_factor', 1.0),
1624
+ ('wireframe_triple_bond_radius_factor', 'triple_bond_radius_factor', 0.75),
1625
+ ('stick_double_bond_offset_factor', 'double_bond_offset_factor', 1.5),
1626
+ ('stick_triple_bond_offset_factor', 'triple_bond_offset_factor', 1.0),
1627
+ ('stick_double_bond_radius_factor', 'double_bond_radius_factor', 0.60),
1628
+ ('stick_triple_bond_radius_factor', 'triple_bond_radius_factor', 0.40),
1629
+ ]
1630
+ for new_k, legacy_k, default_v in per_model_map:
1631
+ copy_if_missing(new_k, legacy_k, default_v)
1632
+
1633
+ # Optionally remove legacy keys to avoid confusion (keep them for now but mark dirty)
1634
+ if migrated:
1635
+ changed = True
1636
+
1637
+ # If we added any defaults (e.g. skip_chemistry_checks) or migrated keys, write them back so
1638
+ # the configuration file reflects the effective defaults without requiring
1639
+ # the user to edit the file manually.
1640
+ if changed:
1641
+ # Don't write immediately; mark dirty and let closeEvent persist
1642
+ try:
1643
+ self.settings_dirty = True
1644
+ except Exception:
1645
+ pass
1646
+
1647
+ else:
1648
+ # No settings file - use defaults. Mark dirty so defaults will be written on exit.
1649
+ self.settings = default_settings
1650
+ try:
1651
+ self.settings_dirty = True
1652
+ except Exception:
1653
+ pass
1654
+
1655
+ except Exception:
1656
+ self.settings = default_settings
1657
+
1658
+
1659
+
1660
+ def save_settings(self):
1661
+ try:
1662
+ if not os.path.exists(self.settings_dir):
1663
+ os.makedirs(self.settings_dir)
1664
+ with open(self.settings_file, 'w') as f:
1665
+ json.dump(self.settings, f, indent=4)
1666
+ except Exception as e:
1667
+ print(f"Error saving settings: {e}")
1668
+