MoleditPy-linux 2.2.4__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 (58) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +293 -0
  7. moleditpy_linux/modules/alignment_dialog.py +273 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/icon.icns +0 -0
  11. moleditpy_linux/modules/assets/icon.ico +0 -0
  12. moleditpy_linux/modules/assets/icon.png +0 -0
  13. moleditpy_linux/modules/atom_item.py +348 -0
  14. moleditpy_linux/modules/bond_item.py +406 -0
  15. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  16. moleditpy_linux/modules/calculation_worker.py +766 -0
  17. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  18. moleditpy_linux/modules/constants.py +88 -0
  19. moleditpy_linux/modules/constrained_optimization_dialog.py +679 -0
  20. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  21. moleditpy_linux/modules/custom_qt_interactor.py +59 -0
  22. moleditpy_linux/modules/dialog3_d_picking_mixin.py +108 -0
  23. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  24. moleditpy_linux/modules/main_window.py +842 -0
  25. moleditpy_linux/modules/main_window_app_state.py +780 -0
  26. moleditpy_linux/modules/main_window_compute.py +1242 -0
  27. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  28. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  29. moleditpy_linux/modules/main_window_edit_actions.py +1455 -0
  30. moleditpy_linux/modules/main_window_export.py +806 -0
  31. moleditpy_linux/modules/main_window_main_init.py +2006 -0
  32. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  33. moleditpy_linux/modules/main_window_project_io.py +434 -0
  34. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  35. moleditpy_linux/modules/main_window_ui_manager.py +606 -0
  36. moleditpy_linux/modules/main_window_view_3d.py +1531 -0
  37. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  38. moleditpy_linux/modules/mirror_dialog.py +122 -0
  39. moleditpy_linux/modules/molecular_data.py +302 -0
  40. moleditpy_linux/modules/molecule_scene.py +2000 -0
  41. moleditpy_linux/modules/move_group_dialog.py +598 -0
  42. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  43. moleditpy_linux/modules/planarize_dialog.py +221 -0
  44. moleditpy_linux/modules/plugin_interface.py +195 -0
  45. moleditpy_linux/modules/plugin_manager.py +309 -0
  46. moleditpy_linux/modules/plugin_manager_window.py +221 -0
  47. moleditpy_linux/modules/settings_dialog.py +1149 -0
  48. moleditpy_linux/modules/template_preview_item.py +157 -0
  49. moleditpy_linux/modules/template_preview_view.py +74 -0
  50. moleditpy_linux/modules/translation_dialog.py +365 -0
  51. moleditpy_linux/modules/user_template_dialog.py +692 -0
  52. moleditpy_linux/modules/zoomable_view.py +129 -0
  53. moleditpy_linux-2.2.4.dist-info/METADATA +936 -0
  54. moleditpy_linux-2.2.4.dist-info/RECORD +58 -0
  55. moleditpy_linux-2.2.4.dist-info/WHEEL +5 -0
  56. moleditpy_linux-2.2.4.dist-info/entry_points.txt +2 -0
  57. moleditpy_linux-2.2.4.dist-info/licenses/LICENSE +674 -0
  58. moleditpy_linux-2.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,749 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ import numpy as np
14
+
15
+ from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera
16
+
17
+ from PyQt6.QtWidgets import QApplication
18
+
19
+ from PyQt6.QtCore import (
20
+ Qt
21
+ )
22
+
23
+ try:
24
+ from .constants import pt
25
+ except Exception:
26
+ from modules.constants import pt
27
+ try:
28
+ from .move_group_dialog import MoveGroupDialog
29
+ except Exception:
30
+ from modules.move_group_dialog import MoveGroupDialog
31
+
32
+ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
33
+ def __init__(self, main_window):
34
+ super().__init__()
35
+ self.main_window = main_window
36
+ # カスタム状態を管理するフラグを一つに絞ります
37
+ self._is_dragging_atom = False
38
+ # undoスタックのためのフラグ
39
+ self.is_dragging = False
40
+ # 回転操作を検出するためのフラグ
41
+ self._mouse_moved_during_drag = False
42
+ self._mouse_press_pos = None
43
+
44
+ self.AddObserver("LeftButtonPressEvent", self.on_left_button_down)
45
+ self.AddObserver("RightButtonPressEvent", self.on_right_button_down)
46
+ self.AddObserver("MouseMoveEvent", self.on_mouse_move)
47
+ self.AddObserver("LeftButtonReleaseEvent", self.on_left_button_up)
48
+ self.AddObserver("RightButtonReleaseEvent", self.on_right_button_up)
49
+
50
+ def on_left_button_down(self, obj, event):
51
+ """
52
+ クリック時の処理を振り分けます。
53
+ 原子を掴めた場合のみカスタム動作に入り、それ以外は親クラス(カメラ回転)に任せます。
54
+ """
55
+ mw = self.main_window
56
+
57
+ # 前回のドラッグ状態をクリア(トリプルクリック/ダブルクリック対策)
58
+ self._is_dragging_atom = False
59
+ self.is_dragging = False
60
+ self._mouse_moved_during_drag = False
61
+ self._mouse_press_pos = None
62
+
63
+ # Move Groupダイアログが開いている場合の処理
64
+ move_group_dialog = None
65
+ try:
66
+ for widget in QApplication.topLevelWidgets():
67
+ if isinstance(widget, MoveGroupDialog) and widget.isVisible():
68
+ move_group_dialog = widget
69
+ break
70
+ except Exception:
71
+ pass
72
+
73
+ if move_group_dialog and move_group_dialog.group_atoms:
74
+ # グループが選択されている場合、グループドラッグ処理
75
+ click_pos = self.GetInteractor().GetEventPosition()
76
+ picker = mw.plotter.picker
77
+ picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
78
+
79
+ clicked_atom_idx = None
80
+ if picker.GetActor() is mw.atom_actor:
81
+ picked_position = np.array(picker.GetPickPosition())
82
+ distances = np.linalg.norm(mw.atom_positions_3d - picked_position, axis=1)
83
+ closest_atom_idx = np.argmin(distances)
84
+
85
+ if 0 <= closest_atom_idx < mw.current_mol.GetNumAtoms():
86
+ atom = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx))
87
+ if atom:
88
+ atomic_num = atom.GetAtomicNum()
89
+ vdw_radius = pt.GetRvdw(atomic_num)
90
+ click_threshold = vdw_radius * 1.5
91
+
92
+ if distances[closest_atom_idx] < click_threshold:
93
+ clicked_atom_idx = int(closest_atom_idx)
94
+
95
+ # グループ内の原子がクリックされた場合
96
+ if clicked_atom_idx is not None:
97
+ if clicked_atom_idx in move_group_dialog.group_atoms:
98
+ # 既存グループ内の原子 - ドラッグ準備
99
+ move_group_dialog._is_dragging_group_vtk = True
100
+ move_group_dialog._drag_atom_idx = clicked_atom_idx
101
+ move_group_dialog._drag_start_pos = click_pos
102
+ move_group_dialog._mouse_moved = False
103
+ # 初期位置を保存
104
+ move_group_dialog._initial_positions = {}
105
+ conf = mw.current_mol.GetConformer()
106
+ for atom_idx in move_group_dialog.group_atoms:
107
+ pos = conf.GetAtomPosition(atom_idx)
108
+ move_group_dialog._initial_positions[atom_idx] = np.array([pos.x, pos.y, pos.z])
109
+ mw.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
110
+ return # カメラ回転を無効化
111
+ else:
112
+ # グループ外の原子をクリック - BFS/DFSで連結成分を探索
113
+ visited = set()
114
+ queue = [clicked_atom_idx]
115
+ visited.add(clicked_atom_idx)
116
+
117
+ while queue:
118
+ current_idx = queue.pop(0)
119
+ for bond_idx in range(mw.current_mol.GetNumBonds()):
120
+ bond = mw.current_mol.GetBondWithIdx(bond_idx)
121
+ begin_idx = bond.GetBeginAtomIdx()
122
+ end_idx = bond.GetEndAtomIdx()
123
+
124
+ if begin_idx == current_idx and end_idx not in visited:
125
+ visited.add(end_idx)
126
+ queue.append(end_idx)
127
+ elif end_idx == current_idx and begin_idx not in visited:
128
+ visited.add(begin_idx)
129
+ queue.append(begin_idx)
130
+
131
+ # Ctrlキーが押されている場合のみ複数グループ選択
132
+ is_ctrl_pressed = bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier)
133
+
134
+ if is_ctrl_pressed:
135
+ # Ctrl + クリック: 追加または解除
136
+ if visited.issubset(move_group_dialog.group_atoms):
137
+ # すでに選択されている - 解除
138
+ move_group_dialog.group_atoms -= visited
139
+ else:
140
+ # 新しいグループを追加
141
+ move_group_dialog.group_atoms |= visited
142
+ else:
143
+ # 通常のクリック: 既存の選択を置き換え
144
+ move_group_dialog.group_atoms = visited.copy()
145
+
146
+ move_group_dialog.selected_atoms.add(clicked_atom_idx)
147
+ move_group_dialog.show_atom_labels()
148
+ move_group_dialog.update_display()
149
+ return
150
+ else:
151
+ # 原子以外をクリック - 全選択を解除
152
+ move_group_dialog.group_atoms.clear()
153
+ move_group_dialog.selected_atoms.clear()
154
+ move_group_dialog.clear_atom_labels()
155
+ move_group_dialog.update_display()
156
+ # カメラ回転を許可
157
+ super(CustomInteractorStyle, self).OnLeftButtonDown()
158
+ return
159
+
160
+ is_temp_mode = bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
161
+ is_edit_active = mw.is_3d_edit_mode or is_temp_mode
162
+
163
+ # Ctrl+クリックで原子選択(3D編集用)
164
+ is_ctrl_click = bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier)
165
+
166
+ # 測定モードが有効な場合の処理
167
+ if mw.measurement_mode and mw.current_mol:
168
+ click_pos = self.GetInteractor().GetEventPosition()
169
+ self._mouse_press_pos = click_pos # マウスプレス位置を記録
170
+ self._mouse_moved_during_drag = False # 移動フラグをリセット
171
+
172
+ picker = mw.plotter.picker
173
+
174
+ # 通常のピック処理を実行
175
+ picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
176
+
177
+ # 原子がクリックされた場合のみ特別処理
178
+ if picker.GetActor() is mw.atom_actor:
179
+ picked_position = np.array(picker.GetPickPosition())
180
+ distances = np.linalg.norm(mw.atom_positions_3d - picked_position, axis=1)
181
+ closest_atom_idx = np.argmin(distances)
182
+
183
+ # 範囲チェックを追加
184
+ if 0 <= closest_atom_idx < mw.current_mol.GetNumAtoms():
185
+ # クリック閾値チェック
186
+ atom = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx))
187
+ if atom:
188
+ atomic_num = atom.GetAtomicNum()
189
+ vdw_radius = pt.GetRvdw(atomic_num)
190
+ click_threshold = vdw_radius * 1.5
191
+
192
+ if distances[closest_atom_idx] < click_threshold:
193
+ mw.handle_measurement_atom_selection(int(closest_atom_idx))
194
+ return # 原子選択処理完了、カメラ回転は無効
195
+
196
+ # 測定モードで原子以外をクリックした場合は計測選択をクリア
197
+ # ただし、これは通常のカメラ回転も許可する
198
+ self._is_dragging_atom = False
199
+ super().OnLeftButtonDown()
200
+ return
201
+
202
+ # Ctrl+クリックの原子選択機能は無効化(Move Group機能で代替)
203
+ # if is_ctrl_click and mw.current_mol:
204
+ # ... (無効化)
205
+
206
+ # 3D分子(mw.current_mol)が存在する場合のみ、原子の選択処理を実行
207
+ if is_edit_active and mw.current_mol:
208
+ click_pos = self.GetInteractor().GetEventPosition()
209
+ picker = mw.plotter.picker
210
+ picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
211
+
212
+ if picker.GetActor() is mw.atom_actor:
213
+ picked_position = np.array(picker.GetPickPosition())
214
+ distances = np.linalg.norm(mw.atom_positions_3d - picked_position, axis=1)
215
+ closest_atom_idx = np.argmin(distances)
216
+
217
+ # 範囲チェックを追加
218
+ if 0 <= closest_atom_idx < mw.current_mol.GetNumAtoms():
219
+ # RDKitのMolオブジェクトから原子を安全に取得
220
+ atom = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx))
221
+ if atom:
222
+ atomic_num = atom.GetAtomicNum()
223
+ vdw_radius = pt.GetRvdw(atomic_num)
224
+ click_threshold = vdw_radius * 1.5
225
+
226
+ if distances[closest_atom_idx] < click_threshold:
227
+ # 原子を掴むことに成功した場合
228
+ self._is_dragging_atom = True
229
+ self.is_dragging = False
230
+ mw.dragged_atom_info = {'id': int(closest_atom_idx)}
231
+ mw.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
232
+ return # 親クラスのカメラ回転を呼ばない
233
+
234
+ self._is_dragging_atom = False
235
+ super().OnLeftButtonDown()
236
+
237
+ def on_right_button_down(self, obj, event):
238
+ """
239
+ 右クリック時の処理。Move Groupダイアログが開いている場合はグループ回転を開始。
240
+ """
241
+ mw = self.main_window
242
+
243
+ # Move Groupダイアログが開いているか確認
244
+ move_group_dialog = None
245
+ try:
246
+ for widget in QApplication.topLevelWidgets():
247
+ if isinstance(widget, MoveGroupDialog) and widget.isVisible():
248
+ move_group_dialog = widget
249
+ break
250
+ except Exception:
251
+ pass
252
+
253
+ if move_group_dialog and move_group_dialog.group_atoms:
254
+ # グループが選択されている場合、回転ドラッグを開始
255
+ click_pos = self.GetInteractor().GetEventPosition()
256
+ picker = mw.plotter.picker
257
+ picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
258
+
259
+ clicked_atom_idx = None
260
+ if picker.GetActor() is mw.atom_actor:
261
+ picked_position = np.array(picker.GetPickPosition())
262
+ distances = np.linalg.norm(mw.atom_positions_3d - picked_position, axis=1)
263
+ closest_atom_idx = np.argmin(distances)
264
+
265
+ if 0 <= closest_atom_idx < mw.current_mol.GetNumAtoms():
266
+ atom = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx))
267
+ if atom:
268
+ atomic_num = atom.GetAtomicNum()
269
+ vdw_radius = pt.GetRvdw(atomic_num)
270
+ click_threshold = vdw_radius * 1.5
271
+
272
+ if distances[closest_atom_idx] < click_threshold:
273
+ clicked_atom_idx = int(closest_atom_idx)
274
+
275
+ # グループ内の原子がクリックされた場合、回転ドラッグを開始
276
+ if clicked_atom_idx is not None and clicked_atom_idx in move_group_dialog.group_atoms:
277
+ move_group_dialog._is_rotating_group_vtk = True
278
+ move_group_dialog._rotation_start_pos = click_pos
279
+ move_group_dialog._rotation_mouse_moved = False
280
+ move_group_dialog._rotation_atom_idx = clicked_atom_idx # 掴んだ原子を記録
281
+
282
+ # 初期位置と重心を保存
283
+ move_group_dialog._initial_positions = {}
284
+ conf = mw.current_mol.GetConformer()
285
+ centroid = np.zeros(3)
286
+ for atom_idx in move_group_dialog.group_atoms:
287
+ pos = conf.GetAtomPosition(atom_idx)
288
+ pos_array = np.array([pos.x, pos.y, pos.z])
289
+ move_group_dialog._initial_positions[atom_idx] = pos_array
290
+ centroid += pos_array
291
+ centroid /= len(move_group_dialog.group_atoms)
292
+ move_group_dialog._group_centroid = centroid
293
+
294
+ mw.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
295
+ return # カメラ回転を無効化
296
+
297
+ # 通常の右クリック処理
298
+ super().OnRightButtonDown()
299
+
300
+ def on_mouse_move(self, obj, event):
301
+ """
302
+ マウス移動時の処理。原子ドラッグ中か、それ以外(カメラ回転+ホバー)かをハンドリングします。
303
+ """
304
+ mw = self.main_window
305
+
306
+ # Move Groupダイアログのドラッグ処理
307
+ move_group_dialog = None
308
+ try:
309
+ for widget in QApplication.topLevelWidgets():
310
+ if isinstance(widget, MoveGroupDialog) and widget.isVisible():
311
+ move_group_dialog = widget
312
+ break
313
+ except Exception:
314
+ pass
315
+
316
+ if move_group_dialog and getattr(move_group_dialog, '_is_dragging_group_vtk', False):
317
+ # グループをドラッグ中 - 移動距離を記録するのみ
318
+ interactor = self.GetInteractor()
319
+ current_pos = interactor.GetEventPosition()
320
+
321
+ dx = current_pos[0] - move_group_dialog._drag_start_pos[0]
322
+ dy = current_pos[1] - move_group_dialog._drag_start_pos[1]
323
+
324
+ if abs(dx) > 2 or abs(dy) > 2:
325
+ move_group_dialog._mouse_moved = True
326
+
327
+ return # カメラ回転を無効化
328
+
329
+ # グループ回転中の処理
330
+ if move_group_dialog and getattr(move_group_dialog, '_is_rotating_group_vtk', False):
331
+ interactor = self.GetInteractor()
332
+ current_pos = interactor.GetEventPosition()
333
+
334
+ dx = current_pos[0] - move_group_dialog._rotation_start_pos[0]
335
+ dy = current_pos[1] - move_group_dialog._rotation_start_pos[1]
336
+
337
+ if abs(dx) > 2 or abs(dy) > 2:
338
+ move_group_dialog._rotation_mouse_moved = True
339
+
340
+ return # カメラ回転を無効化
341
+
342
+ interactor = self.GetInteractor()
343
+
344
+ # マウス移動があったことを記録
345
+ if self._mouse_press_pos is not None:
346
+ current_pos = interactor.GetEventPosition()
347
+ if abs(current_pos[0] - self._mouse_press_pos[0]) > 3 or abs(current_pos[1] - self._mouse_press_pos[1]) > 3:
348
+ self._mouse_moved_during_drag = True
349
+
350
+ if self._is_dragging_atom and mw.dragged_atom_info is not None:
351
+ # カスタムの原子ドラッグ処理
352
+ self.is_dragging = True
353
+ atom_id = mw.dragged_atom_info['id']
354
+ # We intentionally do NOT update visible coordinates or the
355
+ # authoritative atom position during mouse-move while dragging.
356
+ # The UX requirement here is that atoms need not visibly move
357
+ # while the mouse is being dragged. Compute and apply the final
358
+ # world-coordinate only once on mouse release (on_left_button_up).
359
+ # Keep minimal state: mark that a drag occurred (is_dragging)
360
+ # and allow the release handler to compute the final position.
361
+ # This avoids duplicate updates and simplifies event ordering.
362
+ else:
363
+ # カメラ回転処理を親クラスに任せます
364
+ super().OnMouseMove()
365
+
366
+ # その後、カーソルの表示を更新します
367
+ is_edit_active = mw.is_3d_edit_mode or interactor.GetAltKey()
368
+ if is_edit_active:
369
+ # 編集がアクティブな場合のみ、原子のホバーチェックを行う
370
+ atom_under_cursor = False
371
+ click_pos = interactor.GetEventPosition()
372
+ picker = mw.plotter.picker
373
+ picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
374
+ if picker.GetActor() is mw.atom_actor:
375
+ atom_under_cursor = True
376
+
377
+ if atom_under_cursor:
378
+ mw.plotter.setCursor(Qt.CursorShape.OpenHandCursor)
379
+ else:
380
+ mw.plotter.setCursor(Qt.CursorShape.ArrowCursor)
381
+ else:
382
+ mw.plotter.setCursor(Qt.CursorShape.ArrowCursor)
383
+
384
+ def on_left_button_up(self, obj, event):
385
+ """
386
+ クリック終了時の処理。状態をリセットします。
387
+ """
388
+ mw = self.main_window
389
+
390
+ # Move Groupダイアログのドラッグ終了処理
391
+ move_group_dialog = None
392
+ try:
393
+ for widget in QApplication.topLevelWidgets():
394
+ if isinstance(widget, MoveGroupDialog) and widget.isVisible():
395
+ move_group_dialog = widget
396
+ break
397
+ except Exception:
398
+ pass
399
+
400
+ # ダブルクリック/トリプルクリックで状態が混乱するのを防ぐ(Move Group用)
401
+ if move_group_dialog:
402
+ if getattr(move_group_dialog, '_is_dragging_group_vtk', False) and not getattr(move_group_dialog, '_mouse_moved', False):
403
+ # ドラッグしていない状態で複数クリックされた場合は状態をリセット
404
+ move_group_dialog._is_dragging_group_vtk = False
405
+ move_group_dialog._drag_start_pos = None
406
+ move_group_dialog._mouse_moved = False
407
+ if hasattr(move_group_dialog, '_initial_positions'):
408
+ delattr(move_group_dialog, '_initial_positions')
409
+
410
+ if move_group_dialog and getattr(move_group_dialog, '_is_dragging_group_vtk', False):
411
+ if getattr(move_group_dialog, '_mouse_moved', False):
412
+ # ドラッグが実行された - リリース時に座標を更新
413
+ try:
414
+ interactor = self.GetInteractor()
415
+ renderer = mw.plotter.renderer
416
+ current_pos = interactor.GetEventPosition()
417
+ conf = mw.current_mol.GetConformer()
418
+
419
+ # ドラッグ原子の初期位置
420
+ drag_atom_initial_pos = move_group_dialog._initial_positions[move_group_dialog._drag_atom_idx]
421
+
422
+ # スクリーン座標からワールド座標への変換
423
+ renderer.SetWorldPoint(drag_atom_initial_pos[0], drag_atom_initial_pos[1], drag_atom_initial_pos[2], 1.0)
424
+ renderer.WorldToDisplay()
425
+ display_coords = renderer.GetDisplayPoint()
426
+
427
+ new_display_pos = (current_pos[0], current_pos[1], display_coords[2])
428
+ renderer.SetDisplayPoint(new_display_pos[0], new_display_pos[1], new_display_pos[2])
429
+ renderer.DisplayToWorld()
430
+ new_world_coords = renderer.GetWorldPoint()
431
+
432
+ # 移動ベクトル
433
+ translation_vector = np.array([
434
+ new_world_coords[0] - drag_atom_initial_pos[0],
435
+ new_world_coords[1] - drag_atom_initial_pos[1],
436
+ new_world_coords[2] - drag_atom_initial_pos[2]
437
+ ])
438
+
439
+ # グループ全体を移動
440
+ for atom_idx in move_group_dialog.group_atoms:
441
+ initial_pos = move_group_dialog._initial_positions[atom_idx]
442
+ new_pos = initial_pos + translation_vector
443
+ conf.SetAtomPosition(atom_idx, new_pos.tolist())
444
+ mw.atom_positions_3d[atom_idx] = new_pos
445
+
446
+ # 3D表示を更新
447
+ mw.draw_molecule_3d(mw.current_mol)
448
+ mw.update_chiral_labels()
449
+ move_group_dialog.show_atom_labels()
450
+ mw.push_undo_state()
451
+ except Exception as e:
452
+ print(f"Error finalizing group drag: {e}")
453
+ else:
454
+ # ドラッグがなかった = クリックのみ → トグル処理
455
+ if hasattr(move_group_dialog, '_drag_atom_idx'):
456
+ clicked_atom = move_group_dialog._drag_atom_idx
457
+ try:
458
+ move_group_dialog.on_atom_picked(clicked_atom)
459
+ except Exception as e:
460
+ print(f"Error in toggle: {e}")
461
+
462
+ # 状態をリセット(完全なクリーンアップ)
463
+ move_group_dialog._is_dragging_group_vtk = False
464
+ move_group_dialog._drag_start_pos = None
465
+ move_group_dialog._mouse_moved = False
466
+ if hasattr(move_group_dialog, '_initial_positions'):
467
+ delattr(move_group_dialog, '_initial_positions')
468
+ if hasattr(move_group_dialog, '_drag_atom_idx'):
469
+ delattr(move_group_dialog, '_drag_atom_idx')
470
+
471
+ # CustomInteractorStyleの状態もクリア
472
+ self._is_dragging_atom = False
473
+ self.is_dragging = False
474
+ self._mouse_moved_during_drag = False
475
+ self._mouse_press_pos = None
476
+
477
+ try:
478
+ mw.plotter.setCursor(Qt.CursorShape.ArrowCursor)
479
+ except Exception:
480
+ pass
481
+ return
482
+
483
+ # 計測モードで、マウスが動いていない場合(つまりクリック)の処理
484
+ if mw.measurement_mode and not self._mouse_moved_during_drag and self._mouse_press_pos is not None:
485
+ click_pos = self.GetInteractor().GetEventPosition()
486
+ picker = mw.plotter.picker
487
+ picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
488
+
489
+ # 原子がクリックされていない場合は測定選択をクリア
490
+ if picker.GetActor() is not mw.atom_actor:
491
+ mw.clear_measurement_selection()
492
+
493
+ if self._is_dragging_atom:
494
+ # カスタムドラッグの後始末
495
+ if self.is_dragging:
496
+ if mw.current_mol and mw.current_mol.GetNumConformers() > 0:
497
+ try:
498
+ # Before applying conformer updates, compute the final
499
+ # world coordinates for the dragged atom based on the
500
+ # release pointer position. During the drag we did not
501
+ # update mw.atom_positions_3d (to keep the visuals
502
+ # static). Now compute the final position for the
503
+ # dragged atom and store it into mw.atom_positions_3d
504
+ # so the conformer update loop below will pick it up.
505
+ atom_id = None
506
+ try:
507
+ atom_id = mw.dragged_atom_info.get('id') if mw.dragged_atom_info else None
508
+ except Exception:
509
+ atom_id = None
510
+
511
+ if atom_id is not None:
512
+ try:
513
+ interactor = self.GetInteractor()
514
+ renderer = mw.plotter.renderer
515
+ current_display_pos = interactor.GetEventPosition()
516
+ conf = mw.current_mol.GetConformer()
517
+ # Use the atom's current 3D position to obtain a
518
+ # display-space depth (z) value, then replace the
519
+ # x/y with the pointer position to project back to
520
+ # world coordinates at that depth.
521
+ pos_3d = conf.GetAtomPosition(atom_id)
522
+ renderer.SetWorldPoint(pos_3d.x, pos_3d.y, pos_3d.z, 1.0)
523
+ renderer.WorldToDisplay()
524
+ display_coords = renderer.GetDisplayPoint()
525
+ new_display_pos = (current_display_pos[0], current_display_pos[1], display_coords[2])
526
+ renderer.SetDisplayPoint(new_display_pos[0], new_display_pos[1], new_display_pos[2])
527
+ renderer.DisplayToWorld()
528
+ new_world_coords_tuple = renderer.GetWorldPoint()
529
+ new_world_coords = list(new_world_coords_tuple)[:3]
530
+ # Ensure the container supports assignment
531
+ try:
532
+ mw.atom_positions_3d[atom_id] = new_world_coords
533
+ except Exception:
534
+ # If atom_positions_3d is immutable or shaped
535
+ # differently, attempt a safe conversion.
536
+ try:
537
+ ap = list(mw.atom_positions_3d)
538
+ ap[atom_id] = new_world_coords
539
+ mw.atom_positions_3d = ap
540
+ except Exception:
541
+ pass
542
+ except Exception:
543
+ # If final-position computation fails, continue
544
+ # and apply whatever state is available.
545
+ pass
546
+
547
+ # Apply the (now updated) positions to the RDKit conformer
548
+ # exactly once. This ensures the conformer is
549
+ # authoritative and avoids double-moves.
550
+ conf = mw.current_mol.GetConformer()
551
+ for i in range(mw.current_mol.GetNumAtoms()):
552
+ try:
553
+ pos = mw.atom_positions_3d[i]
554
+ conf.SetAtomPosition(i, pos.tolist())
555
+ except Exception:
556
+ # Skip individual failures but continue applying
557
+ # other atom positions.
558
+ pass
559
+ except Exception:
560
+ # If applying positions fails, continue to redraw from
561
+ # whatever authoritative state is available.
562
+ pass
563
+
564
+ # Redraw once and push undo state
565
+ try:
566
+ mw.draw_molecule_3d(mw.current_mol)
567
+ except Exception:
568
+ pass
569
+ mw.push_undo_state()
570
+ mw.dragged_atom_info = None
571
+ # Refresh overlays and labels that depend on atom_positions_3d. Do
572
+ # not overwrite mw.atom_positions_3d here — it already reflects the
573
+ # positions the user dragged to. Only update dependent displays.
574
+ try:
575
+ mw.update_3d_selection_display()
576
+ except Exception:
577
+ pass
578
+ try:
579
+ mw.update_measurement_labels_display()
580
+ except Exception:
581
+ pass
582
+ try:
583
+ mw.update_2d_measurement_labels()
584
+ except Exception:
585
+ pass
586
+ try:
587
+ mw.show_all_atom_info()
588
+ except Exception:
589
+ pass
590
+ except Exception:
591
+ # Do not allow a failure here to interrupt release flow
592
+ pass
593
+ else:
594
+ # カメラ回転の後始末を親クラスに任せます
595
+ super().OnLeftButtonUp()
596
+
597
+ # 状態をリセット(完全なクリーンアップ)
598
+ self._is_dragging_atom = False
599
+ self.is_dragging = False
600
+ self._mouse_press_pos = None
601
+ self._mouse_moved_during_drag = False
602
+
603
+ # Move Group関連の状態もクリア
604
+ try:
605
+ move_group_dialog = None
606
+ for widget in QApplication.topLevelWidgets():
607
+ if isinstance(widget, MoveGroupDialog) and widget.isVisible():
608
+ move_group_dialog = widget
609
+ break
610
+
611
+ if move_group_dialog:
612
+ move_group_dialog._is_dragging_group_vtk = False
613
+ move_group_dialog._drag_start_pos = None
614
+ move_group_dialog._mouse_moved = False
615
+ if hasattr(move_group_dialog, '_initial_positions'):
616
+ delattr(move_group_dialog, '_initial_positions')
617
+ if hasattr(move_group_dialog, '_drag_atom_idx'):
618
+ delattr(move_group_dialog, '_drag_atom_idx')
619
+ except Exception:
620
+ pass
621
+
622
+ # ピックリセットは測定モードで実際に問題が発生した場合のみ行う
623
+ # (通常のドラッグ回転では行わない)
624
+
625
+ # ボタンを離した後のカーソル表示を最新の状態に更新
626
+ self.on_mouse_move(obj, event)
627
+
628
+ # 2Dビューにフォーカスを戻し、ショートカットキーなどが使えるようにする
629
+ if mw and mw.view_2d:
630
+ mw.view_2d.setFocus()
631
+
632
+ def on_right_button_up(self, obj, event):
633
+ """
634
+ 右クリック終了時の処理。グループ回転を確定。
635
+ """
636
+ mw = self.main_window
637
+
638
+ # Move Groupダイアログの回転終了処理
639
+ move_group_dialog = None
640
+ try:
641
+ for widget in QApplication.topLevelWidgets():
642
+ if isinstance(widget, MoveGroupDialog) and widget.isVisible():
643
+ move_group_dialog = widget
644
+ break
645
+ except Exception:
646
+ pass
647
+
648
+ if move_group_dialog and getattr(move_group_dialog, '_is_rotating_group_vtk', False):
649
+ # 回転モードで右クリックリリース - 選択を保持
650
+ if getattr(move_group_dialog, '_rotation_mouse_moved', False):
651
+ # 回転が実行された - リリース時に回転を適用
652
+ try:
653
+ interactor = self.GetInteractor()
654
+ renderer = mw.plotter.renderer
655
+ current_pos = interactor.GetEventPosition()
656
+ conf = mw.current_mol.GetConformer()
657
+ centroid = move_group_dialog._group_centroid
658
+
659
+ # 掴んだ原子の初期位置
660
+ if not hasattr(move_group_dialog, '_rotation_atom_idx'):
661
+ # 最初に掴んだ原子のインデックスを保存
662
+ move_group_dialog._rotation_atom_idx = next(iter(move_group_dialog.group_atoms))
663
+
664
+ grabbed_atom_idx = move_group_dialog._rotation_atom_idx
665
+ grabbed_initial_pos = move_group_dialog._initial_positions[grabbed_atom_idx]
666
+
667
+ # 開始位置のスクリーン座標を取得
668
+ renderer.SetWorldPoint(grabbed_initial_pos[0], grabbed_initial_pos[1], grabbed_initial_pos[2], 1.0)
669
+ renderer.WorldToDisplay()
670
+ start_display = renderer.GetDisplayPoint()
671
+
672
+ # 現在のマウス位置をワールド座標に変換(同じ深度で)
673
+ renderer.SetDisplayPoint(current_pos[0], current_pos[1], start_display[2])
674
+ renderer.DisplayToWorld()
675
+ target_world = renderer.GetWorldPoint()
676
+ target_pos = np.array([target_world[0], target_world[1], target_world[2]])
677
+
678
+ # 重心から見た、掴んだ原子の初期ベクトルと目標ベクトル
679
+ v1 = grabbed_initial_pos - centroid
680
+ v2 = target_pos - centroid
681
+
682
+ # ベクトルを正規化
683
+ v1_norm = np.linalg.norm(v1)
684
+ v2_norm = np.linalg.norm(v2)
685
+
686
+ if v1_norm > 1e-6 and v2_norm > 1e-6:
687
+ v1_normalized = v1 / v1_norm
688
+ v2_normalized = v2 / v2_norm
689
+
690
+ # 回転軸(外積)
691
+ rotation_axis = np.cross(v1_normalized, v2_normalized)
692
+ axis_norm = np.linalg.norm(rotation_axis)
693
+
694
+ if axis_norm > 1e-6:
695
+ rotation_axis = rotation_axis / axis_norm
696
+
697
+ # 回転角(内積)
698
+ cos_angle = np.clip(np.dot(v1_normalized, v2_normalized), -1.0, 1.0)
699
+ angle = np.arccos(cos_angle)
700
+
701
+ # Rodriguesの回転公式で回転行列を作成
702
+ K = np.array([
703
+ [0, -rotation_axis[2], rotation_axis[1]],
704
+ [rotation_axis[2], 0, -rotation_axis[0]],
705
+ [-rotation_axis[1], rotation_axis[0], 0]
706
+ ])
707
+
708
+ rot_matrix = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
709
+
710
+ # グループ全体を重心周りに回転
711
+ for atom_idx in move_group_dialog.group_atoms:
712
+ initial_pos = move_group_dialog._initial_positions[atom_idx]
713
+ # 重心からの相対座標
714
+ relative_pos = initial_pos - centroid
715
+ # 回転を適用
716
+ rotated_pos = rot_matrix @ relative_pos
717
+ # 絶対座標に戻す
718
+ new_pos = rotated_pos + centroid
719
+
720
+ conf.SetAtomPosition(atom_idx, new_pos.tolist())
721
+ mw.atom_positions_3d[atom_idx] = new_pos
722
+
723
+ # 3D表示を更新
724
+ mw.draw_molecule_3d(mw.current_mol)
725
+ mw.update_chiral_labels()
726
+ move_group_dialog.show_atom_labels()
727
+ mw.push_undo_state()
728
+ except Exception as e:
729
+ print(f"Error finalizing group rotation: {e}")
730
+
731
+ # 状態をリセット
732
+ move_group_dialog._is_rotating_group_vtk = False
733
+ move_group_dialog._rotation_start_pos = None
734
+ move_group_dialog._rotation_mouse_moved = False
735
+ if hasattr(move_group_dialog, '_initial_positions'):
736
+ delattr(move_group_dialog, '_initial_positions')
737
+ if hasattr(move_group_dialog, '_group_centroid'):
738
+ delattr(move_group_dialog, '_group_centroid')
739
+ if hasattr(move_group_dialog, '_rotation_atom_idx'):
740
+ delattr(move_group_dialog, '_rotation_atom_idx')
741
+
742
+ try:
743
+ mw.plotter.setCursor(Qt.CursorShape.ArrowCursor)
744
+ except Exception:
745
+ pass
746
+ return
747
+
748
+ # 通常の右クリックリリース処理
749
+ super().OnRightButtonUp()