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