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

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