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