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.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- 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
|
+
|