MoleditPy-linux 2.4.1__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_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1539 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
MoleditPy — A Python-based molecular editing software
|
|
6
|
+
|
|
7
|
+
Author: Hiromichi Yokoyama
|
|
8
|
+
License: GPL-3.0 license
|
|
9
|
+
Repo: https://github.com/HiroYokoyama/python_molecular_editor
|
|
10
|
+
DOI: 10.5281/zenodo.17268532
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
main_window_view_3d.py
|
|
15
|
+
MainWindow (main_window.py) から分離されたモジュール
|
|
16
|
+
機能クラス: MainWindowView3d
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import vtk
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# RDKit imports (explicit to satisfy flake8 and used features)
|
|
26
|
+
from rdkit import Chem
|
|
27
|
+
try:
|
|
28
|
+
pass
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
# PyQt6 Modules
|
|
33
|
+
from PyQt6.QtWidgets import (
|
|
34
|
+
QApplication, QGraphicsView
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from PyQt6.QtGui import (
|
|
38
|
+
QColor, QTransform
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
from PyQt6.QtCore import (
|
|
43
|
+
Qt, QRectF
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
import pyvista as pv
|
|
47
|
+
|
|
48
|
+
# Use centralized Open Babel availability from package-level __init__
|
|
49
|
+
# Use per-package modules availability (local __init__).
|
|
50
|
+
try:
|
|
51
|
+
from . import OBABEL_AVAILABLE
|
|
52
|
+
except Exception:
|
|
53
|
+
from modules import OBABEL_AVAILABLE
|
|
54
|
+
# Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
|
|
55
|
+
if OBABEL_AVAILABLE:
|
|
56
|
+
try:
|
|
57
|
+
from openbabel import pybel
|
|
58
|
+
except Exception:
|
|
59
|
+
# If import fails here, disable OBABEL locally; avoid raising
|
|
60
|
+
pybel = None
|
|
61
|
+
OBABEL_AVAILABLE = False
|
|
62
|
+
logging.warning("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
|
|
63
|
+
else:
|
|
64
|
+
pybel = None
|
|
65
|
+
|
|
66
|
+
# Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
|
|
67
|
+
# allows safely detecting C++ wrapper objects that have been deleted. Import
|
|
68
|
+
# it once at module import time and expose a small, robust wrapper so callers
|
|
69
|
+
# can avoid re-importing sip repeatedly and so we centralize exception
|
|
70
|
+
# handling (this reduces crash risk during teardown and deletion operations).
|
|
71
|
+
try:
|
|
72
|
+
import sip as _sip # type: ignore
|
|
73
|
+
_sip_isdeleted = getattr(_sip, 'isdeleted', None)
|
|
74
|
+
except Exception:
|
|
75
|
+
_sip = None
|
|
76
|
+
_sip_isdeleted = None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
# package relative imports (preferred when running as `python -m moleditpy`)
|
|
80
|
+
from .constants import CPK_COLORS_PV, DEFAULT_CPK_COLORS, VDW_RADII, pt
|
|
81
|
+
from .template_preview_item import TemplatePreviewItem
|
|
82
|
+
except Exception:
|
|
83
|
+
# Fallback to absolute imports for script-style execution
|
|
84
|
+
from modules.constants import CPK_COLORS_PV, DEFAULT_CPK_COLORS, VDW_RADII, pt
|
|
85
|
+
from modules.template_preview_item import TemplatePreviewItem
|
|
86
|
+
|
|
87
|
+
# --- クラス定義 ---
|
|
88
|
+
class MainWindowView3d(object):
|
|
89
|
+
""" main_window.py から分離された機能クラス """
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_3d_style(self, style_name):
|
|
93
|
+
"""3D表示スタイルを設定し、ビューを更新する"""
|
|
94
|
+
if self.current_3d_style == style_name:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# 描画モード変更時に測定モードと3D編集モードをリセット
|
|
98
|
+
if self.measurement_mode:
|
|
99
|
+
self.measurement_action.setChecked(False)
|
|
100
|
+
self.toggle_measurement_mode(False) # 測定モードを無効化
|
|
101
|
+
|
|
102
|
+
if self.is_3d_edit_mode:
|
|
103
|
+
self.edit_3d_action.setChecked(False)
|
|
104
|
+
self.toggle_3d_edit_mode(False) # 3D編集モードを無効化
|
|
105
|
+
|
|
106
|
+
# 3D原子選択をクリア
|
|
107
|
+
self.clear_3d_selection()
|
|
108
|
+
|
|
109
|
+
self.current_3d_style = style_name
|
|
110
|
+
self.statusBar().showMessage(f"3D style set to: {style_name}")
|
|
111
|
+
|
|
112
|
+
# 現在表示中の分子があれば、新しいスタイルで再描画する
|
|
113
|
+
if self.current_mol:
|
|
114
|
+
self.draw_molecule_3d(self.current_mol)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def draw_molecule_3d(self, mol):
|
|
119
|
+
"""Dispatch to custom style or standard drawing."""
|
|
120
|
+
mw = self
|
|
121
|
+
|
|
122
|
+
if hasattr(mw, 'plugin_manager') and hasattr(mw.plugin_manager, 'custom_3d_styles'):
|
|
123
|
+
if hasattr(self, 'current_3d_style') and self.current_3d_style in mw.plugin_manager.custom_3d_styles:
|
|
124
|
+
handler = mw.plugin_manager.custom_3d_styles[self.current_3d_style]['callback']
|
|
125
|
+
try:
|
|
126
|
+
handler(mw, mol)
|
|
127
|
+
return
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logging.error(f"Error in custom 3d style '{self.current_3d_style}': {e}")
|
|
130
|
+
|
|
131
|
+
self.draw_standard_3d_style(mol)
|
|
132
|
+
|
|
133
|
+
def draw_standard_3d_style(self, mol, style_override=None):
|
|
134
|
+
"""3D 分子を描画し、軸アクターの参照をクリアする(軸の再制御は apply_3d_settings に任せる)"""
|
|
135
|
+
|
|
136
|
+
current_style = style_override if style_override else self.current_3d_style
|
|
137
|
+
|
|
138
|
+
# 測定選択をクリア(分子が変更されたため)
|
|
139
|
+
if hasattr(self, 'measurement_mode'):
|
|
140
|
+
self.clear_measurement_selection()
|
|
141
|
+
|
|
142
|
+
# 色情報追跡のための辞書を初期化
|
|
143
|
+
if not hasattr(self, '_3d_color_map'):
|
|
144
|
+
self._3d_color_map = {}
|
|
145
|
+
self._3d_color_map.clear()
|
|
146
|
+
|
|
147
|
+
# 1. カメラ状態とクリア
|
|
148
|
+
camera_state = self.plotter.camera.copy()
|
|
149
|
+
|
|
150
|
+
# **残留防止のための強制削除**
|
|
151
|
+
if self.axes_actor is not None:
|
|
152
|
+
try:
|
|
153
|
+
self.plotter.remove_actor(self.axes_actor)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
self.axes_actor = None
|
|
157
|
+
|
|
158
|
+
self.plotter.clear()
|
|
159
|
+
|
|
160
|
+
# 2. 背景色の設定
|
|
161
|
+
self.plotter.set_background(self.settings.get('background_color', '#4f4f4f'))
|
|
162
|
+
|
|
163
|
+
# 3. mol が None または原子数ゼロの場合は、背景と軸のみで終了
|
|
164
|
+
if mol is None or mol.GetNumAtoms() == 0:
|
|
165
|
+
self.atom_actor = None
|
|
166
|
+
self.current_mol = None
|
|
167
|
+
self.plotter.render()
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# 4. ライティングの設定
|
|
171
|
+
is_lighting_enabled = self.settings.get('lighting_enabled', True)
|
|
172
|
+
|
|
173
|
+
if is_lighting_enabled:
|
|
174
|
+
light = pv.Light(
|
|
175
|
+
position=(1, 1, 2),
|
|
176
|
+
light_type='cameralight',
|
|
177
|
+
intensity=self.settings.get('light_intensity', 1.2)
|
|
178
|
+
)
|
|
179
|
+
self.plotter.add_light(light)
|
|
180
|
+
|
|
181
|
+
# 5. 分子描画ロジック
|
|
182
|
+
# Optionally kekulize aromatic systems for 3D visualization.
|
|
183
|
+
mol_to_draw = mol
|
|
184
|
+
if self.settings.get('display_kekule_3d', False):
|
|
185
|
+
try:
|
|
186
|
+
# Operate on a copy to avoid mutating the original molecule
|
|
187
|
+
mol_to_draw = Chem.Mol(mol)
|
|
188
|
+
Chem.Kekulize(mol_to_draw, clearAromaticFlags=True)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
# Kekulize failed; keep original and warn user
|
|
191
|
+
try:
|
|
192
|
+
self.statusBar().showMessage(f"Kekulize failed: {e}")
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
mol_to_draw = mol
|
|
196
|
+
|
|
197
|
+
# Use the original molecule's conformer (positions) to ensure coordinates
|
|
198
|
+
# are preserved even when we create a kekulized copy for bond types.
|
|
199
|
+
conf = mol.GetConformer()
|
|
200
|
+
|
|
201
|
+
# Use the kekulized molecule's atom ordering for color/size decisions
|
|
202
|
+
self.atom_positions_3d = np.array([list(conf.GetAtomPosition(i)) for i in range(mol_to_draw.GetNumAtoms())])
|
|
203
|
+
|
|
204
|
+
# Use the possibly-kekulized molecule for symbol/bond types
|
|
205
|
+
sym = [a.GetSymbol() for a in mol_to_draw.GetAtoms()]
|
|
206
|
+
col = np.array([CPK_COLORS_PV.get(s, [0.5, 0.5, 0.5]) for s in sym])
|
|
207
|
+
|
|
208
|
+
# Apply plugin color overrides
|
|
209
|
+
if hasattr(self, '_plugin_color_overrides') and self._plugin_color_overrides:
|
|
210
|
+
for atom_idx, hex_color in self._plugin_color_overrides.items():
|
|
211
|
+
if 0 <= atom_idx < len(col):
|
|
212
|
+
try:
|
|
213
|
+
c = QColor(hex_color)
|
|
214
|
+
col[atom_idx] = [c.redF(), c.greenF(), c.blueF()]
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# スタイルに応じて原子の半径を設定(設定から読み込み)
|
|
219
|
+
if current_style == 'cpk':
|
|
220
|
+
atom_scale = self.settings.get('cpk_atom_scale', 1.0)
|
|
221
|
+
resolution = self.settings.get('cpk_resolution', 32)
|
|
222
|
+
# Safe VDW lookup to handle custom elements like 'Bq'
|
|
223
|
+
def get_safe_rvdw(s):
|
|
224
|
+
try:
|
|
225
|
+
r = pt.GetRvdw(pt.GetAtomicNumber(s))
|
|
226
|
+
return r if r > 0.1 else 1.5
|
|
227
|
+
except Exception:
|
|
228
|
+
return 1.5
|
|
229
|
+
|
|
230
|
+
rad = np.array([get_safe_rvdw(s) * atom_scale for s in sym])
|
|
231
|
+
elif current_style == 'wireframe':
|
|
232
|
+
# Wireframeでは原子を描画しないので、この設定は実際には使用されない
|
|
233
|
+
resolution = self.settings.get('wireframe_resolution', 6)
|
|
234
|
+
rad = np.array([0.01 for s in sym]) # 極小値(使用されない)
|
|
235
|
+
elif current_style == 'stick':
|
|
236
|
+
atom_radius = self.settings.get('stick_bond_radius', 0.15) # Use bond radius for atoms
|
|
237
|
+
resolution = self.settings.get('stick_resolution', 16)
|
|
238
|
+
rad = np.array([atom_radius for s in sym])
|
|
239
|
+
else: # ball_and_stick
|
|
240
|
+
atom_scale = self.settings.get('ball_stick_atom_scale', 1.0)
|
|
241
|
+
resolution = self.settings.get('ball_stick_resolution', 16)
|
|
242
|
+
rad = np.array([VDW_RADII.get(s, 0.4) * atom_scale for s in sym])
|
|
243
|
+
|
|
244
|
+
self.glyph_source = pv.PolyData(self.atom_positions_3d)
|
|
245
|
+
self.glyph_source['colors'] = col
|
|
246
|
+
self.glyph_source['radii'] = rad
|
|
247
|
+
|
|
248
|
+
# メッシュプロパティを共通で定義
|
|
249
|
+
mesh_props = dict(
|
|
250
|
+
smooth_shading=True,
|
|
251
|
+
specular=self.settings.get('specular', 0.2),
|
|
252
|
+
specular_power=self.settings.get('specular_power', 20),
|
|
253
|
+
lighting=is_lighting_enabled,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Wireframeスタイルの場合は原子を描画しない
|
|
257
|
+
if current_style != 'wireframe':
|
|
258
|
+
# Stickモードで末端二重結合・三重結合の原子を分裂させるための処理
|
|
259
|
+
if current_style == 'stick':
|
|
260
|
+
# 末端原子(次数1)で多重結合を持つものを検出
|
|
261
|
+
split_atoms = [] # (atom_idx, bond_order, offset_vecs)
|
|
262
|
+
skip_atoms = set() # スキップする原子のインデックス
|
|
263
|
+
|
|
264
|
+
for i in range(mol_to_draw.GetNumAtoms()):
|
|
265
|
+
atom = mol_to_draw.GetAtomWithIdx(i)
|
|
266
|
+
if atom.GetDegree() == 1: # 末端原子
|
|
267
|
+
bonds = atom.GetBonds()
|
|
268
|
+
if len(bonds) == 1:
|
|
269
|
+
bond = bonds[0]
|
|
270
|
+
bond_type = bond.GetBondType()
|
|
271
|
+
|
|
272
|
+
if bond_type in [Chem.BondType.DOUBLE, Chem.BondType.TRIPLE]:
|
|
273
|
+
# 多重結合を持つ末端原子を発見
|
|
274
|
+
# 結合のもう一方の原子を取得
|
|
275
|
+
other_idx = bond.GetBeginAtomIdx() if bond.GetEndAtomIdx() == i else bond.GetEndAtomIdx()
|
|
276
|
+
|
|
277
|
+
# 結合ベクトルを計算
|
|
278
|
+
pos_i = np.array(conf.GetAtomPosition(i))
|
|
279
|
+
pos_other = np.array(conf.GetAtomPosition(other_idx))
|
|
280
|
+
bond_vec = pos_i - pos_other
|
|
281
|
+
bond_length = np.linalg.norm(bond_vec)
|
|
282
|
+
|
|
283
|
+
if bond_length > 0:
|
|
284
|
+
bond_unit = bond_vec / bond_length
|
|
285
|
+
|
|
286
|
+
# 二重結合の場合は実際の描画と同じオフセット方向を使用
|
|
287
|
+
if bond_type == Chem.BondType.DOUBLE:
|
|
288
|
+
offset_dir1 = self._calculate_double_bond_offset(mol_to_draw, bond, conf)
|
|
289
|
+
else:
|
|
290
|
+
# 三重結合の場合は結合描画と同じロジック
|
|
291
|
+
v_arb = np.array([0, 0, 1])
|
|
292
|
+
if np.allclose(np.abs(np.dot(bond_unit, v_arb)), 1.0):
|
|
293
|
+
v_arb = np.array([0, 1, 0])
|
|
294
|
+
offset_dir1 = np.cross(bond_unit, v_arb)
|
|
295
|
+
offset_dir1 /= np.linalg.norm(offset_dir1)
|
|
296
|
+
|
|
297
|
+
# 二重/三重結合描画のオフセット値と半径を取得(結合描画と完全に一致させる)
|
|
298
|
+
try:
|
|
299
|
+
cyl_radius = self.settings.get('stick_bond_radius', 0.15)
|
|
300
|
+
if bond_type == Chem.BondType.DOUBLE:
|
|
301
|
+
radius_factor = self.settings.get('stick_double_bond_radius_factor', 0.60)
|
|
302
|
+
offset_factor = self.settings.get('stick_double_bond_offset_factor', 1.5)
|
|
303
|
+
# 二重結合:s_double / 2 を使用
|
|
304
|
+
offset_distance = cyl_radius * offset_factor / 2
|
|
305
|
+
else: # TRIPLE
|
|
306
|
+
radius_factor = self.settings.get('stick_triple_bond_radius_factor', 0.40)
|
|
307
|
+
offset_factor = self.settings.get('stick_triple_bond_offset_factor', 1.0)
|
|
308
|
+
# 三重結合:s_triple をそのまま使用(/ 2 なし)
|
|
309
|
+
offset_distance = cyl_radius * offset_factor
|
|
310
|
+
|
|
311
|
+
# 結合描画と同じ計算
|
|
312
|
+
sphere_radius = cyl_radius * radius_factor
|
|
313
|
+
except Exception:
|
|
314
|
+
sphere_radius = 0.09 # デフォルト値
|
|
315
|
+
offset_distance = 0.15 # デフォルト値
|
|
316
|
+
|
|
317
|
+
if bond_type == Chem.BondType.DOUBLE:
|
|
318
|
+
# 二重結合:2個に分裂
|
|
319
|
+
offset_vecs = [
|
|
320
|
+
offset_dir1 * offset_distance,
|
|
321
|
+
-offset_dir1 * offset_distance
|
|
322
|
+
]
|
|
323
|
+
split_atoms.append((i, 2, offset_vecs))
|
|
324
|
+
else: # TRIPLE
|
|
325
|
+
# 三重結合:3個に分裂(中心 + 両側2つ)
|
|
326
|
+
# 結合描画と同じ配置
|
|
327
|
+
offset_vecs = [
|
|
328
|
+
np.array([0, 0, 0]), # 中心
|
|
329
|
+
offset_dir1 * offset_distance, # +side
|
|
330
|
+
-offset_dir1 * offset_distance # -side
|
|
331
|
+
]
|
|
332
|
+
split_atoms.append((i, 3, offset_vecs))
|
|
333
|
+
|
|
334
|
+
skip_atoms.add(i)
|
|
335
|
+
|
|
336
|
+
# 分裂させる原子がある場合、新しい位置リストを作成
|
|
337
|
+
if split_atoms:
|
|
338
|
+
new_positions = []
|
|
339
|
+
new_colors = []
|
|
340
|
+
new_radii = []
|
|
341
|
+
|
|
342
|
+
# 通常の原子を追加(スキップリスト以外)
|
|
343
|
+
for i in range(len(self.atom_positions_3d)):
|
|
344
|
+
if i not in skip_atoms:
|
|
345
|
+
new_positions.append(self.atom_positions_3d[i])
|
|
346
|
+
new_colors.append(col[i])
|
|
347
|
+
new_radii.append(rad[i])
|
|
348
|
+
|
|
349
|
+
# 分裂した原子を追加
|
|
350
|
+
# 上記で計算されたsphere_radiusを使用(結合描画のradius_factorを適用済み)
|
|
351
|
+
for atom_idx, bond_order, offset_vecs in split_atoms:
|
|
352
|
+
pos = self.atom_positions_3d[atom_idx]
|
|
353
|
+
# この原子の結合から半径を取得(上記ループで計算済み)
|
|
354
|
+
# 簡便のため、最後に計算されたsphere_radiusを使用
|
|
355
|
+
for offset_vec in offset_vecs:
|
|
356
|
+
new_positions.append(pos + offset_vec)
|
|
357
|
+
new_colors.append(col[atom_idx])
|
|
358
|
+
new_radii.append(sphere_radius)
|
|
359
|
+
|
|
360
|
+
# PolyDataを新しい位置で作成
|
|
361
|
+
glyph_source = pv.PolyData(np.array(new_positions))
|
|
362
|
+
glyph_source['colors'] = np.array(new_colors)
|
|
363
|
+
glyph_source['radii'] = np.array(new_radii)
|
|
364
|
+
else:
|
|
365
|
+
glyph_source = self.glyph_source
|
|
366
|
+
else:
|
|
367
|
+
glyph_source = self.glyph_source
|
|
368
|
+
|
|
369
|
+
glyphs = glyph_source.glyph(scale='radii', geom=pv.Sphere(radius=1.0, theta_resolution=resolution, phi_resolution=resolution), orient=False)
|
|
370
|
+
|
|
371
|
+
if is_lighting_enabled:
|
|
372
|
+
self.atom_actor = self.plotter.add_mesh(glyphs, scalars='colors', rgb=True, **mesh_props)
|
|
373
|
+
else:
|
|
374
|
+
self.atom_actor = self.plotter.add_mesh(
|
|
375
|
+
glyphs, scalars='colors', rgb=True,
|
|
376
|
+
style='surface', show_edges=True, edge_color='grey',
|
|
377
|
+
**mesh_props
|
|
378
|
+
)
|
|
379
|
+
self.atom_actor.GetProperty().SetEdgeOpacity(0.3)
|
|
380
|
+
|
|
381
|
+
# 原子の色情報を記録
|
|
382
|
+
for i, atom_color in enumerate(col):
|
|
383
|
+
atom_rgb = [int(c * 255) for c in atom_color]
|
|
384
|
+
self._3d_color_map[f'atom_{i}'] = atom_rgb
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ボンドの描画(ball_and_stick、wireframe、stickで描画)
|
|
388
|
+
if current_style in ['ball_and_stick', 'wireframe', 'stick']:
|
|
389
|
+
# スタイルに応じてボンドの太さと解像度を設定(設定から読み込み)
|
|
390
|
+
if current_style == 'wireframe':
|
|
391
|
+
cyl_radius = self.settings.get('wireframe_bond_radius', 0.01)
|
|
392
|
+
bond_resolution = self.settings.get('wireframe_resolution', 6)
|
|
393
|
+
elif current_style == 'stick':
|
|
394
|
+
cyl_radius = self.settings.get('stick_bond_radius', 0.15)
|
|
395
|
+
bond_resolution = self.settings.get('stick_resolution', 16)
|
|
396
|
+
else: # ball_and_stick
|
|
397
|
+
cyl_radius = self.settings.get('ball_stick_bond_radius', 0.1)
|
|
398
|
+
bond_resolution = self.settings.get('ball_stick_resolution', 16)
|
|
399
|
+
|
|
400
|
+
# Ball and Stick用の共通色
|
|
401
|
+
bs_bond_rgb = [127, 127, 127]
|
|
402
|
+
if current_style == 'ball_and_stick':
|
|
403
|
+
try:
|
|
404
|
+
bs_hex = self.settings.get('ball_stick_bond_color', '#7F7F7F')
|
|
405
|
+
q = QColor(bs_hex)
|
|
406
|
+
bs_bond_rgb = [q.red(), q.green(), q.blue()]
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
# バッチ処理用のリスト
|
|
411
|
+
all_points = []
|
|
412
|
+
all_lines = []
|
|
413
|
+
all_radii = []
|
|
414
|
+
all_colors = [] # Cell data (one per line segment)
|
|
415
|
+
|
|
416
|
+
current_point_idx = 0
|
|
417
|
+
bond_counter = 0
|
|
418
|
+
|
|
419
|
+
for bond in mol_to_draw.GetBonds():
|
|
420
|
+
begin_atom_idx = bond.GetBeginAtomIdx()
|
|
421
|
+
end_atom_idx = bond.GetEndAtomIdx()
|
|
422
|
+
sp = np.array(conf.GetAtomPosition(begin_atom_idx))
|
|
423
|
+
ep = np.array(conf.GetAtomPosition(end_atom_idx))
|
|
424
|
+
bt = bond.GetBondType()
|
|
425
|
+
d = ep - sp
|
|
426
|
+
h = np.linalg.norm(d)
|
|
427
|
+
if h == 0: continue
|
|
428
|
+
|
|
429
|
+
# ボンドの色
|
|
430
|
+
begin_color = col[begin_atom_idx]
|
|
431
|
+
end_color = col[end_atom_idx]
|
|
432
|
+
begin_color_rgb = [int(c * 255) for c in begin_color]
|
|
433
|
+
end_color_rgb = [int(c * 255) for c in end_color]
|
|
434
|
+
|
|
435
|
+
# Check for plugin override
|
|
436
|
+
bond_idx = bond.GetIdx()
|
|
437
|
+
# Override handling: if set, force both ends and uniform color to this value
|
|
438
|
+
if hasattr(self, '_plugin_bond_color_overrides') and bond_idx in self._plugin_bond_color_overrides:
|
|
439
|
+
try:
|
|
440
|
+
# Expecting hex string
|
|
441
|
+
hex_c = self._plugin_bond_color_overrides[bond_idx]
|
|
442
|
+
c_obj = QColor(hex_c)
|
|
443
|
+
ov_rgb = [c_obj.red(), c_obj.green(), c_obj.blue()]
|
|
444
|
+
begin_color_rgb = ov_rgb
|
|
445
|
+
end_color_rgb = ov_rgb
|
|
446
|
+
# Also override uniform color in case style uses it
|
|
447
|
+
# We need to use a local variable for this iteration instead of the global bs_bond_rgb
|
|
448
|
+
# But wait, bs_bond_rgb is defined outside loop.
|
|
449
|
+
# We can define local_bs_bond_rgb
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
# Determine effective uniform color for this bond
|
|
454
|
+
local_bs_bond_rgb = begin_color_rgb if (hasattr(self, '_plugin_bond_color_overrides') and bond_idx in self._plugin_bond_color_overrides) else bs_bond_rgb
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# セグメント追加用ヘルパー関数
|
|
458
|
+
def add_segment(p1, p2, radius, color_rgb):
|
|
459
|
+
nonlocal current_point_idx
|
|
460
|
+
all_points.append(p1)
|
|
461
|
+
all_points.append(p2)
|
|
462
|
+
all_lines.append([2, current_point_idx, current_point_idx + 1])
|
|
463
|
+
all_radii.append(radius)
|
|
464
|
+
all_radii.append(radius)
|
|
465
|
+
all_colors.append(color_rgb)
|
|
466
|
+
current_point_idx += 2
|
|
467
|
+
|
|
468
|
+
QApplication.processEvents()
|
|
469
|
+
|
|
470
|
+
# Get CPK bond color setting once for all bond types
|
|
471
|
+
use_cpk_bond = self.settings.get('ball_stick_use_cpk_bond_color', False)
|
|
472
|
+
# If overwritten, treat as if we want to show that color (effectively behave like CPK_Split but with same color, or Uniform).
|
|
473
|
+
# To be robust, if overwritten, we can force "use_cpk_bond" logic but with our same colors?
|
|
474
|
+
# Actually, if overridden, we probably want the whole bond to be that color.
|
|
475
|
+
|
|
476
|
+
is_overridden = hasattr(self, '_plugin_bond_color_overrides') and bond_idx in self._plugin_bond_color_overrides
|
|
477
|
+
|
|
478
|
+
if bt == Chem.rdchem.BondType.SINGLE or bt == Chem.rdchem.BondType.AROMATIC:
|
|
479
|
+
if current_style == 'ball_and_stick' and not use_cpk_bond and not is_overridden:
|
|
480
|
+
# 単一セグメント (Uniform color) - Default behavior
|
|
481
|
+
add_segment(sp, ep, cyl_radius, local_bs_bond_rgb)
|
|
482
|
+
self._3d_color_map[f'bond_{bond_counter}'] = local_bs_bond_rgb
|
|
483
|
+
else:
|
|
484
|
+
# 分割セグメント (CPK split colors OR Overridden uniform)
|
|
485
|
+
# If overridden, begin/end are same, so this produces a uniform looking bond split in middle
|
|
486
|
+
mid_point = (sp + ep) / 2
|
|
487
|
+
add_segment(sp, mid_point, cyl_radius, begin_color_rgb)
|
|
488
|
+
add_segment(mid_point, ep, cyl_radius, end_color_rgb)
|
|
489
|
+
self._3d_color_map[f'bond_{bond_counter}_start'] = begin_color_rgb
|
|
490
|
+
self._3d_color_map[f'bond_{bond_counter}_end'] = end_color_rgb
|
|
491
|
+
|
|
492
|
+
else:
|
|
493
|
+
# 多重結合のパラメータ計算
|
|
494
|
+
v1 = d / h
|
|
495
|
+
# モデルごとの半径ファクターを適用
|
|
496
|
+
if current_style == 'ball_and_stick':
|
|
497
|
+
double_radius_factor = self.settings.get('ball_stick_double_bond_radius_factor', 0.8)
|
|
498
|
+
triple_radius_factor = self.settings.get('ball_stick_triple_bond_radius_factor', 0.75)
|
|
499
|
+
elif current_style == 'wireframe':
|
|
500
|
+
double_radius_factor = self.settings.get('wireframe_double_bond_radius_factor', 0.8)
|
|
501
|
+
triple_radius_factor = self.settings.get('wireframe_triple_bond_radius_factor', 0.75)
|
|
502
|
+
elif current_style == 'stick':
|
|
503
|
+
double_radius_factor = self.settings.get('stick_double_bond_radius_factor', 0.60)
|
|
504
|
+
triple_radius_factor = self.settings.get('stick_triple_bond_radius_factor', 0.40)
|
|
505
|
+
else:
|
|
506
|
+
double_radius_factor = 1.0
|
|
507
|
+
triple_radius_factor = 0.75
|
|
508
|
+
|
|
509
|
+
# 設定からオフセットファクターを取得(モデルごと)
|
|
510
|
+
if current_style == 'ball_and_stick':
|
|
511
|
+
double_offset_factor = self.settings.get('ball_stick_double_bond_offset_factor', 2.0)
|
|
512
|
+
triple_offset_factor = self.settings.get('ball_stick_triple_bond_offset_factor', 2.0)
|
|
513
|
+
elif current_style == 'wireframe':
|
|
514
|
+
double_offset_factor = self.settings.get('wireframe_double_bond_offset_factor', 3.0)
|
|
515
|
+
triple_offset_factor = self.settings.get('wireframe_triple_bond_offset_factor', 3.0)
|
|
516
|
+
elif current_style == 'stick':
|
|
517
|
+
double_offset_factor = self.settings.get('stick_double_bond_offset_factor', 1.5)
|
|
518
|
+
triple_offset_factor = self.settings.get('stick_triple_bond_offset_factor', 1.0)
|
|
519
|
+
else:
|
|
520
|
+
double_offset_factor = 2.0
|
|
521
|
+
triple_offset_factor = 2.0
|
|
522
|
+
|
|
523
|
+
if bt == Chem.rdchem.BondType.DOUBLE:
|
|
524
|
+
r = cyl_radius * double_radius_factor
|
|
525
|
+
off_dir = self._calculate_double_bond_offset(mol_to_draw, bond, conf)
|
|
526
|
+
s_double = cyl_radius * double_offset_factor
|
|
527
|
+
|
|
528
|
+
p1_start = sp + off_dir * (s_double / 2)
|
|
529
|
+
p1_end = ep + off_dir * (s_double / 2)
|
|
530
|
+
p2_start = sp - off_dir * (s_double / 2)
|
|
531
|
+
p2_end = ep - off_dir * (s_double / 2)
|
|
532
|
+
|
|
533
|
+
if current_style == 'ball_and_stick' and not use_cpk_bond and not is_overridden:
|
|
534
|
+
add_segment(p1_start, p1_end, r, local_bs_bond_rgb)
|
|
535
|
+
add_segment(p2_start, p2_end, r, local_bs_bond_rgb)
|
|
536
|
+
self._3d_color_map[f'bond_{bond_counter}_1'] = local_bs_bond_rgb
|
|
537
|
+
self._3d_color_map[f'bond_{bond_counter}_2'] = local_bs_bond_rgb
|
|
538
|
+
else:
|
|
539
|
+
mid1 = (p1_start + p1_end) / 2
|
|
540
|
+
mid2 = (p2_start + p2_end) / 2
|
|
541
|
+
add_segment(p1_start, mid1, r, begin_color_rgb)
|
|
542
|
+
add_segment(mid1, p1_end, r, end_color_rgb)
|
|
543
|
+
add_segment(p2_start, mid2, r, begin_color_rgb)
|
|
544
|
+
add_segment(mid2, p2_end, r, end_color_rgb)
|
|
545
|
+
self._3d_color_map[f'bond_{bond_counter}_1_start'] = begin_color_rgb
|
|
546
|
+
self._3d_color_map[f'bond_{bond_counter}_1_end'] = end_color_rgb
|
|
547
|
+
self._3d_color_map[f'bond_{bond_counter}_2_start'] = begin_color_rgb
|
|
548
|
+
self._3d_color_map[f'bond_{bond_counter}_2_end'] = end_color_rgb
|
|
549
|
+
|
|
550
|
+
elif bt == Chem.rdchem.BondType.TRIPLE:
|
|
551
|
+
r = cyl_radius * triple_radius_factor
|
|
552
|
+
v_arb = np.array([0, 0, 1])
|
|
553
|
+
if np.allclose(np.abs(np.dot(v1, v_arb)), 1.0): v_arb = np.array([0, 1, 0])
|
|
554
|
+
off_dir = np.cross(v1, v_arb)
|
|
555
|
+
off_dir /= np.linalg.norm(off_dir)
|
|
556
|
+
s_triple = cyl_radius * triple_offset_factor
|
|
557
|
+
|
|
558
|
+
# Center
|
|
559
|
+
if current_style == 'ball_and_stick' and not use_cpk_bond and not is_overridden:
|
|
560
|
+
add_segment(sp, ep, r, local_bs_bond_rgb)
|
|
561
|
+
self._3d_color_map[f'bond_{bond_counter}_1'] = local_bs_bond_rgb
|
|
562
|
+
else:
|
|
563
|
+
mid = (sp + ep) / 2
|
|
564
|
+
add_segment(sp, mid, r, begin_color_rgb)
|
|
565
|
+
add_segment(mid, ep, r, end_color_rgb)
|
|
566
|
+
self._3d_color_map[f'bond_{bond_counter}_1_start'] = begin_color_rgb
|
|
567
|
+
self._3d_color_map[f'bond_{bond_counter}_1_end'] = end_color_rgb
|
|
568
|
+
|
|
569
|
+
# Sides
|
|
570
|
+
for sign in [1, -1]:
|
|
571
|
+
offset = off_dir * s_triple * sign
|
|
572
|
+
p_start = sp + offset
|
|
573
|
+
p_end = ep + offset
|
|
574
|
+
|
|
575
|
+
if current_style == 'ball_and_stick' and not use_cpk_bond and not is_overridden:
|
|
576
|
+
add_segment(p_start, p_end, r, local_bs_bond_rgb)
|
|
577
|
+
suffix = '_2' if sign == 1 else '_3'
|
|
578
|
+
self._3d_color_map[f'bond_{bond_counter}{suffix}'] = local_bs_bond_rgb
|
|
579
|
+
else:
|
|
580
|
+
mid = (p_start + p_end) / 2
|
|
581
|
+
add_segment(p_start, mid, r, begin_color_rgb)
|
|
582
|
+
add_segment(mid, p_end, r, end_color_rgb)
|
|
583
|
+
suffix = '_2' if sign == 1 else '_3'
|
|
584
|
+
self._3d_color_map[f'bond_{bond_counter}{suffix}_start'] = begin_color_rgb
|
|
585
|
+
self._3d_color_map[f'bond_{bond_counter}{suffix}_end'] = end_color_rgb
|
|
586
|
+
|
|
587
|
+
bond_counter += 1
|
|
588
|
+
|
|
589
|
+
# ジオメトリの生成と描画
|
|
590
|
+
if all_points:
|
|
591
|
+
# Create PolyData
|
|
592
|
+
bond_pd = pv.PolyData(np.array(all_points), lines=np.hstack(all_lines))
|
|
593
|
+
# lines needs to be a flat array with padding indicating number of points per cell
|
|
594
|
+
# all_lines is [[2, i, j], [2, k, l], ...], flatten it
|
|
595
|
+
|
|
596
|
+
# Add data
|
|
597
|
+
bond_pd.point_data['radii'] = np.array(all_radii)
|
|
598
|
+
|
|
599
|
+
# Convert colors to 0-1 range for PyVista if needed, but add_mesh with rgb=True expects uint8 if using direct array?
|
|
600
|
+
# Actually pyvista scalars usually prefer float 0-1 or uint8 0-255.
|
|
601
|
+
# Let's use uint8 0-255 and rgb=True.
|
|
602
|
+
bond_pd.cell_data['colors'] = np.array(all_colors, dtype=np.uint8)
|
|
603
|
+
|
|
604
|
+
# Tube filter
|
|
605
|
+
# n_sides (resolution) corresponds to theta_resolution in Cylinder
|
|
606
|
+
tube = bond_pd.tube(scalars='radii', absolute=True, radius_factor=1.0, n_sides=bond_resolution, capping=True)
|
|
607
|
+
|
|
608
|
+
# Add to plotter
|
|
609
|
+
self.plotter.add_mesh(tube, scalars='colors', rgb=True, **mesh_props)
|
|
610
|
+
|
|
611
|
+
# Aromatic ring circles display
|
|
612
|
+
if self.settings.get('display_aromatic_circles_3d', False):
|
|
613
|
+
try:
|
|
614
|
+
ring_info = mol_to_draw.GetRingInfo()
|
|
615
|
+
aromatic_rings = []
|
|
616
|
+
|
|
617
|
+
# Find aromatic rings
|
|
618
|
+
for ring in ring_info.AtomRings():
|
|
619
|
+
# Check if all atoms in ring are aromatic
|
|
620
|
+
is_aromatic = all(mol_to_draw.GetAtomWithIdx(idx).GetIsAromatic() for idx in ring)
|
|
621
|
+
if is_aromatic:
|
|
622
|
+
aromatic_rings.append(ring)
|
|
623
|
+
|
|
624
|
+
# Draw circles for aromatic rings
|
|
625
|
+
for ring in aromatic_rings:
|
|
626
|
+
# Get atom positions
|
|
627
|
+
ring_positions = [self.atom_positions_3d[idx] for idx in ring]
|
|
628
|
+
ring_positions_np = np.array(ring_positions)
|
|
629
|
+
|
|
630
|
+
# Calculate ring center
|
|
631
|
+
center = np.mean(ring_positions_np, axis=0)
|
|
632
|
+
|
|
633
|
+
# Calculate ring normal using PCA or cross product
|
|
634
|
+
# Use first 3 atoms to get two vectors
|
|
635
|
+
if len(ring) >= 3:
|
|
636
|
+
v1 = ring_positions_np[1] - ring_positions_np[0]
|
|
637
|
+
v2 = ring_positions_np[2] - ring_positions_np[0]
|
|
638
|
+
normal = np.cross(v1, v2)
|
|
639
|
+
normal_length = np.linalg.norm(normal)
|
|
640
|
+
if normal_length > 0:
|
|
641
|
+
normal = normal / normal_length
|
|
642
|
+
else:
|
|
643
|
+
normal = np.array([0, 0, 1])
|
|
644
|
+
else:
|
|
645
|
+
normal = np.array([0, 0, 1])
|
|
646
|
+
|
|
647
|
+
# Calculate ring radius (average distance from center)
|
|
648
|
+
distances = [np.linalg.norm(pos - center) for pos in ring_positions_np]
|
|
649
|
+
ring_radius = np.mean(distances) * 0.55 # Slightly smaller
|
|
650
|
+
|
|
651
|
+
# Get bond radius from current style settings for torus thickness
|
|
652
|
+
if current_style == 'stick':
|
|
653
|
+
bond_radius = self.settings.get('stick_bond_radius', 0.15)
|
|
654
|
+
elif current_style == 'ball_and_stick':
|
|
655
|
+
bond_radius = self.settings.get('ball_stick_bond_radius', 0.1)
|
|
656
|
+
elif current_style == 'wireframe':
|
|
657
|
+
bond_radius = self.settings.get('wireframe_bond_radius', 0.01)
|
|
658
|
+
else:
|
|
659
|
+
bond_radius = 0.1 # Default
|
|
660
|
+
# Apply user-defined thickness factor (default 0.6)
|
|
661
|
+
thickness_factor = self.settings.get('aromatic_torus_thickness_factor', 0.6)
|
|
662
|
+
tube_radius = bond_radius * thickness_factor
|
|
663
|
+
theta = np.linspace(0, 2.2 * np.pi, 64)
|
|
664
|
+
circle_x = ring_radius * np.cos(theta)
|
|
665
|
+
circle_y = ring_radius * np.sin(theta)
|
|
666
|
+
circle_z = np.zeros_like(theta)
|
|
667
|
+
circle_points = np.c_[circle_x, circle_y, circle_z]
|
|
668
|
+
|
|
669
|
+
# Create line from points
|
|
670
|
+
circle_line = pv.Spline(circle_points, n_points=64).tube(radius=tube_radius, n_sides=16)
|
|
671
|
+
|
|
672
|
+
# Rotate torus to align with ring plane
|
|
673
|
+
# Default torus is in XY plane (normal = [0, 0, 1])
|
|
674
|
+
default_normal = np.array([0, 0, 1])
|
|
675
|
+
|
|
676
|
+
# Calculate rotation axis and angle
|
|
677
|
+
if not np.allclose(normal, default_normal) and not np.allclose(normal, -default_normal):
|
|
678
|
+
axis = np.cross(default_normal, normal)
|
|
679
|
+
axis_length = np.linalg.norm(axis)
|
|
680
|
+
if axis_length > 0:
|
|
681
|
+
axis = axis / axis_length
|
|
682
|
+
angle = np.arccos(np.clip(np.dot(default_normal, normal), -1.0, 1.0))
|
|
683
|
+
angle_deg = np.degrees(angle)
|
|
684
|
+
|
|
685
|
+
# Rotate torus
|
|
686
|
+
circle_line = circle_line.rotate_vector(axis, angle_deg, point=[0, 0, 0])
|
|
687
|
+
|
|
688
|
+
# Translate to ring center
|
|
689
|
+
circle_line = circle_line.translate(center)
|
|
690
|
+
|
|
691
|
+
# Get torus color from bond color settings
|
|
692
|
+
# Calculate most common atom type in ring for CPK color
|
|
693
|
+
from collections import Counter
|
|
694
|
+
atom_symbols = [mol_to_draw.GetAtomWithIdx(idx).GetSymbol() for idx in ring]
|
|
695
|
+
most_common_symbol = Counter(atom_symbols).most_common(1)[0][0] if atom_symbols else None
|
|
696
|
+
|
|
697
|
+
if current_style == 'ball_and_stick':
|
|
698
|
+
# Check if using CPK bond colors
|
|
699
|
+
use_cpk = self.settings.get('ball_stick_use_cpk_bond_color', False)
|
|
700
|
+
if use_cpk:
|
|
701
|
+
# Use CPK color of most common atom type in ring
|
|
702
|
+
if most_common_symbol:
|
|
703
|
+
cpk_color = CPK_COLORS_PV.get(most_common_symbol, [0.5, 0.5, 0.5])
|
|
704
|
+
torus_color = cpk_color
|
|
705
|
+
else:
|
|
706
|
+
torus_color = [0.5, 0.5, 0.5]
|
|
707
|
+
else:
|
|
708
|
+
# Use Ball & Stick bond color setting
|
|
709
|
+
bond_hex = self.settings.get('ball_stick_bond_color', '#7F7F7F')
|
|
710
|
+
q = QColor(bond_hex)
|
|
711
|
+
torus_color = [q.red() / 255.0, q.green() / 255.0, q.blue() / 255.0]
|
|
712
|
+
else:
|
|
713
|
+
# For Wireframe and Stick, use CPK color of most common atom
|
|
714
|
+
if most_common_symbol:
|
|
715
|
+
cpk_color = CPK_COLORS_PV.get(most_common_symbol, [0.5, 0.5, 0.5])
|
|
716
|
+
torus_color = cpk_color
|
|
717
|
+
else:
|
|
718
|
+
torus_color = [0.5, 0.5, 0.5]
|
|
719
|
+
|
|
720
|
+
self.plotter.add_mesh(circle_line, color=torus_color, **mesh_props)
|
|
721
|
+
|
|
722
|
+
except Exception as e:
|
|
723
|
+
logging.error(f"Error rendering aromatic circles: {e}")
|
|
724
|
+
|
|
725
|
+
if getattr(self, 'show_chiral_labels', False):
|
|
726
|
+
try:
|
|
727
|
+
# 3D座標からキラル中心を計算
|
|
728
|
+
chiral_centers = Chem.FindMolChiralCenters(mol, includeUnassigned=True)
|
|
729
|
+
if chiral_centers:
|
|
730
|
+
pts, labels = [], []
|
|
731
|
+
z_off = 0
|
|
732
|
+
for idx, lbl in chiral_centers:
|
|
733
|
+
coord = self.atom_positions_3d[idx].copy(); coord[2] += z_off
|
|
734
|
+
pts.append(coord); labels.append(lbl if lbl is not None else '?')
|
|
735
|
+
try: self.plotter.remove_actor('chiral_labels')
|
|
736
|
+
except Exception: pass
|
|
737
|
+
self.plotter.add_point_labels(np.array(pts), labels, font_size=20, point_size=0, text_color='blue', name='chiral_labels', always_visible=True, tolerance=0.01, show_points=False)
|
|
738
|
+
except Exception as e: self.statusBar().showMessage(f"3D chiral label drawing error: {e}")
|
|
739
|
+
|
|
740
|
+
# E/Zラベルも表示
|
|
741
|
+
if getattr(self, 'show_chiral_labels', False):
|
|
742
|
+
try:
|
|
743
|
+
# If we drew a kekulized molecule use it for E/Z detection so
|
|
744
|
+
# E/Z labels reflect Kekulé rendering; pass mol_to_draw as the
|
|
745
|
+
# molecule to scan for bond stereochemistry.
|
|
746
|
+
self.show_ez_labels_3d(mol)
|
|
747
|
+
except Exception as e:
|
|
748
|
+
self.statusBar().showMessage(f"3D E/Z label drawing error: {e}")
|
|
749
|
+
|
|
750
|
+
self.plotter.camera = camera_state
|
|
751
|
+
|
|
752
|
+
# Ensure the underlying VTK camera's parallel/projection flag matches
|
|
753
|
+
# the saved application setting. draw_molecule_3d restores a PyVista
|
|
754
|
+
# camera object which may not propagate the ParallelProjection flag
|
|
755
|
+
# to the VTK renderer camera; enforce it here to guarantee the
|
|
756
|
+
# projection mode selected in settings actually takes effect.
|
|
757
|
+
try:
|
|
758
|
+
proj_mode = self.settings.get('projection_mode', 'Perspective')
|
|
759
|
+
if hasattr(self.plotter, 'renderer') and hasattr(self.plotter.renderer, 'GetActiveCamera'):
|
|
760
|
+
vcam = self.plotter.renderer.GetActiveCamera()
|
|
761
|
+
if vcam:
|
|
762
|
+
if proj_mode == 'Orthographic':
|
|
763
|
+
vcam.SetParallelProjection(True)
|
|
764
|
+
else:
|
|
765
|
+
vcam.SetParallelProjection(False)
|
|
766
|
+
try:
|
|
767
|
+
# Force a render so the change is visible immediately
|
|
768
|
+
self.plotter.render()
|
|
769
|
+
except Exception:
|
|
770
|
+
pass
|
|
771
|
+
except Exception:
|
|
772
|
+
pass
|
|
773
|
+
|
|
774
|
+
# AtomIDまたは他の原子情報が表示されている場合は再表示
|
|
775
|
+
if hasattr(self, 'atom_info_display_mode') and self.atom_info_display_mode is not None:
|
|
776
|
+
self.show_all_atom_info()
|
|
777
|
+
|
|
778
|
+
# メニューテキストと状態を現在の分子の種類に応じて更新
|
|
779
|
+
self.update_atom_id_menu_text()
|
|
780
|
+
self.update_atom_id_menu_state()
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _calculate_double_bond_offset(self, mol, bond, conf):
|
|
785
|
+
"""
|
|
786
|
+
二重結合のオフセット方向を計算する。
|
|
787
|
+
結合している原子の他の結合を考慮して、平面的になるようにする。
|
|
788
|
+
"""
|
|
789
|
+
begin_atom = mol.GetAtomWithIdx(bond.GetBeginAtomIdx())
|
|
790
|
+
end_atom = mol.GetAtomWithIdx(bond.GetEndAtomIdx())
|
|
791
|
+
|
|
792
|
+
begin_pos = np.array(conf.GetAtomPosition(bond.GetBeginAtomIdx()))
|
|
793
|
+
end_pos = np.array(conf.GetAtomPosition(bond.GetEndAtomIdx()))
|
|
794
|
+
|
|
795
|
+
bond_vec = end_pos - begin_pos
|
|
796
|
+
bond_length = np.linalg.norm(bond_vec)
|
|
797
|
+
if bond_length == 0:
|
|
798
|
+
# フォールバック: Z軸基準
|
|
799
|
+
return np.array([0, 0, 1])
|
|
800
|
+
|
|
801
|
+
bond_unit = bond_vec / bond_length
|
|
802
|
+
|
|
803
|
+
# 両端の原子の隣接原子を調べる
|
|
804
|
+
begin_neighbors = []
|
|
805
|
+
end_neighbors = []
|
|
806
|
+
|
|
807
|
+
for neighbor in begin_atom.GetNeighbors():
|
|
808
|
+
if neighbor.GetIdx() != bond.GetEndAtomIdx():
|
|
809
|
+
neighbor_pos = np.array(conf.GetAtomPosition(neighbor.GetIdx()))
|
|
810
|
+
begin_neighbors.append(neighbor_pos)
|
|
811
|
+
|
|
812
|
+
for neighbor in end_atom.GetNeighbors():
|
|
813
|
+
if neighbor.GetIdx() != bond.GetBeginAtomIdx():
|
|
814
|
+
neighbor_pos = np.array(conf.GetAtomPosition(neighbor.GetIdx()))
|
|
815
|
+
end_neighbors.append(neighbor_pos)
|
|
816
|
+
|
|
817
|
+
# 平面の法線ベクトルを計算
|
|
818
|
+
normal_candidates = []
|
|
819
|
+
|
|
820
|
+
# 開始原子の隣接原子から平面を推定
|
|
821
|
+
if len(begin_neighbors) >= 1:
|
|
822
|
+
for neighbor_pos in begin_neighbors:
|
|
823
|
+
vec_to_neighbor = neighbor_pos - begin_pos
|
|
824
|
+
if np.linalg.norm(vec_to_neighbor) > 1e-6:
|
|
825
|
+
# bond_vec と neighbor_vec の外積が平面の法線
|
|
826
|
+
normal = np.cross(bond_vec, vec_to_neighbor)
|
|
827
|
+
norm_length = np.linalg.norm(normal)
|
|
828
|
+
if norm_length > 1e-6:
|
|
829
|
+
normal_candidates.append(normal / norm_length)
|
|
830
|
+
|
|
831
|
+
# 終了原子の隣接原子から平面を推定
|
|
832
|
+
if len(end_neighbors) >= 1:
|
|
833
|
+
for neighbor_pos in end_neighbors:
|
|
834
|
+
vec_to_neighbor = neighbor_pos - end_pos
|
|
835
|
+
if np.linalg.norm(vec_to_neighbor) > 1e-6:
|
|
836
|
+
# bond_vec と neighbor_vec の外積が平面の法線
|
|
837
|
+
normal = np.cross(bond_vec, vec_to_neighbor)
|
|
838
|
+
norm_length = np.linalg.norm(normal)
|
|
839
|
+
if norm_length > 1e-6:
|
|
840
|
+
normal_candidates.append(normal / norm_length)
|
|
841
|
+
|
|
842
|
+
# 複数の法線ベクトルがある場合は平均を取る
|
|
843
|
+
if normal_candidates:
|
|
844
|
+
# 方向を統一するため、最初のベクトルとの内積が正になるように調整
|
|
845
|
+
reference_normal = normal_candidates[0]
|
|
846
|
+
aligned_normals = []
|
|
847
|
+
|
|
848
|
+
for normal in normal_candidates:
|
|
849
|
+
if np.dot(normal, reference_normal) < 0:
|
|
850
|
+
normal = -normal
|
|
851
|
+
aligned_normals.append(normal)
|
|
852
|
+
|
|
853
|
+
avg_normal = np.mean(aligned_normals, axis=0)
|
|
854
|
+
norm_length = np.linalg.norm(avg_normal)
|
|
855
|
+
if norm_length > 1e-6:
|
|
856
|
+
avg_normal /= norm_length
|
|
857
|
+
|
|
858
|
+
# 法線ベクトルと結合ベクトルに垂直な方向を二重結合のオフセット方向とする
|
|
859
|
+
offset_dir = np.cross(bond_unit, avg_normal)
|
|
860
|
+
offset_length = np.linalg.norm(offset_dir)
|
|
861
|
+
if offset_length > 1e-6:
|
|
862
|
+
return offset_dir / offset_length
|
|
863
|
+
|
|
864
|
+
# フォールバック: 結合ベクトルに垂直な任意の方向
|
|
865
|
+
v_arb = np.array([0, 0, 1])
|
|
866
|
+
if np.allclose(np.abs(np.dot(bond_unit, v_arb)), 1.0):
|
|
867
|
+
v_arb = np.array([0, 1, 0])
|
|
868
|
+
|
|
869
|
+
off_dir = np.cross(bond_unit, v_arb)
|
|
870
|
+
off_dir /= np.linalg.norm(off_dir)
|
|
871
|
+
return off_dir
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def show_ez_labels_3d(self, mol):
|
|
876
|
+
"""3DビューでE/Zラベルを表示する(RDKitのステレオ化学判定を使用)"""
|
|
877
|
+
if not mol:
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
try:
|
|
881
|
+
# 既存のE/Zラベルを削除
|
|
882
|
+
self.plotter.remove_actor('ez_labels')
|
|
883
|
+
except Exception:
|
|
884
|
+
pass
|
|
885
|
+
|
|
886
|
+
pts, labels = [], []
|
|
887
|
+
|
|
888
|
+
# 3D座標が存在するかチェック
|
|
889
|
+
if mol.GetNumConformers() == 0:
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
conf = mol.GetConformer()
|
|
893
|
+
|
|
894
|
+
# 二重結合でRDKitが判定したE/Z立体化学を表示
|
|
895
|
+
|
|
896
|
+
try:
|
|
897
|
+
# 3D座標からステレオ化学を再計算 (molに対して行う)
|
|
898
|
+
# これにより、2Dでの描画状態に関わらず、現在の3D座標に基づいたE/Z判定が行われる
|
|
899
|
+
Chem.AssignStereochemistry(mol, cleanIt=True, force=True, flagPossibleStereoCenters=True)
|
|
900
|
+
except Exception:
|
|
901
|
+
pass
|
|
902
|
+
|
|
903
|
+
for bond in mol.GetBonds():
|
|
904
|
+
if bond.GetBondType() == Chem.BondType.DOUBLE:
|
|
905
|
+
new_stereo = bond.GetStereo()
|
|
906
|
+
|
|
907
|
+
if new_stereo in [Chem.BondStereo.STEREOE, Chem.BondStereo.STEREOZ]:
|
|
908
|
+
# 結合の中心座標を計算
|
|
909
|
+
begin_pos = np.array(conf.GetAtomPosition(bond.GetBeginAtomIdx()))
|
|
910
|
+
end_pos = np.array(conf.GetAtomPosition(bond.GetEndAtomIdx()))
|
|
911
|
+
center_pos = (begin_pos + end_pos) / 2
|
|
912
|
+
|
|
913
|
+
# ラベルの決定
|
|
914
|
+
label = 'E' if new_stereo == Chem.BondStereo.STEREOE else 'Z'
|
|
915
|
+
|
|
916
|
+
# 2Dとの不一致チェック
|
|
917
|
+
# main_window_compute.py で保存された2D由来の立体化学プロパティを取得
|
|
918
|
+
try:
|
|
919
|
+
old_stereo = bond.GetIntProp("_original_2d_stereo")
|
|
920
|
+
except KeyError:
|
|
921
|
+
old_stereo = Chem.BondStereo.STEREONONE
|
|
922
|
+
|
|
923
|
+
# 2D側でもE/Zが指定されていて、かつ3Dと異なる場合は「?」にする
|
|
924
|
+
if old_stereo in [Chem.BondStereo.STEREOE, Chem.BondStereo.STEREOZ]:
|
|
925
|
+
if old_stereo != new_stereo:
|
|
926
|
+
label = '?'
|
|
927
|
+
|
|
928
|
+
pts.append(center_pos)
|
|
929
|
+
labels.append(label)
|
|
930
|
+
|
|
931
|
+
if pts and labels:
|
|
932
|
+
self.plotter.add_point_labels(
|
|
933
|
+
np.array(pts),
|
|
934
|
+
labels,
|
|
935
|
+
font_size=18,
|
|
936
|
+
point_size=0,
|
|
937
|
+
text_color='darkgreen', # 暗い緑色
|
|
938
|
+
name='ez_labels',
|
|
939
|
+
always_visible=True,
|
|
940
|
+
tolerance=0.01,
|
|
941
|
+
show_points=False
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def toggle_chiral_labels_display(self, checked):
|
|
948
|
+
"""Viewメニューのアクションに応じてキラルラベル表示を切り替える"""
|
|
949
|
+
self.show_chiral_labels = checked
|
|
950
|
+
|
|
951
|
+
if self.current_mol:
|
|
952
|
+
self.draw_molecule_3d(self.current_mol)
|
|
953
|
+
|
|
954
|
+
if checked:
|
|
955
|
+
self.statusBar().showMessage("Chiral labels: will be (re)computed after Convert→3D.")
|
|
956
|
+
else:
|
|
957
|
+
self.statusBar().showMessage("Chiral labels disabled.")
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def update_chiral_labels(self):
|
|
963
|
+
"""分子のキラル中心を計算し、2Dビューの原子アイテムにR/Sラベルを設定/解除する
|
|
964
|
+
※ 可能なら 3D(self.current_mol)を優先して計算し、なければ 2D から作った RDKit 分子を使う。
|
|
965
|
+
"""
|
|
966
|
+
# まず全てのアイテムからラベルをクリア
|
|
967
|
+
for atom_data in self.data.atoms.values():
|
|
968
|
+
if atom_data.get('item'):
|
|
969
|
+
atom_data['item'].chiral_label = None
|
|
970
|
+
|
|
971
|
+
if not self.show_chiral_labels:
|
|
972
|
+
self.scene.update()
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
# 3D の RDKit Mol(コンフォマーを持つもの)を使う
|
|
976
|
+
mol_for_chirality = None
|
|
977
|
+
if getattr(self, 'current_mol', None) is not None:
|
|
978
|
+
mol_for_chirality = self.current_mol
|
|
979
|
+
else:
|
|
980
|
+
return
|
|
981
|
+
|
|
982
|
+
if mol_for_chirality is None or mol_for_chirality.GetNumAtoms() == 0:
|
|
983
|
+
self.scene.update()
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
try:
|
|
987
|
+
# --- 重要:3D コンフォマーがあるなら、それを使って原子のキラルタグを割り当てる ---
|
|
988
|
+
if mol_for_chirality.GetNumConformers() > 0:
|
|
989
|
+
# confId=0(最初のコンフォマー)を指定して、原子のキラリティータグを3D座標由来で設定
|
|
990
|
+
try:
|
|
991
|
+
Chem.AssignAtomChiralTagsFromStructure(mol_for_chirality, confId=0)
|
|
992
|
+
except Exception:
|
|
993
|
+
# 古い RDKit では関数が無い場合があるので(念のため保護)
|
|
994
|
+
pass
|
|
995
|
+
|
|
996
|
+
# RDKit の通常の stereochemistry 割当(念のため)
|
|
997
|
+
#Chem.AssignStereochemistry(mol_for_chirality, cleanIt=True, force=True, flagPossibleStereoCenters=True)
|
|
998
|
+
|
|
999
|
+
# キラル中心の取得((idx, 'R'/'S'/'?') のリスト)
|
|
1000
|
+
chiral_centers = Chem.FindMolChiralCenters(mol_for_chirality, includeUnassigned=True)
|
|
1001
|
+
|
|
1002
|
+
# RDKit atom index -> エディタ側 atom_id へのマッピング
|
|
1003
|
+
rdkit_idx_to_my_id = {}
|
|
1004
|
+
for atom in mol_for_chirality.GetAtoms():
|
|
1005
|
+
if atom.HasProp("_original_atom_id"):
|
|
1006
|
+
rdkit_idx_to_my_id[atom.GetIdx()] = atom.GetIntProp("_original_atom_id")
|
|
1007
|
+
|
|
1008
|
+
# 見つかったキラル中心を対応する AtomItem に設定
|
|
1009
|
+
for idx, label in chiral_centers:
|
|
1010
|
+
if idx in rdkit_idx_to_my_id:
|
|
1011
|
+
atom_id = rdkit_idx_to_my_id[idx]
|
|
1012
|
+
if atom_id in self.data.atoms and self.data.atoms[atom_id].get('item'):
|
|
1013
|
+
# 'R' / 'S' / '?'
|
|
1014
|
+
self.data.atoms[atom_id]['item'].chiral_label = label
|
|
1015
|
+
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
self.statusBar().showMessage(f"Update chiral labels error: {e}")
|
|
1018
|
+
|
|
1019
|
+
# 最後に 2D シーンを再描画
|
|
1020
|
+
self.scene.update()
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def toggle_atom_info_display(self, mode):
|
|
1025
|
+
"""原子情報表示モードを切り替える"""
|
|
1026
|
+
# 現在の表示をクリア
|
|
1027
|
+
self.clear_all_atom_info_labels()
|
|
1028
|
+
|
|
1029
|
+
# 同じモードが選択された場合はOFFにする
|
|
1030
|
+
if self.atom_info_display_mode == mode:
|
|
1031
|
+
self.atom_info_display_mode = None
|
|
1032
|
+
# 全てのアクションのチェックを外す
|
|
1033
|
+
self.show_atom_id_action.setChecked(False)
|
|
1034
|
+
self.show_rdkit_id_action.setChecked(False)
|
|
1035
|
+
self.show_atom_coords_action.setChecked(False)
|
|
1036
|
+
self.show_atom_symbol_action.setChecked(False)
|
|
1037
|
+
self.statusBar().showMessage("Atom info display disabled.")
|
|
1038
|
+
else:
|
|
1039
|
+
# 新しいモードを設定
|
|
1040
|
+
self.atom_info_display_mode = mode
|
|
1041
|
+
# 該当するアクションのみチェック
|
|
1042
|
+
self.show_atom_id_action.setChecked(mode == 'id')
|
|
1043
|
+
self.show_rdkit_id_action.setChecked(mode == 'rdkit_id')
|
|
1044
|
+
self.show_atom_coords_action.setChecked(mode == 'coords')
|
|
1045
|
+
self.show_atom_symbol_action.setChecked(mode == 'symbol')
|
|
1046
|
+
|
|
1047
|
+
mode_names = {'id': 'Atom ID', 'rdkit_id': 'RDKit Index', 'coords': 'Coordinates', 'symbol': 'Element Symbol'}
|
|
1048
|
+
self.statusBar().showMessage(f"Displaying: {mode_names[mode]}")
|
|
1049
|
+
|
|
1050
|
+
# すべての原子に情報を表示
|
|
1051
|
+
self.show_all_atom_info()
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def is_xyz_derived_molecule(self):
|
|
1056
|
+
"""現在の分子がXYZファイル由来かどうかを判定"""
|
|
1057
|
+
if not self.current_mol:
|
|
1058
|
+
return False
|
|
1059
|
+
try:
|
|
1060
|
+
# 最初の原子がxyz_unique_idプロパティを持っているかチェック
|
|
1061
|
+
if self.current_mol.GetNumAtoms() > 0:
|
|
1062
|
+
return self.current_mol.GetAtomWithIdx(0).HasProp("xyz_unique_id")
|
|
1063
|
+
except Exception:
|
|
1064
|
+
pass
|
|
1065
|
+
return False
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def has_original_atom_ids(self):
|
|
1070
|
+
"""現在の分子がOriginal Atom IDsを持っているかどうかを判定"""
|
|
1071
|
+
if not self.current_mol:
|
|
1072
|
+
return False
|
|
1073
|
+
try:
|
|
1074
|
+
# いずれかの原子が_original_atom_idプロパティを持っているかチェック
|
|
1075
|
+
for atom_idx in range(self.current_mol.GetNumAtoms()):
|
|
1076
|
+
atom = self.current_mol.GetAtomWithIdx(atom_idx)
|
|
1077
|
+
if atom.HasProp("_original_atom_id"):
|
|
1078
|
+
return True
|
|
1079
|
+
except Exception:
|
|
1080
|
+
pass
|
|
1081
|
+
return False
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def update_atom_id_menu_text(self):
|
|
1086
|
+
"""原子IDメニューのテキストを現在の分子の種類に応じて更新"""
|
|
1087
|
+
if hasattr(self, 'show_atom_id_action'):
|
|
1088
|
+
if self.is_xyz_derived_molecule():
|
|
1089
|
+
self.show_atom_id_action.setText("Show XYZ Unique ID")
|
|
1090
|
+
else:
|
|
1091
|
+
self.show_atom_id_action.setText("Show Original ID / Index")
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def update_atom_id_menu_state(self):
|
|
1096
|
+
"""原子IDメニューの有効/無効状態を更新"""
|
|
1097
|
+
if hasattr(self, 'show_atom_id_action'):
|
|
1098
|
+
has_original_ids = self.has_original_atom_ids()
|
|
1099
|
+
has_xyz_ids = self.is_xyz_derived_molecule()
|
|
1100
|
+
|
|
1101
|
+
# Original IDまたはXYZ IDがある場合のみ有効化
|
|
1102
|
+
self.show_atom_id_action.setEnabled(has_original_ids or has_xyz_ids)
|
|
1103
|
+
|
|
1104
|
+
# 現在選択されているモードが無効化される場合は解除
|
|
1105
|
+
if not (has_original_ids or has_xyz_ids) and self.atom_info_display_mode == 'id':
|
|
1106
|
+
self.atom_info_display_mode = None
|
|
1107
|
+
self.show_atom_id_action.setChecked(False)
|
|
1108
|
+
self.clear_all_atom_info_labels()
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def show_all_atom_info(self):
|
|
1114
|
+
"""すべての原子に情報を表示"""
|
|
1115
|
+
if self.atom_info_display_mode is None or not hasattr(self, 'atom_positions_3d') or self.atom_positions_3d is None:
|
|
1116
|
+
return
|
|
1117
|
+
|
|
1118
|
+
# 既存のラベルをクリア
|
|
1119
|
+
self.clear_all_atom_info_labels()
|
|
1120
|
+
|
|
1121
|
+
# ラベルを表示するためにタイプ別に分けてリストを作る
|
|
1122
|
+
rdkit_positions = []
|
|
1123
|
+
rdkit_texts = []
|
|
1124
|
+
id_positions = []
|
|
1125
|
+
id_texts = []
|
|
1126
|
+
xyz_positions = []
|
|
1127
|
+
xyz_texts = []
|
|
1128
|
+
other_positions = []
|
|
1129
|
+
other_texts = []
|
|
1130
|
+
|
|
1131
|
+
for atom_idx, pos in enumerate(self.atom_positions_3d):
|
|
1132
|
+
# default: skip if no display mode
|
|
1133
|
+
if self.atom_info_display_mode is None:
|
|
1134
|
+
continue
|
|
1135
|
+
|
|
1136
|
+
if self.atom_info_display_mode == 'id':
|
|
1137
|
+
# Original IDがある場合は優先表示、なければXYZのユニークID、最後にRDKitインデックス
|
|
1138
|
+
try:
|
|
1139
|
+
if self.current_mol:
|
|
1140
|
+
atom = self.current_mol.GetAtomWithIdx(atom_idx)
|
|
1141
|
+
if atom.HasProp("_original_atom_id"):
|
|
1142
|
+
original_id = atom.GetIntProp("_original_atom_id")
|
|
1143
|
+
# プレフィックスを削除して数値だけ表示
|
|
1144
|
+
id_positions.append(pos)
|
|
1145
|
+
id_texts.append(str(original_id))
|
|
1146
|
+
elif atom.HasProp("xyz_unique_id"):
|
|
1147
|
+
unique_id = atom.GetIntProp("xyz_unique_id")
|
|
1148
|
+
xyz_positions.append(pos)
|
|
1149
|
+
xyz_texts.append(str(unique_id))
|
|
1150
|
+
else:
|
|
1151
|
+
rdkit_positions.append(pos)
|
|
1152
|
+
rdkit_texts.append(str(atom_idx))
|
|
1153
|
+
else:
|
|
1154
|
+
rdkit_positions.append(pos)
|
|
1155
|
+
rdkit_texts.append(str(atom_idx))
|
|
1156
|
+
except Exception:
|
|
1157
|
+
rdkit_positions.append(pos)
|
|
1158
|
+
rdkit_texts.append(str(atom_idx))
|
|
1159
|
+
|
|
1160
|
+
elif self.atom_info_display_mode == 'rdkit_id':
|
|
1161
|
+
rdkit_positions.append(pos)
|
|
1162
|
+
rdkit_texts.append(str(atom_idx))
|
|
1163
|
+
|
|
1164
|
+
elif self.atom_info_display_mode == 'coords':
|
|
1165
|
+
other_positions.append(pos)
|
|
1166
|
+
other_texts.append(f"({pos[0]:.2f},{pos[1]:.2f},{pos[2]:.2f})")
|
|
1167
|
+
|
|
1168
|
+
elif self.atom_info_display_mode == 'symbol':
|
|
1169
|
+
if self.current_mol:
|
|
1170
|
+
symbol = self.current_mol.GetAtomWithIdx(atom_idx).GetSymbol()
|
|
1171
|
+
other_positions.append(pos)
|
|
1172
|
+
other_texts.append(symbol)
|
|
1173
|
+
else:
|
|
1174
|
+
other_positions.append(pos)
|
|
1175
|
+
other_texts.append("?")
|
|
1176
|
+
|
|
1177
|
+
else:
|
|
1178
|
+
continue
|
|
1179
|
+
|
|
1180
|
+
# 色の定義(暗めの青/緑/赤)
|
|
1181
|
+
rdkit_color = '#003366' # 暗めの青
|
|
1182
|
+
id_color = '#006400' # 暗めの緑
|
|
1183
|
+
xyz_color = '#8B0000' # 暗めの赤
|
|
1184
|
+
other_color = 'black'
|
|
1185
|
+
|
|
1186
|
+
# それぞれのグループごとにラベルを追加し、参照をリストで保持する
|
|
1187
|
+
self.current_atom_info_labels = []
|
|
1188
|
+
try:
|
|
1189
|
+
if rdkit_positions:
|
|
1190
|
+
a = self.plotter.add_point_labels(
|
|
1191
|
+
np.array(rdkit_positions), rdkit_texts,
|
|
1192
|
+
point_size=12, font_size=18, text_color=rdkit_color,
|
|
1193
|
+
always_visible=True, tolerance=0.01, show_points=False,
|
|
1194
|
+
name='atom_labels_rdkit'
|
|
1195
|
+
)
|
|
1196
|
+
self.current_atom_info_labels.append(a)
|
|
1197
|
+
|
|
1198
|
+
if id_positions:
|
|
1199
|
+
a = self.plotter.add_point_labels(
|
|
1200
|
+
np.array(id_positions), id_texts,
|
|
1201
|
+
point_size=12, font_size=18, text_color=id_color,
|
|
1202
|
+
always_visible=True, tolerance=0.01, show_points=False,
|
|
1203
|
+
name='atom_labels_id'
|
|
1204
|
+
)
|
|
1205
|
+
self.current_atom_info_labels.append(a)
|
|
1206
|
+
|
|
1207
|
+
if xyz_positions:
|
|
1208
|
+
a = self.plotter.add_point_labels(
|
|
1209
|
+
np.array(xyz_positions), xyz_texts,
|
|
1210
|
+
point_size=12, font_size=18, text_color=xyz_color,
|
|
1211
|
+
always_visible=True, tolerance=0.01, show_points=False,
|
|
1212
|
+
name='atom_labels_xyz'
|
|
1213
|
+
)
|
|
1214
|
+
self.current_atom_info_labels.append(a)
|
|
1215
|
+
|
|
1216
|
+
if other_positions:
|
|
1217
|
+
a = self.plotter.add_point_labels(
|
|
1218
|
+
np.array(other_positions), other_texts,
|
|
1219
|
+
point_size=12, font_size=18, text_color=other_color,
|
|
1220
|
+
always_visible=True, tolerance=0.01, show_points=False,
|
|
1221
|
+
name='atom_labels_other'
|
|
1222
|
+
)
|
|
1223
|
+
self.current_atom_info_labels.append(a)
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
print(f"Error adding atom info labels: {e}")
|
|
1226
|
+
|
|
1227
|
+
# 右上に凡例を表示(既存の凡例は消す)
|
|
1228
|
+
try:
|
|
1229
|
+
# 古い凡例削除
|
|
1230
|
+
if hasattr(self, 'atom_label_legend_names') and self.atom_label_legend_names:
|
|
1231
|
+
for nm in self.atom_label_legend_names:
|
|
1232
|
+
try:
|
|
1233
|
+
self.plotter.remove_actor(nm)
|
|
1234
|
+
except Exception:
|
|
1235
|
+
pass
|
|
1236
|
+
self.atom_label_legend_names = []
|
|
1237
|
+
|
|
1238
|
+
# 凡例テキストを右上に縦並びで追加(背景なし、太字のみ)
|
|
1239
|
+
legend_entries = []
|
|
1240
|
+
if rdkit_positions:
|
|
1241
|
+
legend_entries.append(('RDKit', rdkit_color, 'legend_rdkit'))
|
|
1242
|
+
if id_positions:
|
|
1243
|
+
legend_entries.append(('ID', id_color, 'legend_id'))
|
|
1244
|
+
if xyz_positions:
|
|
1245
|
+
legend_entries.append(('XYZ', xyz_color, 'legend_xyz'))
|
|
1246
|
+
# Do not show 'Other' in the legend per UI requirement
|
|
1247
|
+
# (other_positions are still labeled in-scene but not listed in the legend)
|
|
1248
|
+
|
|
1249
|
+
# 左下に凡例ラベルを追加(背景なし、太字のみ)
|
|
1250
|
+
# Increase spacing to avoid overlapping when short labels like 'RDKit' and 'ID' appear
|
|
1251
|
+
spacing = 30
|
|
1252
|
+
for i, (label_text, label_color, label_name) in enumerate(legend_entries):
|
|
1253
|
+
# 左下基準でy座標を上げる
|
|
1254
|
+
# Add a small horizontal offset for very short adjacent labels so they don't visually collide
|
|
1255
|
+
y = 0.0 + i * spacing
|
|
1256
|
+
x_offset = 0.0
|
|
1257
|
+
# If both RDKit and ID are present, nudge the second entry slightly to the right to avoid overlap
|
|
1258
|
+
try:
|
|
1259
|
+
if label_text == 'ID' and any(e[0] == 'RDKit' for e in legend_entries):
|
|
1260
|
+
x_offset = 0.06
|
|
1261
|
+
except Exception:
|
|
1262
|
+
x_offset = 0.0
|
|
1263
|
+
try:
|
|
1264
|
+
actor = self.plotter.add_text(
|
|
1265
|
+
label_text,
|
|
1266
|
+
position=(0.0 + x_offset, y),
|
|
1267
|
+
font_size=12,
|
|
1268
|
+
color=label_color,
|
|
1269
|
+
name=label_name,
|
|
1270
|
+
font='arial'
|
|
1271
|
+
)
|
|
1272
|
+
self.atom_label_legend_names.append(label_name)
|
|
1273
|
+
# 太字のみ設定(背景は設定しない)
|
|
1274
|
+
try:
|
|
1275
|
+
if hasattr(actor, 'GetTextProperty'):
|
|
1276
|
+
tp = actor.GetTextProperty()
|
|
1277
|
+
try:
|
|
1278
|
+
tp.SetBold(True)
|
|
1279
|
+
except Exception:
|
|
1280
|
+
pass
|
|
1281
|
+
except Exception:
|
|
1282
|
+
pass
|
|
1283
|
+
except Exception:
|
|
1284
|
+
continue
|
|
1285
|
+
|
|
1286
|
+
except Exception:
|
|
1287
|
+
pass
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def clear_all_atom_info_labels(self):
|
|
1292
|
+
"""すべての原子情報ラベルをクリア"""
|
|
1293
|
+
# Remove label actors (may be a single actor, a list, or None)
|
|
1294
|
+
try:
|
|
1295
|
+
if hasattr(self, 'current_atom_info_labels') and self.current_atom_info_labels:
|
|
1296
|
+
if isinstance(self.current_atom_info_labels, (list, tuple)):
|
|
1297
|
+
for a in list(self.current_atom_info_labels):
|
|
1298
|
+
try:
|
|
1299
|
+
self.plotter.remove_actor(a)
|
|
1300
|
+
except Exception:
|
|
1301
|
+
pass
|
|
1302
|
+
else:
|
|
1303
|
+
try:
|
|
1304
|
+
self.plotter.remove_actor(self.current_atom_info_labels)
|
|
1305
|
+
except Exception:
|
|
1306
|
+
pass
|
|
1307
|
+
except Exception:
|
|
1308
|
+
pass
|
|
1309
|
+
finally:
|
|
1310
|
+
self.current_atom_info_labels = None
|
|
1311
|
+
|
|
1312
|
+
# Remove legend text actors if present
|
|
1313
|
+
try:
|
|
1314
|
+
if hasattr(self, 'atom_label_legend_names') and self.atom_label_legend_names:
|
|
1315
|
+
for nm in list(self.atom_label_legend_names):
|
|
1316
|
+
try:
|
|
1317
|
+
self.plotter.remove_actor(nm)
|
|
1318
|
+
except Exception:
|
|
1319
|
+
pass
|
|
1320
|
+
except Exception:
|
|
1321
|
+
pass
|
|
1322
|
+
finally:
|
|
1323
|
+
self.atom_label_legend_names = []
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def setup_3d_hover(self):
|
|
1328
|
+
"""3Dビューでの表示を設定(常時表示に変更)"""
|
|
1329
|
+
if self.atom_info_display_mode is not None:
|
|
1330
|
+
self.show_all_atom_info()
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def zoom_in(self):
|
|
1335
|
+
""" ビューを 20% 拡大する """
|
|
1336
|
+
self.view_2d.scale(1.2, 1.2)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def zoom_out(self):
|
|
1341
|
+
""" ビューを 20% 縮小する """
|
|
1342
|
+
self.view_2d.scale(1/1.2, 1/1.2)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def reset_zoom(self):
|
|
1347
|
+
""" ビューの拡大率をデフォルト (75%) にリセットする """
|
|
1348
|
+
transform = QTransform()
|
|
1349
|
+
transform.scale(0.75, 0.75)
|
|
1350
|
+
self.view_2d.setTransform(transform)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def fit_to_view(self):
|
|
1355
|
+
""" シーン上のすべてのアイテムがビューに収まるように調整する """
|
|
1356
|
+
if not self.scene.items():
|
|
1357
|
+
self.reset_zoom()
|
|
1358
|
+
return
|
|
1359
|
+
|
|
1360
|
+
# 合計の表示矩形(目に見えるアイテムのみ)を計算
|
|
1361
|
+
visible_items_rect = QRectF()
|
|
1362
|
+
for item in self.scene.items():
|
|
1363
|
+
if item.isVisible() and not isinstance(item, TemplatePreviewItem):
|
|
1364
|
+
if visible_items_rect.isEmpty():
|
|
1365
|
+
visible_items_rect = item.sceneBoundingRect()
|
|
1366
|
+
else:
|
|
1367
|
+
visible_items_rect = visible_items_rect.united(item.sceneBoundingRect())
|
|
1368
|
+
|
|
1369
|
+
if visible_items_rect.isEmpty():
|
|
1370
|
+
self.reset_zoom()
|
|
1371
|
+
return
|
|
1372
|
+
|
|
1373
|
+
# 少し余白を持たせる(パディング)
|
|
1374
|
+
padding_factor = 1.10 # 10% の余裕
|
|
1375
|
+
cx = visible_items_rect.center().x()
|
|
1376
|
+
cy = visible_items_rect.center().y()
|
|
1377
|
+
w = visible_items_rect.width() * padding_factor
|
|
1378
|
+
h = visible_items_rect.height() * padding_factor
|
|
1379
|
+
padded = QRectF(cx - w / 2.0, cy - h / 2.0, w, h)
|
|
1380
|
+
|
|
1381
|
+
# フィット時にマウス位置に依存するアンカーが原因でジャンプすることがあるため
|
|
1382
|
+
# 一時的にトランスフォームアンカーをビュー中心にしてから fitInView を呼ぶ
|
|
1383
|
+
try:
|
|
1384
|
+
old_ta = self.view_2d.transformationAnchor()
|
|
1385
|
+
old_ra = self.view_2d.resizeAnchor()
|
|
1386
|
+
except Exception:
|
|
1387
|
+
old_ta = old_ra = None
|
|
1388
|
+
|
|
1389
|
+
try:
|
|
1390
|
+
self.view_2d.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
1391
|
+
self.view_2d.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
1392
|
+
self.view_2d.fitInView(padded, Qt.AspectRatioMode.KeepAspectRatio)
|
|
1393
|
+
finally:
|
|
1394
|
+
# 元のアンカーを復元
|
|
1395
|
+
try:
|
|
1396
|
+
if old_ta is not None:
|
|
1397
|
+
self.view_2d.setTransformationAnchor(old_ta)
|
|
1398
|
+
if old_ra is not None:
|
|
1399
|
+
self.view_2d.setResizeAnchor(old_ra)
|
|
1400
|
+
except Exception:
|
|
1401
|
+
pass
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def update_cpk_colors_from_settings(self):
|
|
1406
|
+
"""Update global CPK_COLORS and CPK_COLORS_PV from saved settings overrides.
|
|
1407
|
+
|
|
1408
|
+
This modifies the in-memory CPK_COLORS mapping (not persisted until settings are saved).
|
|
1409
|
+
Only keys present in self.settings['cpk_colors'] are changed; other elements keep the defaults.
|
|
1410
|
+
"""
|
|
1411
|
+
try:
|
|
1412
|
+
# Overridden CPK settings are stored in self.settings['cpk_colors'].
|
|
1413
|
+
# To ensure that 2D modules (e.g., atom_item.py) which imported the
|
|
1414
|
+
# `CPK_COLORS` mapping from `modules.constants` at import time see
|
|
1415
|
+
# updates, mutate the mapping in-place on the constants module
|
|
1416
|
+
# instead of rebinding a new local variable here.
|
|
1417
|
+
overrides = self.settings.get('cpk_colors', {}) or {}
|
|
1418
|
+
|
|
1419
|
+
# Import the constants module so we can update mappings directly
|
|
1420
|
+
try:
|
|
1421
|
+
from . import constants as constants_mod
|
|
1422
|
+
except Exception:
|
|
1423
|
+
import modules.constants as constants_mod
|
|
1424
|
+
|
|
1425
|
+
# Reset constants.CPK_COLORS to defaults but keep the same dict
|
|
1426
|
+
constants_mod.CPK_COLORS.clear()
|
|
1427
|
+
for k, v in DEFAULT_CPK_COLORS.items():
|
|
1428
|
+
constants_mod.CPK_COLORS[k] = QColor(v) if not isinstance(v, QColor) else v
|
|
1429
|
+
|
|
1430
|
+
# Apply overrides from settings
|
|
1431
|
+
for k, hexv in overrides.items():
|
|
1432
|
+
if isinstance(hexv, str) and hexv:
|
|
1433
|
+
constants_mod.CPK_COLORS[k] = QColor(hexv)
|
|
1434
|
+
|
|
1435
|
+
# Rebuild the PV representation in-place too
|
|
1436
|
+
constants_mod.CPK_COLORS_PV.clear()
|
|
1437
|
+
for k, c in constants_mod.CPK_COLORS.items():
|
|
1438
|
+
constants_mod.CPK_COLORS_PV[k] = [c.redF(), c.greenF(), c.blueF()]
|
|
1439
|
+
except Exception as e:
|
|
1440
|
+
print(f"Failed to update CPK colors from settings: {e}")
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def apply_3d_settings(self, redraw=True):
|
|
1446
|
+
# Projection mode
|
|
1447
|
+
proj_mode = self.settings.get('projection_mode', 'Perspective')
|
|
1448
|
+
if hasattr(self.plotter, 'renderer') and hasattr(self.plotter.renderer, 'GetActiveCamera'):
|
|
1449
|
+
cam = self.plotter.renderer.GetActiveCamera()
|
|
1450
|
+
if cam:
|
|
1451
|
+
if proj_mode == 'Orthographic':
|
|
1452
|
+
cam.SetParallelProjection(True)
|
|
1453
|
+
else:
|
|
1454
|
+
cam.SetParallelProjection(False)
|
|
1455
|
+
"""3Dビューの視覚設定を適用する"""
|
|
1456
|
+
if not hasattr(self, 'plotter'):
|
|
1457
|
+
return
|
|
1458
|
+
|
|
1459
|
+
# レンダラーのレイヤー設定を有効化(テキストオーバーレイ用)
|
|
1460
|
+
renderer = self.plotter.renderer
|
|
1461
|
+
if renderer and hasattr(renderer, 'SetNumberOfLayers'):
|
|
1462
|
+
try:
|
|
1463
|
+
renderer.SetNumberOfLayers(2) # レイヤー0:3Dオブジェクト、レイヤー1:2Dオーバーレイ
|
|
1464
|
+
except Exception:
|
|
1465
|
+
pass # PyVistaのバージョンによってはサポートされていない場合がある
|
|
1466
|
+
|
|
1467
|
+
# --- 3D軸ウィジェットの設定 ---
|
|
1468
|
+
show_axes = self.settings.get('show_3d_axes', True)
|
|
1469
|
+
|
|
1470
|
+
# ウィジェットがまだ作成されていない場合は作成する
|
|
1471
|
+
if self.axes_widget is None and hasattr(self.plotter, 'interactor'):
|
|
1472
|
+
axes = vtk.vtkAxesActor()
|
|
1473
|
+
self.axes_widget = vtk.vtkOrientationMarkerWidget()
|
|
1474
|
+
self.axes_widget.SetOrientationMarker(axes)
|
|
1475
|
+
self.axes_widget.SetInteractor(self.plotter.interactor)
|
|
1476
|
+
# 左下隅に設定 (幅・高さ20%)
|
|
1477
|
+
self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2)
|
|
1478
|
+
|
|
1479
|
+
# 設定に応じてウィジェットを有効化/無効化
|
|
1480
|
+
if self.axes_widget:
|
|
1481
|
+
if show_axes:
|
|
1482
|
+
self.axes_widget.On()
|
|
1483
|
+
self.axes_widget.SetInteractive(False)
|
|
1484
|
+
else:
|
|
1485
|
+
self.axes_widget.Off()
|
|
1486
|
+
|
|
1487
|
+
if redraw:
|
|
1488
|
+
self.draw_molecule_3d(self.current_mol)
|
|
1489
|
+
|
|
1490
|
+
# 設定変更時にカメラ位置をリセットしない(初回のみリセット)
|
|
1491
|
+
if not getattr(self, '_camera_initialized', False):
|
|
1492
|
+
try:
|
|
1493
|
+
self.plotter.reset_camera()
|
|
1494
|
+
except Exception:
|
|
1495
|
+
pass
|
|
1496
|
+
self._camera_initialized = True
|
|
1497
|
+
|
|
1498
|
+
# 強制的にプロッターを更新
|
|
1499
|
+
try:
|
|
1500
|
+
self.plotter.render()
|
|
1501
|
+
if hasattr(self.plotter, 'update'):
|
|
1502
|
+
self.plotter.update()
|
|
1503
|
+
except Exception:
|
|
1504
|
+
pass
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def update_bond_color_override(self, bond_idx, hex_color):
|
|
1510
|
+
"""Plugin API helper to override bond color."""
|
|
1511
|
+
if not hasattr(self, '_plugin_bond_color_overrides'):
|
|
1512
|
+
self._plugin_bond_color_overrides = {}
|
|
1513
|
+
|
|
1514
|
+
if hex_color is None:
|
|
1515
|
+
if bond_idx in self._plugin_bond_color_overrides:
|
|
1516
|
+
del self._plugin_bond_color_overrides[bond_idx]
|
|
1517
|
+
else:
|
|
1518
|
+
self._plugin_bond_color_overrides[bond_idx] = hex_color
|
|
1519
|
+
|
|
1520
|
+
if self.current_mol:
|
|
1521
|
+
self.draw_molecule_3d(self.current_mol)
|
|
1522
|
+
|
|
1523
|
+
def update_atom_color_override(self, atom_index, color_hex):
|
|
1524
|
+
"""Plugin helper to update specific atom color override."""
|
|
1525
|
+
if not hasattr(self, '_plugin_color_overrides'):
|
|
1526
|
+
self._plugin_color_overrides = {}
|
|
1527
|
+
|
|
1528
|
+
if color_hex is None:
|
|
1529
|
+
if atom_index in self._plugin_color_overrides:
|
|
1530
|
+
del self._plugin_color_overrides[atom_index]
|
|
1531
|
+
else:
|
|
1532
|
+
self._plugin_color_overrides[atom_index] = color_hex
|
|
1533
|
+
|
|
1534
|
+
if self.current_mol:
|
|
1535
|
+
self.draw_molecule_3d(self.current_mol)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
|