MoleditPy-linux 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) 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 +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ """
14
+ main_window_app_state.py
15
+ MainWindow (main_window.py) から分離されたモジュール
16
+ 機能クラス: MainWindowAppState
17
+ """
18
+
19
+
20
+ import numpy as np
21
+ import copy
22
+ import os
23
+ import base64
24
+
25
+
26
+ # RDKit imports (explicit to satisfy flake8 and used features)
27
+ from rdkit import Chem
28
+ from rdkit.Chem import Descriptors, rdMolDescriptors
29
+ try:
30
+ pass
31
+ except Exception:
32
+ pass
33
+
34
+ # PyQt6 Modules
35
+ from PyQt6.QtWidgets import (
36
+ QMessageBox
37
+ )
38
+
39
+
40
+
41
+ from PyQt6.QtCore import (
42
+ Qt, QPointF, QDateTime
43
+ )
44
+
45
+
46
+ # Use centralized Open Babel availability from package-level __init__
47
+ # Use per-package modules availability (local __init__).
48
+ try:
49
+ from . import OBABEL_AVAILABLE
50
+ except Exception:
51
+ from modules import OBABEL_AVAILABLE
52
+ # Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
53
+ if OBABEL_AVAILABLE:
54
+ try:
55
+ from openbabel import pybel
56
+ except Exception:
57
+ # If import fails here, disable OBABEL locally; avoid raising
58
+ pybel = None
59
+ OBABEL_AVAILABLE = False
60
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
61
+ else:
62
+ pybel = None
63
+
64
+ # Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
65
+ # allows safely detecting C++ wrapper objects that have been deleted. Import
66
+ # it once at module import time and expose a small, robust wrapper so callers
67
+ # can avoid re-importing sip repeatedly and so we centralize exception
68
+ # handling (this reduces crash risk during teardown and deletion operations).
69
+ try:
70
+ import sip as _sip # type: ignore
71
+ _sip_isdeleted = getattr(_sip, 'isdeleted', None)
72
+ except Exception:
73
+ _sip = None
74
+ _sip_isdeleted = None
75
+
76
+ try:
77
+ # package relative imports (preferred when running as `python -m moleditpy`)
78
+ from .constants import VERSION
79
+ from .atom_item import AtomItem
80
+ from .bond_item import BondItem
81
+ except Exception:
82
+ # Fallback to absolute imports for script-style execution
83
+ from modules.constants import VERSION
84
+ from modules.atom_item import AtomItem
85
+ from modules.bond_item import BondItem
86
+
87
+
88
+ # --- クラス定義 ---
89
+ class MainWindowAppState(object):
90
+ """ main_window.py から分離された機能クラス """
91
+
92
+ def __init__(self):
93
+ """
94
+ クラスの初期化
95
+ BoundFeature経由で呼ばれるため、'self' には MainWindow インスタンスが渡されます。
96
+ """
97
+ self.DEBUG_UNDO = False
98
+
99
+
100
+
101
+ def get_current_state(self):
102
+ atoms = {atom_id: {'symbol': data['symbol'],
103
+ 'pos': (data['item'].pos().x(), data['item'].pos().y()),
104
+ 'charge': data.get('charge', 0),
105
+ 'radical': data.get('radical', 0)}
106
+ for atom_id, data in self.data.atoms.items()}
107
+ bonds = {key: {'order': data['order'], 'stereo': data.get('stereo', 0)} for key, data in self.data.bonds.items()}
108
+ state = {'atoms': atoms, 'bonds': bonds, '_next_atom_id': self.data._next_atom_id}
109
+
110
+ state['version'] = VERSION
111
+
112
+ if self.current_mol: state['mol_3d'] = self.current_mol.ToBinary()
113
+
114
+ state['is_3d_viewer_mode'] = not self.is_2d_editable
115
+
116
+ json_safe_constraints = []
117
+ try:
118
+ for const in self.constraints_3d:
119
+ # (Type, (Idx...), Value, Force) -> [Type, [Idx...], Value, Force]
120
+ if len(const) == 4:
121
+ json_safe_constraints.append([const[0], list(const[1]), const[2], const[3]])
122
+ else:
123
+ # 後方互換性: 3要素の場合はデフォルトForceを追加
124
+ json_safe_constraints.append([const[0], list(const[1]), const[2], 1.0e5])
125
+ except Exception:
126
+ pass # 失敗したら空リスト
127
+ state['constraints_3d'] = json_safe_constraints
128
+
129
+ return state
130
+
131
+
132
+
133
+ def set_state_from_data(self, state_data):
134
+ self.dragged_atom_info = None
135
+ self.clear_2d_editor(push_to_undo=False)
136
+
137
+ loaded_data = copy.deepcopy(state_data)
138
+
139
+ # ファイルのバージョンを取得(存在しない場合は '0.0.0' とする)
140
+ file_version_str = loaded_data.get('version', '0.0.0')
141
+
142
+ try:
143
+ app_version_parts = tuple(map(int, VERSION.split('.')))
144
+ file_version_parts = tuple(map(int, file_version_str.split('.')))
145
+
146
+ # ファイルのバージョンがアプリケーションのバージョンより新しい場合に警告
147
+ if file_version_parts > app_version_parts:
148
+ QMessageBox.warning(
149
+ self,
150
+ "Version Mismatch",
151
+ f"The file you are opening was saved with a newer version of MoleditPy (ver. {file_version_str}).\n\n"
152
+ f"Your current version is {VERSION}.\n\n"
153
+ "Some features may not load or work correctly."
154
+ )
155
+ except (ValueError, AttributeError):
156
+ pass
157
+
158
+ raw_atoms = loaded_data.get('atoms', {})
159
+ raw_bonds = loaded_data.get('bonds', {})
160
+
161
+ # 制約データの復元 (pmeraw)
162
+ try:
163
+ loaded_constraints = loaded_data.get("constraints_3d", [])
164
+ # pmerawもJSON互換形式 [Type, [Idx...], Value, Force] で保存されている想定
165
+ self.constraints_3d = []
166
+ for const in loaded_constraints:
167
+ if isinstance(const, list):
168
+ if len(const) == 4:
169
+ # [Type, [Idx...], Value, Force] -> (Type, (Idx...), Value, Force)
170
+ self.constraints_3d.append((const[0], tuple(const[1]), const[2], const[3]))
171
+ elif len(const) == 3:
172
+ # 後方互換性: [Type, [Idx...], Value] -> (Type, (Idx...), Value, 1.0e5)
173
+ self.constraints_3d.append((const[0], tuple(const[1]), const[2], 1.0e5))
174
+ except Exception:
175
+ self.constraints_3d = [] # 読み込み失敗時はリセット
176
+
177
+ for atom_id, data in raw_atoms.items():
178
+ pos = QPointF(data['pos'][0], data['pos'][1])
179
+ charge = data.get('charge', 0)
180
+ radical = data.get('radical', 0) # <-- ラジカル情報を取得
181
+ # AtomItem生成時にradicalを渡す
182
+ atom_item = AtomItem(atom_id, data['symbol'], pos, charge=charge, radical=radical)
183
+ # self.data.atomsにもradical情報を格納する
184
+ self.data.atoms[atom_id] = {'symbol': data['symbol'], 'pos': pos, 'item': atom_item, 'charge': charge, 'radical': radical}
185
+ self.scene.addItem(atom_item)
186
+
187
+ self.data._next_atom_id = loaded_data.get('_next_atom_id', max(self.data.atoms.keys()) + 1 if self.data.atoms else 0)
188
+
189
+ for key_tuple, data in raw_bonds.items():
190
+ id1, id2 = key_tuple
191
+ if id1 in self.data.atoms and id2 in self.data.atoms:
192
+ atom1_item = self.data.atoms[id1]['item']; atom2_item = self.data.atoms[id2]['item']
193
+ bond_item = BondItem(atom1_item, atom2_item, data.get('order', 1), data.get('stereo', 0))
194
+ self.data.bonds[key_tuple] = {'order': data.get('order', 1), 'stereo': data.get('stereo', 0), 'item': bond_item}
195
+ atom1_item.bonds.append(bond_item); atom2_item.bonds.append(bond_item)
196
+ self.scene.addItem(bond_item)
197
+
198
+ for atom_data in self.data.atoms.values():
199
+ if atom_data['item']: atom_data['item'].update_style()
200
+ self.scene.update()
201
+
202
+ if 'mol_3d' in loaded_data and loaded_data['mol_3d'] is not None:
203
+ try:
204
+ self.current_mol = Chem.Mol(loaded_data['mol_3d'])
205
+ # デバッグ:3D構造が有効かチェック
206
+ if self.current_mol and self.current_mol.GetNumAtoms() > 0:
207
+ self.draw_molecule_3d(self.current_mol)
208
+ self.plotter.reset_camera()
209
+ # 3D関連機能を統一的に有効化
210
+ self._enable_3d_features(True)
211
+
212
+ # 3D原子情報ホバー表示を再設定
213
+ self.setup_3d_hover()
214
+ else:
215
+ # 無効な3D構造の場合
216
+ self.current_mol = None
217
+ self.plotter.clear()
218
+ # 3D関連機能を統一的に無効化
219
+ self._enable_3d_features(False)
220
+ except Exception as e:
221
+ self.statusBar().showMessage(f"Could not load 3D model from project: {e}")
222
+ self.current_mol = None
223
+ # 3D関連機能を統一的に無効化
224
+ self._enable_3d_features(False)
225
+ else:
226
+ self.current_mol = None; self.plotter.clear(); self.analysis_action.setEnabled(False)
227
+ self.optimize_3d_button.setEnabled(False)
228
+ # 3D関連機能を統一的に無効化
229
+ self._enable_3d_features(False)
230
+
231
+ self.update_implicit_hydrogens()
232
+ self.update_chiral_labels()
233
+
234
+ if loaded_data.get('is_3d_viewer_mode', False):
235
+ self._enter_3d_viewer_ui_mode()
236
+ self.statusBar().showMessage("Project loaded in 3D Viewer Mode.")
237
+ else:
238
+ self.restore_ui_for_editing()
239
+ # 3D分子がある場合は、2Dエディタモードでも3D編集機能を有効化
240
+ if self.current_mol and self.current_mol.GetNumAtoms() > 0:
241
+ self._enable_3d_edit_actions(True)
242
+
243
+ # undo/redo後に測定ラベルの位置を更新
244
+ self.update_2d_measurement_labels()
245
+
246
+
247
+
248
+
249
+ def push_undo_state(self):
250
+ if self._is_restoring_state:
251
+ return
252
+
253
+ current_state_for_comparison = {
254
+ 'atoms': {k: (v['symbol'], v['item'].pos().x(), v['item'].pos().y(), v.get('charge', 0), v.get('radical', 0)) for k, v in self.data.atoms.items()},
255
+ 'bonds': {k: (v['order'], v.get('stereo', 0)) for k, v in self.data.bonds.items()},
256
+ '_next_atom_id': self.data._next_atom_id,
257
+ 'mol_3d': self.current_mol.ToBinary() if self.current_mol else None
258
+ }
259
+
260
+ last_state_for_comparison = None
261
+ if self.undo_stack:
262
+ last_state = self.undo_stack[-1]
263
+ last_atoms = last_state.get('atoms', {})
264
+ last_bonds = last_state.get('bonds', {})
265
+ last_state_for_comparison = {
266
+ 'atoms': {k: (v['symbol'], v['pos'][0], v['pos'][1], v.get('charge', 0), v.get('radical', 0)) for k, v in last_atoms.items()},
267
+ 'bonds': {k: (v['order'], v.get('stereo', 0)) for k, v in last_bonds.items()},
268
+ '_next_atom_id': last_state.get('_next_atom_id'),
269
+ 'mol_3d': last_state.get('mol_3d', None)
270
+ }
271
+
272
+ if not last_state_for_comparison or current_state_for_comparison != last_state_for_comparison:
273
+ # Deepcopy state to ensure saved states are immutable and not affected
274
+ # by later modifications to objects referenced from the state.
275
+ state = copy.deepcopy(self.get_current_state())
276
+ self.undo_stack.append(state)
277
+ if getattr(self, 'DEBUG_UNDO', False):
278
+ try:
279
+ print(f"DEBUG_UNDO: push_undo_state -> new stack size: {len(self.undo_stack)}")
280
+ except Exception:
281
+ pass
282
+ self.redo_stack.clear()
283
+ # 初期化完了後のみ変更があったことを記録
284
+ if self.initialization_complete:
285
+ self.has_unsaved_changes = True
286
+ self.update_window_title()
287
+
288
+ self.update_implicit_hydrogens()
289
+ self.update_realtime_info()
290
+ self.update_undo_redo_actions()
291
+
292
+
293
+
294
+ def update_window_title(self):
295
+ """ウィンドウタイトルを更新(保存状態を反映)"""
296
+ base_title = f"MoleditPy Ver. {VERSION}"
297
+ if self.current_file_path:
298
+ filename = os.path.basename(self.current_file_path)
299
+ title = f"{filename} - {base_title}"
300
+ if self.has_unsaved_changes:
301
+ title = f"*{title}"
302
+ else:
303
+ # Untitledファイルとして扱う
304
+ title = f"Untitled - {base_title}"
305
+ if self.has_unsaved_changes:
306
+ title = f"*{title}"
307
+ self.setWindowTitle(title)
308
+
309
+
310
+
311
+ def check_unsaved_changes(self):
312
+ """未保存の変更があるかチェックし、警告ダイアログを表示"""
313
+ if not self.has_unsaved_changes:
314
+ return True # 保存済みまたは変更なし
315
+
316
+ if not self.data.atoms and self.current_mol is None:
317
+ return True # 空のドキュメント
318
+
319
+ reply = QMessageBox.question(
320
+ self,
321
+ "Unsaved Changes",
322
+ "You have unsaved changes. Do you want to save them?",
323
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
324
+ QMessageBox.StandardButton.Yes
325
+ )
326
+
327
+ if reply == QMessageBox.StandardButton.Yes:
328
+ # 拡張子がPMEPRJでなければ「名前を付けて保存」
329
+ file_path = self.current_file_path
330
+ if not file_path or not file_path.lower().endswith('.pmeprj'):
331
+ self.save_project_as()
332
+ else:
333
+ self.save_project()
334
+ return not self.has_unsaved_changes # 保存に成功した場合のみTrueを返す
335
+ elif reply == QMessageBox.StandardButton.No:
336
+ return True # 保存せずに続行
337
+ else:
338
+ return False # キャンセル
339
+
340
+
341
+
342
+ def reset_undo_stack(self):
343
+ self.undo_stack.clear()
344
+ self.redo_stack.clear()
345
+ self.push_undo_state()
346
+ if getattr(self, 'DEBUG_UNDO', False):
347
+ try:
348
+ print(f"DEBUG_UNDO: reset_undo_stack -> undo={len(self.undo_stack)} redo={len(self.redo_stack)}")
349
+ except Exception:
350
+ pass
351
+
352
+
353
+
354
+ def undo(self):
355
+ if len(self.undo_stack) > 1:
356
+ self.redo_stack.append(self.undo_stack.pop())
357
+ state = self.undo_stack[-1]
358
+ self._is_restoring_state = True
359
+ try:
360
+ self.set_state_from_data(state)
361
+ finally:
362
+ self._is_restoring_state = False
363
+
364
+
365
+ # Undo後に3D構造の状態に基づいてメニューを再評価
366
+ if self.current_mol and self.current_mol.GetNumAtoms() > 0:
367
+ # 3D構造がある場合は3D編集機能を有効化
368
+ self._enable_3d_edit_actions(True)
369
+ else:
370
+ # 3D構造がない場合は3D編集機能を無効化
371
+ self._enable_3d_edit_actions(False)
372
+
373
+ if getattr(self, 'DEBUG_UNDO', False):
374
+ try:
375
+ print(f"DEBUG_UNDO: undo -> undo_stack size: {len(self.undo_stack)}, redo_stack size: {len(self.redo_stack)}")
376
+ except Exception:
377
+ pass
378
+ self.update_undo_redo_actions()
379
+ self.update_realtime_info()
380
+ self.view_2d.setFocus()
381
+
382
+
383
+
384
+ def redo(self):
385
+ if self.redo_stack:
386
+ state = self.redo_stack.pop()
387
+ self.undo_stack.append(state)
388
+ self._is_restoring_state = True
389
+ try:
390
+ self.set_state_from_data(state)
391
+ finally:
392
+ self._is_restoring_state = False
393
+
394
+ # Redo後に3D構造の状態に基づいてメニューを再評価
395
+ if self.current_mol and self.current_mol.GetNumAtoms() > 0:
396
+ # 3D構造がある場合は3D編集機能を有効化
397
+ self._enable_3d_edit_actions(True)
398
+ else:
399
+ # 3D構造がない場合は3D編集機能を無効化
400
+ self._enable_3d_edit_actions(False)
401
+
402
+ if getattr(self, 'DEBUG_UNDO', False):
403
+ try:
404
+ print(f"DEBUG_UNDO: redo -> undo_stack size: {len(self.undo_stack)}, redo_stack size: {len(self.redo_stack)}")
405
+ except Exception:
406
+ pass
407
+ self.update_undo_redo_actions()
408
+ self.update_realtime_info()
409
+ self.view_2d.setFocus()
410
+
411
+
412
+
413
+ def update_undo_redo_actions(self):
414
+ self.undo_action.setEnabled(len(self.undo_stack) > 1)
415
+ self.redo_action.setEnabled(len(self.redo_stack) > 0)
416
+
417
+
418
+
419
+ def update_realtime_info(self):
420
+ """ステータスバーの右側に現在の分子情報を表示する"""
421
+ if not self.data.atoms:
422
+ self.formula_label.setText("") # 原子がなければ右側のラベルをクリア
423
+ return
424
+
425
+ try:
426
+ mol = self.data.to_rdkit_mol()
427
+ if mol:
428
+ # 水素原子を明示的に追加した分子オブジェクトを生成
429
+ mol_with_hs = Chem.AddHs(mol)
430
+ mol_formula = rdMolDescriptors.CalcMolFormula(mol)
431
+ # 水素を含む分子オブジェクトから原子数を取得
432
+ num_atoms = mol_with_hs.GetNumAtoms()
433
+ # 右側のラベルのテキストを更新
434
+ self.formula_label.setText(f"Formula: {mol_formula} | Atoms: {num_atoms}")
435
+ except Exception:
436
+ # 計算に失敗してもアプリは継続
437
+ self.formula_label.setText("Invalid structure")
438
+
439
+
440
+
441
+ def create_json_data(self):
442
+ """現在の状態をPMEJSON形式のデータに変換"""
443
+ # 基本的なメタデータ
444
+ json_data = {
445
+ "format": "PME Project",
446
+ "version": "1.0",
447
+ "application": "MoleditPy",
448
+ "application_version": VERSION,
449
+ "created": str(QDateTime.currentDateTime().toString(Qt.DateFormat.ISODate)),
450
+ "is_3d_viewer_mode": not self.is_2d_editable
451
+ }
452
+
453
+ # 2D構造データ
454
+ if self.data.atoms:
455
+ atoms_2d = []
456
+ for atom_id, data in self.data.atoms.items():
457
+ pos = data['item'].pos()
458
+ atom_data = {
459
+ "id": atom_id,
460
+ "symbol": data['symbol'],
461
+ "x": pos.x(),
462
+ "y": pos.y(),
463
+ "charge": data.get('charge', 0),
464
+ "radical": data.get('radical', 0)
465
+ }
466
+ atoms_2d.append(atom_data)
467
+
468
+ bonds_2d = []
469
+ for (atom1_id, atom2_id), bond_data in self.data.bonds.items():
470
+ bond_info = {
471
+ "atom1": atom1_id,
472
+ "atom2": atom2_id,
473
+ "order": bond_data['order'],
474
+ "stereo": bond_data.get('stereo', 0)
475
+ }
476
+ bonds_2d.append(bond_info)
477
+
478
+ json_data["2d_structure"] = {
479
+ "atoms": atoms_2d,
480
+ "bonds": bonds_2d,
481
+ "next_atom_id": self.data._next_atom_id
482
+ }
483
+
484
+ # 3D分子データ
485
+ if self.current_mol and self.current_mol.GetNumConformers() > 0:
486
+ try:
487
+ # MOLデータをBase64エンコードで保存(バイナリデータの安全な保存)
488
+ mol_binary = self.current_mol.ToBinary()
489
+ mol_base64 = base64.b64encode(mol_binary).decode('ascii')
490
+
491
+ # 3D座標を抽出
492
+ atoms_3d = []
493
+ if self.current_mol.GetNumConformers() > 0:
494
+ conf = self.current_mol.GetConformer()
495
+ for i in range(self.current_mol.GetNumAtoms()):
496
+ atom = self.current_mol.GetAtomWithIdx(i)
497
+ pos = conf.GetAtomPosition(i)
498
+
499
+ # Try to preserve original editor atom ID (if present) so it can be
500
+ # restored when loading PMEPRJ files. RDKit atom properties may
501
+ # contain _original_atom_id when the molecule was created from
502
+ # the editor's 2D structure.
503
+ original_id = None
504
+ try:
505
+ if atom.HasProp("_original_atom_id"):
506
+ original_id = atom.GetIntProp("_original_atom_id")
507
+ except Exception:
508
+ original_id = None
509
+
510
+ atom_3d = {
511
+ "index": i,
512
+ "symbol": atom.GetSymbol(),
513
+ "atomic_number": atom.GetAtomicNum(),
514
+ "x": pos.x,
515
+ "y": pos.y,
516
+ "z": pos.z,
517
+ "formal_charge": atom.GetFormalCharge(),
518
+ "num_explicit_hs": atom.GetNumExplicitHs(),
519
+ "num_implicit_hs": atom.GetNumImplicitHs(),
520
+ # include original editor atom id when available for round-trip
521
+ "original_id": original_id
522
+ }
523
+ atoms_3d.append(atom_3d)
524
+
525
+ # 結合情報を抽出
526
+ bonds_3d = []
527
+ for bond in self.current_mol.GetBonds():
528
+ bond_3d = {
529
+ "atom1": bond.GetBeginAtomIdx(),
530
+ "atom2": bond.GetEndAtomIdx(),
531
+ "order": int(bond.GetBondType()),
532
+ "is_aromatic": bond.GetIsAromatic(),
533
+ "stereo": int(bond.GetStereo())
534
+ }
535
+ bonds_3d.append(bond_3d)
536
+
537
+ # constraints_3dをJSON互換形式に変換
538
+ json_safe_constraints = []
539
+ try:
540
+ for const in self.constraints_3d:
541
+ if len(const) == 4:
542
+ json_safe_constraints.append([const[0], list(const[1]), const[2], const[3]])
543
+ else:
544
+ json_safe_constraints.append([const[0], list(const[1]), const[2], 1.0e5])
545
+ except Exception:
546
+ json_safe_constraints = []
547
+
548
+ json_data["3d_structure"] = {
549
+ "mol_binary_base64": mol_base64,
550
+ "atoms": atoms_3d,
551
+ "bonds": bonds_3d,
552
+ "num_conformers": self.current_mol.GetNumConformers(),
553
+ "constraints_3d": json_safe_constraints
554
+ }
555
+
556
+ # 分子の基本情報
557
+ json_data["molecular_info"] = {
558
+ "num_atoms": self.current_mol.GetNumAtoms(),
559
+ "num_bonds": self.current_mol.GetNumBonds(),
560
+ "molecular_weight": Descriptors.MolWt(self.current_mol),
561
+ "formula": rdMolDescriptors.CalcMolFormula(self.current_mol)
562
+ }
563
+
564
+ # SMILESとInChI(可能であれば)
565
+ try:
566
+ json_data["identifiers"] = {
567
+ "smiles": Chem.MolToSmiles(self.current_mol),
568
+ "canonical_smiles": Chem.MolToSmiles(self.current_mol, canonical=True)
569
+ }
570
+
571
+ # InChI生成を試行
572
+ try:
573
+ inchi = Chem.MolToInchi(self.current_mol)
574
+ inchi_key = Chem.MolToInchiKey(self.current_mol)
575
+ json_data["identifiers"]["inchi"] = inchi
576
+ json_data["identifiers"]["inchi_key"] = inchi_key
577
+ except Exception:
578
+ pass # InChI生成に失敗した場合は無視
579
+
580
+ except Exception as e:
581
+ print(f"Warning: Could not generate molecular identifiers: {e}")
582
+
583
+ except Exception as e:
584
+ print(f"Warning: Could not process 3D molecular data: {e}")
585
+ else:
586
+ # 3D情報がない場合の記録
587
+ json_data["3d_structure"] = None
588
+ json_data["note"] = "No 3D structure available. Generate 3D coordinates first."
589
+
590
+ # Record the last-successful optimization method (if any)
591
+ # This is a convenience field so saved projects remember which
592
+ # optimizer variant was last used (e.g. "MMFF94s", "MMFF94", "UFF").
593
+ try:
594
+ json_data["last_successful_optimization_method"] = getattr(self, 'last_successful_optimization_method', None)
595
+ except Exception:
596
+ json_data["last_successful_optimization_method"] = None
597
+
598
+ # Plugin State Persistence (Phase 3)
599
+ # Start with preserved data from missing plugins
600
+ plugin_data = self._preserved_plugin_data.copy() if self._preserved_plugin_data else {}
601
+
602
+ if self.plugin_manager and self.plugin_manager.save_handlers:
603
+ for name, callback in self.plugin_manager.save_handlers.items():
604
+ try:
605
+ p_state = callback()
606
+ # Ensure serializable? Use primitive types ideally.
607
+ plugin_data[name] = p_state
608
+ except Exception as e:
609
+ print(f"Error saving state for plugin {name}: {e}")
610
+
611
+ if plugin_data:
612
+ json_data['plugins'] = plugin_data
613
+
614
+ return json_data
615
+
616
+
617
+
618
+ def load_from_json_data(self, json_data):
619
+ """JSONデータから状態を復元"""
620
+ self.dragged_atom_info = None
621
+ self.clear_2d_editor(push_to_undo=False)
622
+ self._enable_3d_edit_actions(False)
623
+ self._enable_3d_features(False)
624
+
625
+ # 3Dビューアーモードの設定
626
+ is_3d_mode = json_data.get("is_3d_viewer_mode", False)
627
+ # Restore last successful optimization method if present in file
628
+ try:
629
+ self.last_successful_optimization_method = json_data.get("last_successful_optimization_method", None)
630
+ except Exception:
631
+ self.last_successful_optimization_method = None
632
+
633
+ # Plugin State Restoration (Phase 3)
634
+ self._preserved_plugin_data = {} # Reset preserved data on new load
635
+ if "plugins" in json_data:
636
+ plugin_data = json_data["plugins"]
637
+ for name, p_state in plugin_data.items():
638
+ if self.plugin_manager and name in self.plugin_manager.load_handlers:
639
+ try:
640
+ self.plugin_manager.load_handlers[name](p_state)
641
+ except Exception as e:
642
+ print(f"Error loading state for plugin {name}: {e}")
643
+ else:
644
+ # No handler found (plugin disabled or missing)
645
+ # Preserve data so it's not lost on next save
646
+ self._preserved_plugin_data[name] = p_state
647
+
648
+
649
+ # 2D構造データの復元
650
+ if "2d_structure" in json_data:
651
+ structure_2d = json_data["2d_structure"]
652
+ atoms_2d = structure_2d.get("atoms", [])
653
+ bonds_2d = structure_2d.get("bonds", [])
654
+
655
+ # 原子の復元
656
+ for atom_data in atoms_2d:
657
+ atom_id = atom_data["id"]
658
+ symbol = atom_data["symbol"]
659
+ pos = QPointF(atom_data["x"], atom_data["y"])
660
+ charge = atom_data.get("charge", 0)
661
+ radical = atom_data.get("radical", 0)
662
+
663
+ atom_item = AtomItem(atom_id, symbol, pos, charge=charge, radical=radical)
664
+ self.data.atoms[atom_id] = {
665
+ 'symbol': symbol,
666
+ 'pos': pos,
667
+ 'item': atom_item,
668
+ 'charge': charge,
669
+ 'radical': radical
670
+ }
671
+ self.scene.addItem(atom_item)
672
+
673
+ # next_atom_idの復元
674
+ self.data._next_atom_id = structure_2d.get(
675
+ "next_atom_id",
676
+ max([atom["id"] for atom in atoms_2d]) + 1 if atoms_2d else 0
677
+ )
678
+
679
+ # 結合の復元
680
+ for bond_data in bonds_2d:
681
+ atom1_id = bond_data["atom1"]
682
+ atom2_id = bond_data["atom2"]
683
+
684
+ if atom1_id in self.data.atoms and atom2_id in self.data.atoms:
685
+ atom1_item = self.data.atoms[atom1_id]['item']
686
+ atom2_item = self.data.atoms[atom2_id]['item']
687
+
688
+ bond_order = bond_data["order"]
689
+ stereo = bond_data.get("stereo", 0)
690
+
691
+ bond_item = BondItem(atom1_item, atom2_item, bond_order, stereo=stereo)
692
+ # 原子の結合リストに追加(重要:炭素原子の可視性判定で使用)
693
+ atom1_item.bonds.append(bond_item)
694
+ atom2_item.bonds.append(bond_item)
695
+
696
+ self.data.bonds[(atom1_id, atom2_id)] = {
697
+ 'order': bond_order,
698
+ 'item': bond_item,
699
+ 'stereo': stereo
700
+ }
701
+ self.scene.addItem(bond_item)
702
+
703
+ # --- ここで全AtomItemのスタイルを更新(炭素原子の可視性を正しく反映) ---
704
+ for atom in self.data.atoms.values():
705
+ atom['item'].update_style()
706
+ # 3D構造データの復元
707
+ if "3d_structure" in json_data and json_data["3d_structure"] is not None:
708
+ structure_3d = json_data["3d_structure"]
709
+
710
+ # 制約データの復元 (JSONはタプルをリストとして保存するので、タプルに再変換)
711
+ try:
712
+ loaded_constraints = structure_3d.get("constraints_3d", [])
713
+ self.constraints_3d = []
714
+ for const in loaded_constraints:
715
+ if isinstance(const, list):
716
+ if len(const) == 4:
717
+ # [Type, [Idx...], Value, Force] -> (Type, (Idx...), Value, Force)
718
+ self.constraints_3d.append((const[0], tuple(const[1]), const[2], const[3]))
719
+ elif len(const) == 3:
720
+ # 後方互換性: [Type, [Idx...], Value] -> (Type, (Idx...), Value, 1.0e5)
721
+ self.constraints_3d.append((const[0], tuple(const[1]), const[2], 1.0e5))
722
+ except Exception:
723
+ self.constraints_3d = [] # 読み込み失敗時はリセット
724
+
725
+ try:
726
+ # バイナリデータの復元
727
+ mol_base64 = structure_3d.get("mol_binary_base64")
728
+ if mol_base64:
729
+ mol_binary = base64.b64decode(mol_base64.encode('ascii'))
730
+ self.current_mol = Chem.Mol(mol_binary)
731
+ if self.current_mol:
732
+ # 3D座標の設定
733
+ if self.current_mol.GetNumConformers() > 0:
734
+ conf = self.current_mol.GetConformer()
735
+ atoms_3d = structure_3d.get("atoms", [])
736
+ self.atom_positions_3d = np.zeros((len(atoms_3d), 3))
737
+ for atom_data in atoms_3d:
738
+ idx = atom_data["index"]
739
+ if idx < len(self.atom_positions_3d):
740
+ self.atom_positions_3d[idx] = [
741
+ atom_data["x"],
742
+ atom_data["y"],
743
+ atom_data["z"]
744
+ ]
745
+ # Restore original editor atom id into RDKit atom property
746
+ try:
747
+ original_id = atom_data.get("original_id", None)
748
+ if original_id is not None and idx < self.current_mol.GetNumAtoms():
749
+ rd_atom = self.current_mol.GetAtomWithIdx(idx)
750
+ # set as int prop so other code expecting _original_atom_id works
751
+ rd_atom.SetIntProp("_original_atom_id", int(original_id))
752
+ except Exception:
753
+ pass
754
+ # Build mapping from original 2D atom IDs to RDKit indices so
755
+ # 3D picks can be synchronized back to 2D AtomItems.
756
+ try:
757
+ self.create_atom_id_mapping()
758
+ # update menu and UI states that depend on original IDs
759
+ try:
760
+ self.update_atom_id_menu_text()
761
+ self.update_atom_id_menu_state()
762
+ except Exception:
763
+ pass
764
+ except Exception:
765
+ # non-fatal if mapping creation fails
766
+ pass
767
+
768
+ # 3D分子があれば必ず3D表示
769
+ self.draw_molecule_3d(self.current_mol)
770
+ # ViewerモードならUIも切り替え
771
+ if is_3d_mode:
772
+ self._enter_3d_viewer_ui_mode()
773
+ else:
774
+ self.is_2d_editable = True
775
+ self.plotter.reset_camera()
776
+
777
+ # 成功的に3D分子が復元されたので、3D関連UIを有効にする
778
+ try:
779
+ self._enable_3d_edit_actions(True)
780
+ self._enable_3d_features(True)
781
+ except Exception:
782
+ pass
783
+
784
+ except Exception as e:
785
+ print(f"Warning: Could not restore 3D molecular data: {e}")
786
+ self.current_mol = None
787
+