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.
- moleditpy/__init__.py +4 -0
- moleditpy/__main__.py +29 -19748
- moleditpy/main.py +37 -0
- moleditpy/modules/__init__.py +36 -0
- moleditpy/modules/about_dialog.py +92 -0
- moleditpy/modules/align_plane_dialog.py +281 -0
- moleditpy/modules/alignment_dialog.py +261 -0
- moleditpy/modules/analysis_window.py +197 -0
- moleditpy/modules/angle_dialog.py +428 -0
- moleditpy/modules/assets/icon.icns +0 -0
- moleditpy/modules/atom_item.py +336 -0
- moleditpy/modules/bond_item.py +303 -0
- moleditpy/modules/bond_length_dialog.py +368 -0
- moleditpy/modules/calculation_worker.py +754 -0
- moleditpy/modules/color_settings_dialog.py +309 -0
- moleditpy/modules/constants.py +76 -0
- moleditpy/modules/constrained_optimization_dialog.py +667 -0
- moleditpy/modules/custom_interactor_style.py +737 -0
- moleditpy/modules/custom_qt_interactor.py +49 -0
- moleditpy/modules/dialog3_d_picking_mixin.py +96 -0
- moleditpy/modules/dihedral_dialog.py +431 -0
- moleditpy/modules/main_window.py +830 -0
- moleditpy/modules/main_window_app_state.py +747 -0
- moleditpy/modules/main_window_compute.py +1203 -0
- moleditpy/modules/main_window_dialog_manager.py +454 -0
- moleditpy/modules/main_window_edit_3d.py +531 -0
- moleditpy/modules/main_window_edit_actions.py +1449 -0
- moleditpy/modules/main_window_export.py +744 -0
- moleditpy/modules/main_window_main_init.py +1641 -0
- moleditpy/modules/main_window_molecular_parsers.py +956 -0
- moleditpy/modules/main_window_project_io.py +429 -0
- moleditpy/modules/main_window_string_importers.py +270 -0
- moleditpy/modules/main_window_ui_manager.py +567 -0
- moleditpy/modules/main_window_view_3d.py +1163 -0
- moleditpy/modules/main_window_view_loaders.py +350 -0
- moleditpy/modules/mirror_dialog.py +110 -0
- moleditpy/modules/molecular_data.py +290 -0
- moleditpy/modules/molecule_scene.py +1895 -0
- moleditpy/modules/move_group_dialog.py +586 -0
- moleditpy/modules/periodic_table_dialog.py +72 -0
- moleditpy/modules/planarize_dialog.py +209 -0
- moleditpy/modules/settings_dialog.py +1034 -0
- moleditpy/modules/template_preview_item.py +148 -0
- moleditpy/modules/template_preview_view.py +62 -0
- moleditpy/modules/translation_dialog.py +353 -0
- moleditpy/modules/user_template_dialog.py +621 -0
- moleditpy/modules/zoomable_view.py +98 -0
- {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/METADATA +1 -1
- moleditpy-1.16.0.dist-info/RECORD +54 -0
- moleditpy-1.15.1.dist-info/RECORD +0 -9
- /moleditpy/{assets → modules/assets}/icon.ico +0 -0
- /moleditpy/{assets → modules/assets}/icon.png +0 -0
- {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/WHEEL +0 -0
- {moleditpy-1.15.1.dist-info → moleditpy-1.16.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
|