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