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