MoleditPy 1.15.1__py3-none-any.whl → 1.16.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. moleditpy/__init__.py +4 -0
  2. moleditpy/__main__.py +29 -19748
  3. moleditpy/main.py +40 -0
  4. moleditpy/modules/__init__.py +36 -0
  5. moleditpy/modules/about_dialog.py +92 -0
  6. moleditpy/modules/align_plane_dialog.py +281 -0
  7. moleditpy/modules/alignment_dialog.py +261 -0
  8. moleditpy/modules/analysis_window.py +197 -0
  9. moleditpy/modules/angle_dialog.py +428 -0
  10. moleditpy/modules/assets/icon.icns +0 -0
  11. moleditpy/modules/atom_item.py +336 -0
  12. moleditpy/modules/bond_item.py +303 -0
  13. moleditpy/modules/bond_length_dialog.py +368 -0
  14. moleditpy/modules/calculation_worker.py +754 -0
  15. moleditpy/modules/color_settings_dialog.py +309 -0
  16. moleditpy/modules/constants.py +76 -0
  17. moleditpy/modules/constrained_optimization_dialog.py +667 -0
  18. moleditpy/modules/custom_interactor_style.py +737 -0
  19. moleditpy/modules/custom_qt_interactor.py +49 -0
  20. moleditpy/modules/dialog3_d_picking_mixin.py +96 -0
  21. moleditpy/modules/dihedral_dialog.py +431 -0
  22. moleditpy/modules/main_window.py +827 -0
  23. moleditpy/modules/main_window_app_state.py +709 -0
  24. moleditpy/modules/main_window_compute.py +1203 -0
  25. moleditpy/modules/main_window_dialog_manager.py +454 -0
  26. moleditpy/modules/main_window_edit_3d.py +531 -0
  27. moleditpy/modules/main_window_edit_actions.py +1449 -0
  28. moleditpy/modules/main_window_export.py +744 -0
  29. moleditpy/modules/main_window_main_init.py +1560 -0
  30. moleditpy/modules/main_window_molecular_parsers.py +956 -0
  31. moleditpy/modules/main_window_project_io.py +416 -0
  32. moleditpy/modules/main_window_string_importers.py +270 -0
  33. moleditpy/modules/main_window_ui_manager.py +567 -0
  34. moleditpy/modules/main_window_view_3d.py +1163 -0
  35. moleditpy/modules/main_window_view_loaders.py +350 -0
  36. moleditpy/modules/mirror_dialog.py +110 -0
  37. moleditpy/modules/molecular_data.py +290 -0
  38. moleditpy/modules/molecule_scene.py +1895 -0
  39. moleditpy/modules/move_group_dialog.py +586 -0
  40. moleditpy/modules/periodic_table_dialog.py +72 -0
  41. moleditpy/modules/planarize_dialog.py +209 -0
  42. moleditpy/modules/settings_dialog.py +1034 -0
  43. moleditpy/modules/template_preview_item.py +148 -0
  44. moleditpy/modules/template_preview_view.py +62 -0
  45. moleditpy/modules/translation_dialog.py +353 -0
  46. moleditpy/modules/user_template_dialog.py +621 -0
  47. moleditpy/modules/zoomable_view.py +98 -0
  48. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0a1.dist-info}/METADATA +1 -1
  49. moleditpy-1.16.0a1.dist-info/RECORD +54 -0
  50. moleditpy-1.15.1.dist-info/RECORD +0 -9
  51. /moleditpy/{assets → modules/assets}/icon.ico +0 -0
  52. /moleditpy/{assets → modules/assets}/icon.png +0 -0
  53. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0a1.dist-info}/WHEEL +0 -0
  54. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0a1.dist-info}/entry_points.txt +0 -0
  55. {moleditpy-1.15.1.dist-info → moleditpy-1.16.0a1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1163 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ main_window_view_3d.py
6
+ MainWindow (main_window.py) から分離されたモジュール
7
+ 機能クラス: MainWindowView3d
8
+ """
9
+
10
+
11
+ import numpy as np
12
+ import vtk
13
+
14
+
15
+ # RDKit imports (explicit to satisfy flake8 and used features)
16
+ from rdkit import Chem
17
+ try:
18
+ pass
19
+ except Exception:
20
+ pass
21
+
22
+ # PyQt6 Modules
23
+ from PyQt6.QtWidgets import (
24
+ QApplication, QGraphicsView
25
+ )
26
+
27
+ from PyQt6.QtGui import (
28
+ QColor, QTransform
29
+ )
30
+
31
+
32
+ from PyQt6.QtCore import (
33
+ Qt, QRectF
34
+ )
35
+
36
+ import pyvista as pv
37
+
38
+ # Use centralized Open Babel availability from package-level __init__
39
+ # Use per-package modules availability (local __init__).
40
+ try:
41
+ from . import OBABEL_AVAILABLE
42
+ except Exception:
43
+ from modules import OBABEL_AVAILABLE
44
+ # Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
45
+ if OBABEL_AVAILABLE:
46
+ try:
47
+ from openbabel import pybel
48
+ except Exception:
49
+ # If import fails here, disable OBABEL locally; avoid raising
50
+ pybel = None
51
+ OBABEL_AVAILABLE = False
52
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
53
+ else:
54
+ pybel = None
55
+
56
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
57
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
58
+ # it once at module import time and expose a small, robust wrapper so callers
59
+ # can avoid re-importing sip repeatedly and so we centralize exception
60
+ # handling (this reduces crash risk during teardown and deletion operations).
61
+ try:
62
+ import sip as _sip # type: ignore
63
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
64
+ except Exception:
65
+ _sip = None
66
+ _sip_isdeleted = None
67
+
68
+ try:
69
+ # package relative imports (preferred when running as `python -m moleditpy`)
70
+ from .constants import CPK_COLORS_PV, DEFAULT_CPK_COLORS, VDW_RADII, pt
71
+ from .template_preview_item import TemplatePreviewItem
72
+ except Exception:
73
+ # Fallback to absolute imports for script-style execution
74
+ from modules.constants import CPK_COLORS_PV, DEFAULT_CPK_COLORS, VDW_RADII, pt
75
+ from modules.template_preview_item import TemplatePreviewItem
76
+
77
+ # --- クラス定義 ---
78
+ class MainWindowView3d(object):
79
+ """ main_window.py から分離された機能クラス """
80
+
81
+ def __init__(self, main_window):
82
+ """ クラスの初期化 """
83
+ self.mw = main_window
84
+
85
+
86
+ def set_3d_style(self, style_name):
87
+ """3D表示スタイルを設定し、ビューを更新する"""
88
+ if self.current_3d_style == style_name:
89
+ return
90
+
91
+ # 描画モード変更時に測定モードと3D編集モードをリセット
92
+ if self.measurement_mode:
93
+ self.measurement_action.setChecked(False)
94
+ self.toggle_measurement_mode(False) # 測定モードを無効化
95
+
96
+ if self.is_3d_edit_mode:
97
+ self.edit_3d_action.setChecked(False)
98
+ self.toggle_3d_edit_mode(False) # 3D編集モードを無効化
99
+
100
+ # 3D原子選択をクリア
101
+ self.clear_3d_selection()
102
+
103
+ self.current_3d_style = style_name
104
+ self.statusBar().showMessage(f"3D style set to: {style_name}")
105
+
106
+ # 現在表示中の分子があれば、新しいスタイルで再描画する
107
+ if self.current_mol:
108
+ self.draw_molecule_3d(self.current_mol)
109
+
110
+
111
+
112
+ def draw_molecule_3d(self, mol):
113
+ """3D 分子を描画し、軸アクターの参照をクリアする(軸の再制御は apply_3d_settings に任せる)"""
114
+
115
+ # 測定選択をクリア(分子が変更されたため)
116
+ if hasattr(self, 'measurement_mode'):
117
+ self.clear_measurement_selection()
118
+
119
+ # 色情報追跡のための辞書を初期化
120
+ if not hasattr(self, '_3d_color_map'):
121
+ self._3d_color_map = {}
122
+ self._3d_color_map.clear()
123
+
124
+ # 1. カメラ状態とクリア
125
+ camera_state = self.plotter.camera.copy()
126
+
127
+ # **残留防止のための強制削除**
128
+ if self.axes_actor is not None:
129
+ try:
130
+ self.plotter.remove_actor(self.axes_actor)
131
+ except Exception:
132
+ pass
133
+ self.axes_actor = None
134
+
135
+ self.plotter.clear()
136
+
137
+ # 2. 背景色の設定
138
+ self.plotter.set_background(self.settings.get('background_color', '#4f4f4f'))
139
+
140
+ # 3. mol が None または原子数ゼロの場合は、背景と軸のみで終了
141
+ if mol is None or mol.GetNumAtoms() == 0:
142
+ self.atom_actor = None
143
+ self.current_mol = None
144
+ self.plotter.render()
145
+ return
146
+
147
+ # 4. ライティングの設定
148
+ is_lighting_enabled = self.settings.get('lighting_enabled', True)
149
+
150
+ if is_lighting_enabled:
151
+ light = pv.Light(
152
+ position=(1, 1, 2),
153
+ light_type='cameralight',
154
+ intensity=self.settings.get('light_intensity', 1.2)
155
+ )
156
+ self.plotter.add_light(light)
157
+
158
+ # 5. 分子描画ロジック
159
+ conf = mol.GetConformer()
160
+
161
+ self.atom_positions_3d = np.array([list(conf.GetAtomPosition(i)) for i in range(mol.GetNumAtoms())])
162
+
163
+ sym = [a.GetSymbol() for a in mol.GetAtoms()]
164
+ col = np.array([CPK_COLORS_PV.get(s, [0.5, 0.5, 0.5]) for s in sym])
165
+
166
+ # スタイルに応じて原子の半径を設定(設定から読み込み)
167
+ if self.current_3d_style == 'cpk':
168
+ atom_scale = self.settings.get('cpk_atom_scale', 1.0)
169
+ resolution = self.settings.get('cpk_resolution', 32)
170
+ rad = np.array([pt.GetRvdw(pt.GetAtomicNumber(s)) * atom_scale for s in sym])
171
+ elif self.current_3d_style == 'wireframe':
172
+ # Wireframeでは原子を描画しないので、この設定は実際には使用されない
173
+ resolution = self.settings.get('wireframe_resolution', 6)
174
+ rad = np.array([0.01 for s in sym]) # 極小値(使用されない)
175
+ elif self.current_3d_style == 'stick':
176
+ atom_radius = self.settings.get('stick_atom_radius', 0.15)
177
+ resolution = self.settings.get('stick_resolution', 16)
178
+ rad = np.array([atom_radius for s in sym])
179
+ else: # ball_and_stick
180
+ atom_scale = self.settings.get('ball_stick_atom_scale', 1.0)
181
+ resolution = self.settings.get('ball_stick_resolution', 16)
182
+ rad = np.array([VDW_RADII.get(s, 0.4) * atom_scale for s in sym])
183
+
184
+ self.glyph_source = pv.PolyData(self.atom_positions_3d)
185
+ self.glyph_source['colors'] = col
186
+ self.glyph_source['radii'] = rad
187
+
188
+ # メッシュプロパティを共通で定義
189
+ mesh_props = dict(
190
+ smooth_shading=True,
191
+ specular=self.settings.get('specular', 0.2),
192
+ specular_power=self.settings.get('specular_power', 20),
193
+ lighting=is_lighting_enabled,
194
+ )
195
+
196
+ # Wireframeスタイルの場合は原子を描画しない
197
+ if self.current_3d_style != 'wireframe':
198
+ glyphs = self.glyph_source.glyph(scale='radii', geom=pv.Sphere(radius=1.0, theta_resolution=resolution, phi_resolution=resolution), orient=False)
199
+
200
+ if is_lighting_enabled:
201
+ self.atom_actor = self.plotter.add_mesh(glyphs, scalars='colors', rgb=True, **mesh_props)
202
+ else:
203
+ self.atom_actor = self.plotter.add_mesh(
204
+ glyphs, scalars='colors', rgb=True,
205
+ style='surface', show_edges=True, edge_color='grey',
206
+ **mesh_props
207
+ )
208
+ self.atom_actor.GetProperty().SetEdgeOpacity(0.3)
209
+
210
+ # 原子の色情報を記録
211
+ for i, atom_color in enumerate(col):
212
+ atom_rgb = [int(c * 255) for c in atom_color]
213
+ self._3d_color_map[f'atom_{i}'] = atom_rgb
214
+
215
+
216
+ # ボンドの描画(ball_and_stick、wireframe、stickで描画)
217
+ if self.current_3d_style in ['ball_and_stick', 'wireframe', 'stick']:
218
+ # スタイルに応じてボンドの太さと解像度を設定(設定から読み込み)
219
+ if self.current_3d_style == 'wireframe':
220
+ cyl_radius = self.settings.get('wireframe_bond_radius', 0.01)
221
+ bond_resolution = self.settings.get('wireframe_resolution', 6)
222
+ elif self.current_3d_style == 'stick':
223
+ cyl_radius = self.settings.get('stick_bond_radius', 0.15)
224
+ bond_resolution = self.settings.get('stick_resolution', 16)
225
+ else: # ball_and_stick
226
+ cyl_radius = self.settings.get('ball_stick_bond_radius', 0.1)
227
+ bond_resolution = self.settings.get('ball_stick_resolution', 16)
228
+
229
+ bond_counter = 0 # 結合の個別識別用
230
+
231
+ # Ball and Stick用のシリンダーリストを準備(高速化のため)
232
+ if self.current_3d_style == 'ball_and_stick':
233
+ bond_cylinders = []
234
+ # Compute the configured grey/uniform bond color for Ball & Stick
235
+ try:
236
+ bs_hex = self.settings.get('ball_stick_bond_color', '#7F7F7F')
237
+ q = QColor(bs_hex)
238
+ bs_bond_rgb = [q.red(), q.green(), q.blue()]
239
+ except Exception:
240
+ bs_bond_rgb = [127, 127, 127]
241
+
242
+ for bond in mol.GetBonds():
243
+ begin_atom_idx = bond.GetBeginAtomIdx()
244
+ end_atom_idx = bond.GetEndAtomIdx()
245
+ sp = np.array(conf.GetAtomPosition(begin_atom_idx))
246
+ ep = np.array(conf.GetAtomPosition(end_atom_idx))
247
+ bt = bond.GetBondType()
248
+ c = (sp + ep) / 2
249
+ d = ep - sp
250
+ h = np.linalg.norm(d)
251
+ if h == 0: continue
252
+
253
+ # ボンドの色を原子の色から決定(各半分で異なる色)
254
+ begin_color = col[begin_atom_idx]
255
+ end_color = col[end_atom_idx]
256
+
257
+ # 結合の色情報を記録
258
+ begin_color_rgb = [int(c * 255) for c in begin_color]
259
+ end_color_rgb = [int(c * 255) for c in end_color]
260
+
261
+ # UI応答性維持のためイベント処理
262
+ QApplication.processEvents()
263
+ if bt == Chem.rdchem.BondType.SINGLE or bt == Chem.rdchem.BondType.AROMATIC:
264
+ if self.current_3d_style == 'ball_and_stick':
265
+ # Ball and stickは全結合をまとめて処理(高速化)
266
+ cyl = pv.Cylinder(center=c, direction=d, radius=cyl_radius, height=h, resolution=bond_resolution)
267
+ bond_cylinders.append(cyl)
268
+ self._3d_color_map[f'bond_{bond_counter}'] = bs_bond_rgb # グレー (configurable)
269
+ else:
270
+ # その他(stick, wireframe)は中央で色が変わる2つの円柱
271
+ mid_point = (sp + ep) / 2
272
+
273
+ # 前半(開始原子の色)
274
+ cyl1 = pv.Cylinder(center=(sp + mid_point) / 2, direction=d, radius=cyl_radius, height=h/2, resolution=bond_resolution)
275
+ actor1 = self.plotter.add_mesh(cyl1, color=begin_color, **mesh_props)
276
+ self._3d_color_map[f'bond_{bond_counter}_start'] = begin_color_rgb
277
+
278
+ # 後半(終了原子の色)
279
+ cyl2 = pv.Cylinder(center=(mid_point + ep) / 2, direction=d, radius=cyl_radius, height=h/2, resolution=bond_resolution)
280
+ actor2 = self.plotter.add_mesh(cyl2, color=end_color, **mesh_props)
281
+ self._3d_color_map[f'bond_{bond_counter}_end'] = end_color_rgb
282
+ else:
283
+ v1 = d / h
284
+ # モデルごとの半径ファクターを適用
285
+ if self.current_3d_style == 'ball_and_stick':
286
+ double_radius_factor = self.settings.get('ball_stick_double_bond_radius_factor', 0.8)
287
+ triple_radius_factor = self.settings.get('ball_stick_triple_bond_radius_factor', 0.75)
288
+ elif self.current_3d_style == 'wireframe':
289
+ double_radius_factor = self.settings.get('wireframe_double_bond_radius_factor', 1.0)
290
+ triple_radius_factor = self.settings.get('wireframe_triple_bond_radius_factor', 0.75)
291
+ elif self.current_3d_style == 'stick':
292
+ double_radius_factor = self.settings.get('stick_double_bond_radius_factor', 0.60)
293
+ triple_radius_factor = self.settings.get('stick_triple_bond_radius_factor', 0.40)
294
+ else:
295
+ double_radius_factor = 1.0
296
+ triple_radius_factor = 0.75
297
+ r = cyl_radius * 0.8 # fallback, will be overridden below
298
+ # 設定からオフセットファクターを取得(モデルごと)
299
+ if self.current_3d_style == 'ball_and_stick':
300
+ double_offset_factor = self.settings.get('ball_stick_double_bond_offset_factor', 2.0)
301
+ triple_offset_factor = self.settings.get('ball_stick_triple_bond_offset_factor', 2.0)
302
+ elif self.current_3d_style == 'wireframe':
303
+ double_offset_factor = self.settings.get('wireframe_double_bond_offset_factor', 3.0)
304
+ triple_offset_factor = self.settings.get('wireframe_triple_bond_offset_factor', 3.0)
305
+ elif self.current_3d_style == 'stick':
306
+ double_offset_factor = self.settings.get('stick_double_bond_offset_factor', 1.5)
307
+ triple_offset_factor = self.settings.get('stick_triple_bond_offset_factor', 1.0)
308
+ else:
309
+ double_offset_factor = 2.0
310
+ triple_offset_factor = 2.0
311
+ s = cyl_radius * 2.0 # デフォルト値
312
+
313
+ if bt == Chem.rdchem.BondType.DOUBLE:
314
+ r = cyl_radius * double_radius_factor
315
+ # 二重結合の場合、結合している原子の他の結合を考慮してオフセット方向を決定
316
+ off_dir = self._calculate_double_bond_offset(mol, bond, conf)
317
+ # 設定から二重結合のオフセットファクターを適用
318
+ s_double = cyl_radius * double_offset_factor
319
+ c1, c2 = c + off_dir * (s_double / 2), c - off_dir * (s_double / 2)
320
+
321
+ if self.current_3d_style == 'ball_and_stick':
322
+ # Ball and stickは全結合をまとめて処理(高速化)
323
+ cyl1 = pv.Cylinder(center=c1, direction=d, radius=r, height=h, resolution=bond_resolution)
324
+ cyl2 = pv.Cylinder(center=c2, direction=d, radius=r, height=h, resolution=bond_resolution)
325
+ bond_cylinders.extend([cyl1, cyl2])
326
+ self._3d_color_map[f'bond_{bond_counter}_1'] = bs_bond_rgb
327
+ self._3d_color_map[f'bond_{bond_counter}_2'] = bs_bond_rgb
328
+ else:
329
+ # その他(stick, wireframe)は中央で色が変わる
330
+ mid_point = (sp + ep) / 2
331
+
332
+ # 第一の結合線(前半・後半)
333
+ cyl1_1 = pv.Cylinder(center=(sp + mid_point) / 2 + off_dir * (s_double / 2), direction=d, radius=r, height=h/2, resolution=bond_resolution)
334
+ cyl1_2 = pv.Cylinder(center=(mid_point + ep) / 2 + off_dir * (s_double / 2), direction=d, radius=r, height=h/2, resolution=bond_resolution)
335
+ self.plotter.add_mesh(cyl1_1, color=begin_color, **mesh_props)
336
+ self.plotter.add_mesh(cyl1_2, color=end_color, **mesh_props)
337
+ self._3d_color_map[f'bond_{bond_counter}_1_start'] = begin_color_rgb
338
+ self._3d_color_map[f'bond_{bond_counter}_1_end'] = end_color_rgb
339
+
340
+ # 第二の結合線(前半・後半)
341
+ cyl2_1 = pv.Cylinder(center=(sp + mid_point) / 2 - off_dir * (s_double / 2), direction=d, radius=r, height=h/2, resolution=bond_resolution)
342
+ cyl2_2 = pv.Cylinder(center=(mid_point + ep) / 2 - off_dir * (s_double / 2), direction=d, radius=r, height=h/2, resolution=bond_resolution)
343
+ self.plotter.add_mesh(cyl2_1, color=begin_color, **mesh_props)
344
+ self.plotter.add_mesh(cyl2_2, color=end_color, **mesh_props)
345
+ self._3d_color_map[f'bond_{bond_counter}_2_start'] = begin_color_rgb
346
+ self._3d_color_map[f'bond_{bond_counter}_2_end'] = end_color_rgb
347
+ elif bt == Chem.rdchem.BondType.TRIPLE:
348
+ r = cyl_radius * triple_radius_factor
349
+ # 三重結合
350
+ v_arb = np.array([0, 0, 1])
351
+ if np.allclose(np.abs(np.dot(v1, v_arb)), 1.0): v_arb = np.array([0, 1, 0])
352
+ off_dir = np.cross(v1, v_arb)
353
+ off_dir /= np.linalg.norm(off_dir)
354
+
355
+ # 設定から三重結合のオフセットファクターを適用
356
+ s_triple = cyl_radius * triple_offset_factor
357
+
358
+ if self.current_3d_style == 'ball_and_stick':
359
+ # Ball and stickは全結合をまとめて処理(高速化)
360
+ cyl1 = pv.Cylinder(center=c, direction=d, radius=r, height=h, resolution=bond_resolution)
361
+ cyl2 = pv.Cylinder(center=c + off_dir * s_triple, direction=d, radius=r, height=h, resolution=bond_resolution)
362
+ cyl3 = pv.Cylinder(center=c - off_dir * s_triple, direction=d, radius=r, height=h, resolution=bond_resolution)
363
+ bond_cylinders.extend([cyl1, cyl2, cyl3])
364
+ self._3d_color_map[f'bond_{bond_counter}_1'] = bs_bond_rgb
365
+ self._3d_color_map[f'bond_{bond_counter}_2'] = bs_bond_rgb
366
+ self._3d_color_map[f'bond_{bond_counter}_3'] = bs_bond_rgb
367
+ else:
368
+ # その他(stick, wireframe)は中央で色が変わる
369
+ mid_point = (sp + ep) / 2
370
+
371
+ # 中央の結合線(前半・後半)
372
+ cyl1_1 = pv.Cylinder(center=(sp + mid_point) / 2, direction=d, radius=r, height=h/2, resolution=bond_resolution)
373
+ cyl1_2 = pv.Cylinder(center=(mid_point + ep) / 2, direction=d, radius=r, height=h/2, resolution=bond_resolution)
374
+ self.plotter.add_mesh(cyl1_1, color=begin_color, **mesh_props)
375
+ self.plotter.add_mesh(cyl1_2, color=end_color, **mesh_props)
376
+ self._3d_color_map[f'bond_{bond_counter}_1_start'] = begin_color_rgb
377
+ self._3d_color_map[f'bond_{bond_counter}_1_end'] = end_color_rgb
378
+
379
+ # 上側の結合線(前半・後半)
380
+ cyl2_1 = pv.Cylinder(center=(sp + mid_point) / 2 + off_dir * s_triple, direction=d, radius=r, height=h/2, resolution=bond_resolution)
381
+ cyl2_2 = pv.Cylinder(center=(mid_point + ep) / 2 + off_dir * s_triple, direction=d, radius=r, height=h/2, resolution=bond_resolution)
382
+ self.plotter.add_mesh(cyl2_1, color=begin_color, **mesh_props)
383
+ self.plotter.add_mesh(cyl2_2, color=end_color, **mesh_props)
384
+ self._3d_color_map[f'bond_{bond_counter}_2_start'] = begin_color_rgb
385
+ self._3d_color_map[f'bond_{bond_counter}_2_end'] = end_color_rgb
386
+
387
+ # 下側の結合線(前半・後半)
388
+ cyl3_1 = pv.Cylinder(center=(sp + mid_point) / 2 - off_dir * s_triple, direction=d, radius=r, height=h/2, resolution=bond_resolution)
389
+ cyl3_2 = pv.Cylinder(center=(mid_point + ep) / 2 - off_dir * s_triple, direction=d, radius=r, height=h/2, resolution=bond_resolution)
390
+ self.plotter.add_mesh(cyl3_1, color=begin_color, **mesh_props)
391
+ self.plotter.add_mesh(cyl3_2, color=end_color, **mesh_props)
392
+ self._3d_color_map[f'bond_{bond_counter}_3_start'] = begin_color_rgb
393
+ self._3d_color_map[f'bond_{bond_counter}_3_end'] = end_color_rgb
394
+
395
+ bond_counter += 1
396
+
397
+ # Ball and Stick用:全結合をまとめて一括描画(高速化)
398
+ if self.current_3d_style == 'ball_and_stick' and bond_cylinders:
399
+ # 全シリンダーを結合してMultiBlockを作成
400
+ combined_bonds = pv.MultiBlock(bond_cylinders)
401
+ combined_mesh = combined_bonds.combine()
402
+
403
+ # 一括でグレーで描画
404
+ # Use the configured Ball & Stick bond color (hex) for the combined bonds
405
+ try:
406
+ bs_hex = self.settings.get('ball_stick_bond_color', '#7F7F7F')
407
+ q = QColor(bs_hex)
408
+ # Use normalized RGB for pyvista (r,g,b) floats in [0,1]
409
+ bond_color = (q.redF(), q.greenF(), q.blueF())
410
+ bond_actor = self.plotter.add_mesh(combined_mesh, color=bond_color, **mesh_props)
411
+ except Exception:
412
+ bond_actor = self.plotter.add_mesh(combined_mesh, color='grey', **mesh_props)
413
+
414
+ # まとめて色情報を記録
415
+ self._3d_color_map['bonds_combined'] = bs_bond_rgb
416
+
417
+ if getattr(self, 'show_chiral_labels', False):
418
+ try:
419
+ # 3D座標からキラル中心を計算
420
+ chiral_centers = Chem.FindMolChiralCenters(mol, includeUnassigned=True)
421
+ if chiral_centers:
422
+ pts, labels = [], []
423
+ z_off = 0
424
+ for idx, lbl in chiral_centers:
425
+ coord = self.atom_positions_3d[idx].copy(); coord[2] += z_off
426
+ pts.append(coord); labels.append(lbl if lbl is not None else '?')
427
+ try: self.plotter.remove_actor('chiral_labels')
428
+ except Exception: pass
429
+ 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)
430
+ except Exception as e: self.statusBar().showMessage(f"3D chiral label drawing error: {e}")
431
+
432
+ # E/Zラベルも表示
433
+ if getattr(self, 'show_chiral_labels', False):
434
+ try:
435
+ self.show_ez_labels_3d(mol)
436
+ except Exception as e:
437
+ self.statusBar().showMessage(f"3D E/Z label drawing error: {e}")
438
+
439
+ self.plotter.camera = camera_state
440
+
441
+ # Ensure the underlying VTK camera's parallel/projection flag matches
442
+ # the saved application setting. draw_molecule_3d restores a PyVista
443
+ # camera object which may not propagate the ParallelProjection flag
444
+ # to the VTK renderer camera; enforce it here to guarantee the
445
+ # projection mode selected in settings actually takes effect.
446
+ try:
447
+ proj_mode = self.settings.get('projection_mode', 'Perspective')
448
+ if hasattr(self.plotter, 'renderer') and hasattr(self.plotter.renderer, 'GetActiveCamera'):
449
+ vcam = self.plotter.renderer.GetActiveCamera()
450
+ if vcam:
451
+ if proj_mode == 'Orthographic':
452
+ vcam.SetParallelProjection(True)
453
+ else:
454
+ vcam.SetParallelProjection(False)
455
+ try:
456
+ # Force a render so the change is visible immediately
457
+ self.plotter.render()
458
+ except Exception:
459
+ pass
460
+ except Exception:
461
+ pass
462
+
463
+ # AtomIDまたは他の原子情報が表示されている場合は再表示
464
+ if hasattr(self, 'atom_info_display_mode') and self.atom_info_display_mode is not None:
465
+ self.show_all_atom_info()
466
+
467
+ # メニューテキストと状態を現在の分子の種類に応じて更新
468
+ self.update_atom_id_menu_text()
469
+ self.update_atom_id_menu_state()
470
+
471
+
472
+
473
+ def _calculate_double_bond_offset(self, mol, bond, conf):
474
+ """
475
+ 二重結合のオフセット方向を計算する。
476
+ 結合している原子の他の結合を考慮して、平面的になるようにする。
477
+ """
478
+ begin_atom = mol.GetAtomWithIdx(bond.GetBeginAtomIdx())
479
+ end_atom = mol.GetAtomWithIdx(bond.GetEndAtomIdx())
480
+
481
+ begin_pos = np.array(conf.GetAtomPosition(bond.GetBeginAtomIdx()))
482
+ end_pos = np.array(conf.GetAtomPosition(bond.GetEndAtomIdx()))
483
+
484
+ bond_vec = end_pos - begin_pos
485
+ bond_length = np.linalg.norm(bond_vec)
486
+ if bond_length == 0:
487
+ # フォールバック: Z軸基準
488
+ return np.array([0, 0, 1])
489
+
490
+ bond_unit = bond_vec / bond_length
491
+
492
+ # 両端の原子の隣接原子を調べる
493
+ begin_neighbors = []
494
+ end_neighbors = []
495
+
496
+ for neighbor in begin_atom.GetNeighbors():
497
+ if neighbor.GetIdx() != bond.GetEndAtomIdx():
498
+ neighbor_pos = np.array(conf.GetAtomPosition(neighbor.GetIdx()))
499
+ begin_neighbors.append(neighbor_pos)
500
+
501
+ for neighbor in end_atom.GetNeighbors():
502
+ if neighbor.GetIdx() != bond.GetBeginAtomIdx():
503
+ neighbor_pos = np.array(conf.GetAtomPosition(neighbor.GetIdx()))
504
+ end_neighbors.append(neighbor_pos)
505
+
506
+ # 平面の法線ベクトルを計算
507
+ normal_candidates = []
508
+
509
+ # 開始原子の隣接原子から平面を推定
510
+ if len(begin_neighbors) >= 1:
511
+ for neighbor_pos in begin_neighbors:
512
+ vec_to_neighbor = neighbor_pos - begin_pos
513
+ if np.linalg.norm(vec_to_neighbor) > 1e-6:
514
+ # bond_vec と neighbor_vec の外積が平面の法線
515
+ normal = np.cross(bond_vec, vec_to_neighbor)
516
+ norm_length = np.linalg.norm(normal)
517
+ if norm_length > 1e-6:
518
+ normal_candidates.append(normal / norm_length)
519
+
520
+ # 終了原子の隣接原子から平面を推定
521
+ if len(end_neighbors) >= 1:
522
+ for neighbor_pos in end_neighbors:
523
+ vec_to_neighbor = neighbor_pos - end_pos
524
+ if np.linalg.norm(vec_to_neighbor) > 1e-6:
525
+ # bond_vec と neighbor_vec の外積が平面の法線
526
+ normal = np.cross(bond_vec, vec_to_neighbor)
527
+ norm_length = np.linalg.norm(normal)
528
+ if norm_length > 1e-6:
529
+ normal_candidates.append(normal / norm_length)
530
+
531
+ # 複数の法線ベクトルがある場合は平均を取る
532
+ if normal_candidates:
533
+ # 方向を統一するため、最初のベクトルとの内積が正になるように調整
534
+ reference_normal = normal_candidates[0]
535
+ aligned_normals = []
536
+
537
+ for normal in normal_candidates:
538
+ if np.dot(normal, reference_normal) < 0:
539
+ normal = -normal
540
+ aligned_normals.append(normal)
541
+
542
+ avg_normal = np.mean(aligned_normals, axis=0)
543
+ norm_length = np.linalg.norm(avg_normal)
544
+ if norm_length > 1e-6:
545
+ avg_normal /= norm_length
546
+
547
+ # 法線ベクトルと結合ベクトルに垂直な方向を二重結合のオフセット方向とする
548
+ offset_dir = np.cross(bond_unit, avg_normal)
549
+ offset_length = np.linalg.norm(offset_dir)
550
+ if offset_length > 1e-6:
551
+ return offset_dir / offset_length
552
+
553
+ # フォールバック: 結合ベクトルに垂直な任意の方向
554
+ v_arb = np.array([0, 0, 1])
555
+ if np.allclose(np.abs(np.dot(bond_unit, v_arb)), 1.0):
556
+ v_arb = np.array([0, 1, 0])
557
+
558
+ off_dir = np.cross(bond_unit, v_arb)
559
+ off_dir /= np.linalg.norm(off_dir)
560
+ return off_dir
561
+
562
+
563
+
564
+ def show_ez_labels_3d(self, mol):
565
+ """3DビューでE/Zラベルを表示する(RDKitのステレオ化学判定を使用)"""
566
+ if not mol:
567
+ return
568
+
569
+ try:
570
+ # 既存のE/Zラベルを削除
571
+ self.plotter.remove_actor('ez_labels')
572
+ except:
573
+ pass
574
+
575
+ pts, labels = [], []
576
+
577
+ # 3D座標が存在するかチェック
578
+ if mol.GetNumConformers() == 0:
579
+ return
580
+
581
+ conf = mol.GetConformer()
582
+
583
+ # RDKitに3D座標からステレオ化学を計算させる
584
+ try:
585
+ # 3D座標からステレオ化学を再計算
586
+ Chem.AssignStereochemistry(mol, cleanIt=True, force=True, flagPossibleStereoCenters=True)
587
+ except:
588
+ pass
589
+
590
+ # 二重結合でRDKitが判定したE/Z立体化学を表示
591
+ for bond in mol.GetBonds():
592
+ if bond.GetBondType() == Chem.BondType.DOUBLE:
593
+ stereo = bond.GetStereo()
594
+ if stereo in [Chem.BondStereo.STEREOE, Chem.BondStereo.STEREOZ]:
595
+ # 結合の中心座標を計算
596
+ begin_pos = np.array(conf.GetAtomPosition(bond.GetBeginAtomIdx()))
597
+ end_pos = np.array(conf.GetAtomPosition(bond.GetEndAtomIdx()))
598
+ center_pos = (begin_pos + end_pos) / 2
599
+
600
+ # RDKitの判定結果を使用
601
+ label = 'E' if stereo == Chem.BondStereo.STEREOE else 'Z'
602
+ pts.append(center_pos)
603
+ labels.append(label)
604
+
605
+ if pts and labels:
606
+ self.plotter.add_point_labels(
607
+ np.array(pts),
608
+ labels,
609
+ font_size=18,
610
+ point_size=0,
611
+ text_color='darkgreen', # 暗い緑色
612
+ name='ez_labels',
613
+ always_visible=True,
614
+ tolerance=0.01,
615
+ show_points=False
616
+ )
617
+
618
+
619
+
620
+
621
+ def toggle_chiral_labels_display(self, checked):
622
+ """Viewメニューのアクションに応じてキラルラベル表示を切り替える"""
623
+ self.show_chiral_labels = checked
624
+
625
+ if self.current_mol:
626
+ self.draw_molecule_3d(self.current_mol)
627
+
628
+ if checked:
629
+ self.statusBar().showMessage("Chiral labels: will be (re)computed after Convert→3D.")
630
+ else:
631
+ self.statusBar().showMessage("Chiral labels disabled.")
632
+
633
+
634
+
635
+
636
+ def update_chiral_labels(self):
637
+ """分子のキラル中心を計算し、2Dビューの原子アイテムにR/Sラベルを設定/解除する
638
+ ※ 可能なら 3D(self.current_mol)を優先して計算し、なければ 2D から作った RDKit 分子を使う。
639
+ """
640
+ # まず全てのアイテムからラベルをクリア
641
+ for atom_data in self.data.atoms.values():
642
+ if atom_data.get('item'):
643
+ atom_data['item'].chiral_label = None
644
+
645
+ if not self.show_chiral_labels:
646
+ self.scene.update()
647
+ return
648
+
649
+ # 3D の RDKit Mol(コンフォマーを持つもの)を使う
650
+ mol_for_chirality = None
651
+ if getattr(self, 'current_mol', None) is not None:
652
+ mol_for_chirality = self.current_mol
653
+ else:
654
+ return
655
+
656
+ if mol_for_chirality is None or mol_for_chirality.GetNumAtoms() == 0:
657
+ self.scene.update()
658
+ return
659
+
660
+ try:
661
+ # --- 重要:3D コンフォマーがあるなら、それを使って原子のキラルタグを割り当てる ---
662
+ if mol_for_chirality.GetNumConformers() > 0:
663
+ # confId=0(最初のコンフォマー)を指定して、原子のキラリティータグを3D座標由来で設定
664
+ try:
665
+ Chem.AssignAtomChiralTagsFromStructure(mol_for_chirality, confId=0)
666
+ except Exception:
667
+ # 古い RDKit では関数が無い場合があるので(念のため保護)
668
+ pass
669
+
670
+ # RDKit の通常の stereochemistry 割当(念のため)
671
+ #Chem.AssignStereochemistry(mol_for_chirality, cleanIt=True, force=True, flagPossibleStereoCenters=True)
672
+
673
+ # キラル中心の取得((idx, 'R'/'S'/'?') のリスト)
674
+ chiral_centers = Chem.FindMolChiralCenters(mol_for_chirality, includeUnassigned=True)
675
+
676
+ # RDKit atom index -> エディタ側 atom_id へのマッピング
677
+ rdkit_idx_to_my_id = {}
678
+ for atom in mol_for_chirality.GetAtoms():
679
+ if atom.HasProp("_original_atom_id"):
680
+ rdkit_idx_to_my_id[atom.GetIdx()] = atom.GetIntProp("_original_atom_id")
681
+
682
+ # 見つかったキラル中心を対応する AtomItem に設定
683
+ for idx, label in chiral_centers:
684
+ if idx in rdkit_idx_to_my_id:
685
+ atom_id = rdkit_idx_to_my_id[idx]
686
+ if atom_id in self.data.atoms and self.data.atoms[atom_id].get('item'):
687
+ # 'R' / 'S' / '?'
688
+ self.data.atoms[atom_id]['item'].chiral_label = label
689
+
690
+ except Exception as e:
691
+ self.statusBar().showMessage(f"Update chiral labels error: {e}")
692
+
693
+ # 最後に 2D シーンを再描画
694
+ self.scene.update()
695
+
696
+
697
+
698
+ def toggle_atom_info_display(self, mode):
699
+ """原子情報表示モードを切り替える"""
700
+ # 現在の表示をクリア
701
+ self.clear_all_atom_info_labels()
702
+
703
+ # 同じモードが選択された場合はOFFにする
704
+ if self.atom_info_display_mode == mode:
705
+ self.atom_info_display_mode = None
706
+ # 全てのアクションのチェックを外す
707
+ self.show_atom_id_action.setChecked(False)
708
+ self.show_rdkit_id_action.setChecked(False)
709
+ self.show_atom_coords_action.setChecked(False)
710
+ self.show_atom_symbol_action.setChecked(False)
711
+ self.statusBar().showMessage("Atom info display disabled.")
712
+ else:
713
+ # 新しいモードを設定
714
+ self.atom_info_display_mode = mode
715
+ # 該当するアクションのみチェック
716
+ self.show_atom_id_action.setChecked(mode == 'id')
717
+ self.show_rdkit_id_action.setChecked(mode == 'rdkit_id')
718
+ self.show_atom_coords_action.setChecked(mode == 'coords')
719
+ self.show_atom_symbol_action.setChecked(mode == 'symbol')
720
+
721
+ mode_names = {'id': 'Atom ID', 'rdkit_id': 'RDKit Index', 'coords': 'Coordinates', 'symbol': 'Element Symbol'}
722
+ self.statusBar().showMessage(f"Displaying: {mode_names[mode]}")
723
+
724
+ # すべての原子に情報を表示
725
+ self.show_all_atom_info()
726
+
727
+
728
+
729
+ def is_xyz_derived_molecule(self):
730
+ """現在の分子がXYZファイル由来かどうかを判定"""
731
+ if not self.current_mol:
732
+ return False
733
+ try:
734
+ # 最初の原子がxyz_unique_idプロパティを持っているかチェック
735
+ if self.current_mol.GetNumAtoms() > 0:
736
+ return self.current_mol.GetAtomWithIdx(0).HasProp("xyz_unique_id")
737
+ except Exception:
738
+ pass
739
+ return False
740
+
741
+
742
+
743
+ def has_original_atom_ids(self):
744
+ """現在の分子がOriginal Atom IDsを持っているかどうかを判定"""
745
+ if not self.current_mol:
746
+ return False
747
+ try:
748
+ # いずれかの原子が_original_atom_idプロパティを持っているかチェック
749
+ for atom_idx in range(self.current_mol.GetNumAtoms()):
750
+ atom = self.current_mol.GetAtomWithIdx(atom_idx)
751
+ if atom.HasProp("_original_atom_id"):
752
+ return True
753
+ except Exception:
754
+ pass
755
+ return False
756
+
757
+
758
+
759
+ def update_atom_id_menu_text(self):
760
+ """原子IDメニューのテキストを現在の分子の種類に応じて更新"""
761
+ if hasattr(self, 'show_atom_id_action'):
762
+ if self.is_xyz_derived_molecule():
763
+ self.show_atom_id_action.setText("Show XYZ Unique ID")
764
+ else:
765
+ self.show_atom_id_action.setText("Show Original ID / Index")
766
+
767
+
768
+
769
+ def update_atom_id_menu_state(self):
770
+ """原子IDメニューの有効/無効状態を更新"""
771
+ if hasattr(self, 'show_atom_id_action'):
772
+ has_original_ids = self.has_original_atom_ids()
773
+ has_xyz_ids = self.is_xyz_derived_molecule()
774
+
775
+ # Original IDまたはXYZ IDがある場合のみ有効化
776
+ self.show_atom_id_action.setEnabled(has_original_ids or has_xyz_ids)
777
+
778
+ # 現在選択されているモードが無効化される場合は解除
779
+ if not (has_original_ids or has_xyz_ids) and self.atom_info_display_mode == 'id':
780
+ self.atom_info_display_mode = None
781
+ self.show_atom_id_action.setChecked(False)
782
+ self.clear_all_atom_info_labels()
783
+
784
+
785
+
786
+
787
+ def show_all_atom_info(self):
788
+ """すべての原子に情報を表示"""
789
+ if self.atom_info_display_mode is None or not hasattr(self, 'atom_positions_3d') or self.atom_positions_3d is None:
790
+ return
791
+
792
+ # 既存のラベルをクリア
793
+ self.clear_all_atom_info_labels()
794
+
795
+ # ラベルを表示するためにタイプ別に分けてリストを作る
796
+ rdkit_positions = []
797
+ rdkit_texts = []
798
+ id_positions = []
799
+ id_texts = []
800
+ xyz_positions = []
801
+ xyz_texts = []
802
+ other_positions = []
803
+ other_texts = []
804
+
805
+ for atom_idx, pos in enumerate(self.atom_positions_3d):
806
+ # default: skip if no display mode
807
+ if self.atom_info_display_mode is None:
808
+ continue
809
+
810
+ if self.atom_info_display_mode == 'id':
811
+ # Original IDがある場合は優先表示、なければXYZのユニークID、最後にRDKitインデックス
812
+ try:
813
+ if self.current_mol:
814
+ atom = self.current_mol.GetAtomWithIdx(atom_idx)
815
+ if atom.HasProp("_original_atom_id"):
816
+ original_id = atom.GetIntProp("_original_atom_id")
817
+ # プレフィックスを削除して数値だけ表示
818
+ id_positions.append(pos)
819
+ id_texts.append(str(original_id))
820
+ elif atom.HasProp("xyz_unique_id"):
821
+ unique_id = atom.GetIntProp("xyz_unique_id")
822
+ xyz_positions.append(pos)
823
+ xyz_texts.append(str(unique_id))
824
+ else:
825
+ rdkit_positions.append(pos)
826
+ rdkit_texts.append(str(atom_idx))
827
+ else:
828
+ rdkit_positions.append(pos)
829
+ rdkit_texts.append(str(atom_idx))
830
+ except Exception:
831
+ rdkit_positions.append(pos)
832
+ rdkit_texts.append(str(atom_idx))
833
+
834
+ elif self.atom_info_display_mode == 'rdkit_id':
835
+ rdkit_positions.append(pos)
836
+ rdkit_texts.append(str(atom_idx))
837
+
838
+ elif self.atom_info_display_mode == 'coords':
839
+ other_positions.append(pos)
840
+ other_texts.append(f"({pos[0]:.2f},{pos[1]:.2f},{pos[2]:.2f})")
841
+
842
+ elif self.atom_info_display_mode == 'symbol':
843
+ if self.current_mol:
844
+ symbol = self.current_mol.GetAtomWithIdx(atom_idx).GetSymbol()
845
+ other_positions.append(pos)
846
+ other_texts.append(symbol)
847
+ else:
848
+ other_positions.append(pos)
849
+ other_texts.append("?")
850
+
851
+ else:
852
+ continue
853
+
854
+ # 色の定義(暗めの青/緑/赤)
855
+ rdkit_color = '#003366' # 暗めの青
856
+ id_color = '#006400' # 暗めの緑
857
+ xyz_color = '#8B0000' # 暗めの赤
858
+ other_color = 'black'
859
+
860
+ # それぞれのグループごとにラベルを追加し、参照をリストで保持する
861
+ self.current_atom_info_labels = []
862
+ try:
863
+ if rdkit_positions:
864
+ a = self.plotter.add_point_labels(
865
+ np.array(rdkit_positions), rdkit_texts,
866
+ point_size=12, font_size=18, text_color=rdkit_color,
867
+ always_visible=True, tolerance=0.01, show_points=False,
868
+ name='atom_labels_rdkit'
869
+ )
870
+ self.current_atom_info_labels.append(a)
871
+
872
+ if id_positions:
873
+ a = self.plotter.add_point_labels(
874
+ np.array(id_positions), id_texts,
875
+ point_size=12, font_size=18, text_color=id_color,
876
+ always_visible=True, tolerance=0.01, show_points=False,
877
+ name='atom_labels_id'
878
+ )
879
+ self.current_atom_info_labels.append(a)
880
+
881
+ if xyz_positions:
882
+ a = self.plotter.add_point_labels(
883
+ np.array(xyz_positions), xyz_texts,
884
+ point_size=12, font_size=18, text_color=xyz_color,
885
+ always_visible=True, tolerance=0.01, show_points=False,
886
+ name='atom_labels_xyz'
887
+ )
888
+ self.current_atom_info_labels.append(a)
889
+
890
+ if other_positions:
891
+ a = self.plotter.add_point_labels(
892
+ np.array(other_positions), other_texts,
893
+ point_size=12, font_size=18, text_color=other_color,
894
+ always_visible=True, tolerance=0.01, show_points=False,
895
+ name='atom_labels_other'
896
+ )
897
+ self.current_atom_info_labels.append(a)
898
+ except Exception as e:
899
+ print(f"Error adding atom info labels: {e}")
900
+
901
+ # 右上に凡例を表示(既存の凡例は消す)
902
+ try:
903
+ # 古い凡例削除
904
+ if hasattr(self, 'atom_label_legend_names') and self.atom_label_legend_names:
905
+ for nm in self.atom_label_legend_names:
906
+ try:
907
+ self.plotter.remove_actor(nm)
908
+ except:
909
+ pass
910
+ self.atom_label_legend_names = []
911
+
912
+ # 凡例テキストを右上に縦並びで追加(背景なし、太字のみ)
913
+ legend_entries = []
914
+ if rdkit_positions:
915
+ legend_entries.append(('RDKit', rdkit_color, 'legend_rdkit'))
916
+ if id_positions:
917
+ legend_entries.append(('ID', id_color, 'legend_id'))
918
+ if xyz_positions:
919
+ legend_entries.append(('XYZ', xyz_color, 'legend_xyz'))
920
+ # Do not show 'Other' in the legend per UI requirement
921
+ # (other_positions are still labeled in-scene but not listed in the legend)
922
+
923
+ # 左下に凡例ラベルを追加(背景なし、太字のみ)
924
+ # Increase spacing to avoid overlapping when short labels like 'RDKit' and 'ID' appear
925
+ spacing = 30
926
+ for i, (label_text, label_color, label_name) in enumerate(legend_entries):
927
+ # 左下基準でy座標を上げる
928
+ # Add a small horizontal offset for very short adjacent labels so they don't visually collide
929
+ y = 0.0 + i * spacing
930
+ x_offset = 0.0
931
+ # If both RDKit and ID are present, nudge the second entry slightly to the right to avoid overlap
932
+ try:
933
+ if label_text == 'ID' and any(e[0] == 'RDKit' for e in legend_entries):
934
+ x_offset = 0.06
935
+ except Exception:
936
+ x_offset = 0.0
937
+ try:
938
+ actor = self.plotter.add_text(
939
+ label_text,
940
+ position=(0.0 + x_offset, y),
941
+ font_size=12,
942
+ color=label_color,
943
+ name=label_name,
944
+ font='arial'
945
+ )
946
+ self.atom_label_legend_names.append(label_name)
947
+ # 太字のみ設定(背景は設定しない)
948
+ try:
949
+ if hasattr(actor, 'GetTextProperty'):
950
+ tp = actor.GetTextProperty()
951
+ try:
952
+ tp.SetBold(True)
953
+ except Exception:
954
+ pass
955
+ except Exception:
956
+ pass
957
+ except Exception:
958
+ continue
959
+
960
+ except Exception:
961
+ pass
962
+
963
+
964
+
965
+ def clear_all_atom_info_labels(self):
966
+ """すべての原子情報ラベルをクリア"""
967
+ # Remove label actors (may be a single actor, a list, or None)
968
+ try:
969
+ if hasattr(self, 'current_atom_info_labels') and self.current_atom_info_labels:
970
+ if isinstance(self.current_atom_info_labels, (list, tuple)):
971
+ for a in list(self.current_atom_info_labels):
972
+ try:
973
+ self.plotter.remove_actor(a)
974
+ except:
975
+ pass
976
+ else:
977
+ try:
978
+ self.plotter.remove_actor(self.current_atom_info_labels)
979
+ except:
980
+ pass
981
+ except Exception:
982
+ pass
983
+ finally:
984
+ self.current_atom_info_labels = None
985
+
986
+ # Remove legend text actors if present
987
+ try:
988
+ if hasattr(self, 'atom_label_legend_names') and self.atom_label_legend_names:
989
+ for nm in list(self.atom_label_legend_names):
990
+ try:
991
+ self.plotter.remove_actor(nm)
992
+ except:
993
+ pass
994
+ except Exception:
995
+ pass
996
+ finally:
997
+ self.atom_label_legend_names = []
998
+
999
+
1000
+
1001
+ def setup_3d_hover(self):
1002
+ """3Dビューでの表示を設定(常時表示に変更)"""
1003
+ if self.atom_info_display_mode is not None:
1004
+ self.show_all_atom_info()
1005
+
1006
+
1007
+
1008
+ def zoom_in(self):
1009
+ """ ビューを 20% 拡大する """
1010
+ self.view_2d.scale(1.2, 1.2)
1011
+
1012
+
1013
+
1014
+ def zoom_out(self):
1015
+ """ ビューを 20% 縮小する """
1016
+ self.view_2d.scale(1/1.2, 1/1.2)
1017
+
1018
+
1019
+
1020
+ def reset_zoom(self):
1021
+ """ ビューの拡大率をデフォルト (75%) にリセットする """
1022
+ transform = QTransform()
1023
+ transform.scale(0.75, 0.75)
1024
+ self.view_2d.setTransform(transform)
1025
+
1026
+
1027
+
1028
+ def fit_to_view(self):
1029
+ """ シーン上のすべてのアイテムがビューに収まるように調整する """
1030
+ if not self.scene.items():
1031
+ self.reset_zoom()
1032
+ return
1033
+
1034
+ # 合計の表示矩形(目に見えるアイテムのみ)を計算
1035
+ visible_items_rect = QRectF()
1036
+ for item in self.scene.items():
1037
+ if item.isVisible() and not isinstance(item, TemplatePreviewItem):
1038
+ if visible_items_rect.isEmpty():
1039
+ visible_items_rect = item.sceneBoundingRect()
1040
+ else:
1041
+ visible_items_rect = visible_items_rect.united(item.sceneBoundingRect())
1042
+
1043
+ if visible_items_rect.isEmpty():
1044
+ self.reset_zoom()
1045
+ return
1046
+
1047
+ # 少し余白を持たせる(パディング)
1048
+ padding_factor = 1.10 # 10% の余裕
1049
+ cx = visible_items_rect.center().x()
1050
+ cy = visible_items_rect.center().y()
1051
+ w = visible_items_rect.width() * padding_factor
1052
+ h = visible_items_rect.height() * padding_factor
1053
+ padded = QRectF(cx - w / 2.0, cy - h / 2.0, w, h)
1054
+
1055
+ # フィット時にマウス位置に依存するアンカーが原因でジャンプすることがあるため
1056
+ # 一時的にトランスフォームアンカーをビュー中心にしてから fitInView を呼ぶ
1057
+ try:
1058
+ old_ta = self.view_2d.transformationAnchor()
1059
+ old_ra = self.view_2d.resizeAnchor()
1060
+ except Exception:
1061
+ old_ta = old_ra = None
1062
+
1063
+ try:
1064
+ self.view_2d.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
1065
+ self.view_2d.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
1066
+ self.view_2d.fitInView(padded, Qt.AspectRatioMode.KeepAspectRatio)
1067
+ finally:
1068
+ # 元のアンカーを復元
1069
+ try:
1070
+ if old_ta is not None:
1071
+ self.view_2d.setTransformationAnchor(old_ta)
1072
+ if old_ra is not None:
1073
+ self.view_2d.setResizeAnchor(old_ra)
1074
+ except Exception:
1075
+ pass
1076
+
1077
+
1078
+
1079
+ def update_cpk_colors_from_settings(self):
1080
+ """Update global CPK_COLORS and CPK_COLORS_PV from saved settings overrides.
1081
+
1082
+ This modifies the in-memory CPK_COLORS mapping (not persisted until settings are saved).
1083
+ Only keys present in self.settings['cpk_colors'] are changed; other elements keep the defaults.
1084
+ """
1085
+ try:
1086
+ overrides = self.settings.get('cpk_colors', {}) or {}
1087
+ # Start from a clean copy of the defaults
1088
+ global CPK_COLORS, CPK_COLORS_PV
1089
+ CPK_COLORS = {k: QColor(v) if not isinstance(v, QColor) else QColor(v) for k, v in DEFAULT_CPK_COLORS.items()}
1090
+ for k, hexv in overrides.items():
1091
+ if isinstance(hexv, str) and hexv:
1092
+ CPK_COLORS[k] = QColor(hexv)
1093
+ # Rebuild PV version
1094
+ CPK_COLORS_PV = {k: [c.redF(), c.greenF(), c.blueF()] for k, c in CPK_COLORS.items()}
1095
+ except Exception as e:
1096
+ print(f"Failed to update CPK colors from settings: {e}")
1097
+
1098
+
1099
+
1100
+
1101
+ def apply_3d_settings(self, redraw=True):
1102
+ # Projection mode
1103
+ proj_mode = self.settings.get('projection_mode', 'Perspective')
1104
+ if hasattr(self.plotter, 'renderer') and hasattr(self.plotter.renderer, 'GetActiveCamera'):
1105
+ cam = self.plotter.renderer.GetActiveCamera()
1106
+ if cam:
1107
+ if proj_mode == 'Orthographic':
1108
+ cam.SetParallelProjection(True)
1109
+ else:
1110
+ cam.SetParallelProjection(False)
1111
+ """3Dビューの視覚設定を適用する"""
1112
+ if not hasattr(self, 'plotter'):
1113
+ return
1114
+
1115
+ # レンダラーのレイヤー設定を有効化(テキストオーバーレイ用)
1116
+ renderer = self.plotter.renderer
1117
+ if renderer and hasattr(renderer, 'SetNumberOfLayers'):
1118
+ try:
1119
+ renderer.SetNumberOfLayers(2) # レイヤー0:3Dオブジェクト、レイヤー1:2Dオーバーレイ
1120
+ except:
1121
+ pass # PyVistaのバージョンによってはサポートされていない場合がある
1122
+
1123
+ # --- 3D軸ウィジェットの設定 ---
1124
+ show_axes = self.settings.get('show_3d_axes', True)
1125
+
1126
+ # ウィジェットがまだ作成されていない場合は作成する
1127
+ if self.axes_widget is None and hasattr(self.plotter, 'interactor'):
1128
+ axes = vtk.vtkAxesActor()
1129
+ self.axes_widget = vtk.vtkOrientationMarkerWidget()
1130
+ self.axes_widget.SetOrientationMarker(axes)
1131
+ self.axes_widget.SetInteractor(self.plotter.interactor)
1132
+ # 左下隅に設定 (幅・高さ20%)
1133
+ self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2)
1134
+
1135
+ # 設定に応じてウィジェットを有効化/無効化
1136
+ if self.axes_widget:
1137
+ if show_axes:
1138
+ self.axes_widget.On()
1139
+ self.axes_widget.SetInteractive(False)
1140
+ else:
1141
+ self.axes_widget.Off()
1142
+
1143
+ if redraw:
1144
+ self.draw_molecule_3d(self.current_mol)
1145
+
1146
+ # 設定変更時にカメラ位置をリセットしない(初回のみリセット)
1147
+ if not getattr(self, '_camera_initialized', False):
1148
+ try:
1149
+ self.plotter.reset_camera()
1150
+ except Exception:
1151
+ pass
1152
+ self._camera_initialized = True
1153
+
1154
+ # 強制的にプロッターを更新
1155
+ try:
1156
+ self.plotter.render()
1157
+ if hasattr(self.plotter, 'update'):
1158
+ self.plotter.update()
1159
+ except Exception:
1160
+ pass
1161
+
1162
+
1163
+