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,1044 @@
|
|
|
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
|
+
"""
|
|
15
|
+
main_window_molecular_parsers.py
|
|
16
|
+
MainWindow (main_window.py) から分離されたモジュール
|
|
17
|
+
機能クラス: MainWindowMolecularParsers
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import os
|
|
23
|
+
import contextlib
|
|
24
|
+
import traceback
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# RDKit imports (explicit to satisfy flake8 and used features)
|
|
29
|
+
from rdkit import Chem
|
|
30
|
+
from rdkit.Chem import AllChem, Descriptors, rdMolTransforms, rdGeometry
|
|
31
|
+
try:
|
|
32
|
+
pass
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
# PyQt6 Modules
|
|
37
|
+
from PyQt6.QtWidgets import (
|
|
38
|
+
QVBoxLayout, QHBoxLayout,
|
|
39
|
+
QPushButton, QDialog, QFileDialog, QLabel, QLineEdit, QInputDialog, QDialogButtonBox
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
from PyQt6.QtCore import (
|
|
45
|
+
QPointF, QTimer
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Use centralized Open Babel availability from package-level __init__
|
|
50
|
+
# Use per-package modules availability (local __init__).
|
|
51
|
+
try:
|
|
52
|
+
from . import OBABEL_AVAILABLE
|
|
53
|
+
except Exception:
|
|
54
|
+
from modules import OBABEL_AVAILABLE
|
|
55
|
+
# Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
|
|
56
|
+
if OBABEL_AVAILABLE:
|
|
57
|
+
try:
|
|
58
|
+
from openbabel import pybel
|
|
59
|
+
except Exception:
|
|
60
|
+
# If import fails here, disable OBABEL locally; avoid raising
|
|
61
|
+
pybel = None
|
|
62
|
+
OBABEL_AVAILABLE = False
|
|
63
|
+
logging.warning("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
|
|
64
|
+
else:
|
|
65
|
+
pybel = None
|
|
66
|
+
|
|
67
|
+
# Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
|
|
68
|
+
# allows safely detecting C++ wrapper objects that have been deleted. Import
|
|
69
|
+
# it once at module import time and expose a small, robust wrapper so callers
|
|
70
|
+
# can avoid re-importing sip repeatedly and so we centralize exception
|
|
71
|
+
# handling (this reduces crash risk during teardown and deletion operations).
|
|
72
|
+
try:
|
|
73
|
+
import sip as _sip # type: ignore
|
|
74
|
+
_sip_isdeleted = getattr(_sip, 'isdeleted', None)
|
|
75
|
+
except Exception:
|
|
76
|
+
_sip = None
|
|
77
|
+
_sip_isdeleted = None
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
# package relative imports (preferred when running as `python -m moleditpy`)
|
|
81
|
+
from .constants import VERSION
|
|
82
|
+
except Exception:
|
|
83
|
+
# Fallback to absolute imports for script-style execution
|
|
84
|
+
from modules.constants import VERSION
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- クラス定義 ---
|
|
88
|
+
class MainWindowMolecularParsers(object):
|
|
89
|
+
""" main_window.py から分離された機能クラス """
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_mol_file(self, file_path=None):
|
|
93
|
+
if not self.check_unsaved_changes():
|
|
94
|
+
return # ユーザーがキャンセルした場合は何もしない
|
|
95
|
+
if not file_path:
|
|
96
|
+
file_path, _ = QFileDialog.getOpenFileName(self, "Import MOL File", "", "Chemical Files (*.mol *.sdf);;All Files (*)")
|
|
97
|
+
if not file_path:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
self.dragged_atom_info = None
|
|
102
|
+
# If this is a single-record .mol file, read & fix the counts line
|
|
103
|
+
# before parsing. For multi-record .sdf files, keep using SDMolSupplier.
|
|
104
|
+
_, ext = os.path.splitext(file_path)
|
|
105
|
+
ext = ext.lower() if ext else ''
|
|
106
|
+
if ext == '.mol':
|
|
107
|
+
# Read file text, fix CTAB counts line if needed, then parse
|
|
108
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
|
|
109
|
+
raw = fh.read()
|
|
110
|
+
fixed_block = self.fix_mol_block(raw)
|
|
111
|
+
mol = Chem.MolFromMolBlock(fixed_block, sanitize=True, removeHs=False)
|
|
112
|
+
if mol is None:
|
|
113
|
+
raise ValueError("Failed to read molecule from .mol file after fixing counts line.")
|
|
114
|
+
else:
|
|
115
|
+
suppl = Chem.SDMolSupplier(file_path, removeHs=False)
|
|
116
|
+
mol = next(suppl, None)
|
|
117
|
+
if mol is None:
|
|
118
|
+
raise ValueError("Failed to read molecule from file.")
|
|
119
|
+
|
|
120
|
+
Chem.Kekulize(mol)
|
|
121
|
+
|
|
122
|
+
self.restore_ui_for_editing()
|
|
123
|
+
self.clear_2d_editor(push_to_undo=False)
|
|
124
|
+
self.current_mol = None
|
|
125
|
+
self.plotter.clear()
|
|
126
|
+
self.analysis_action.setEnabled(False)
|
|
127
|
+
|
|
128
|
+
# 1. 座標がなければ2D座標を生成する
|
|
129
|
+
if mol.GetNumConformers() == 0:
|
|
130
|
+
AllChem.Compute2DCoords(mol)
|
|
131
|
+
|
|
132
|
+
# 2. 座標の有無にかかわらず、常に立体化学を割り当て、2D表示用にくさび結合を設定する
|
|
133
|
+
# これにより、3D座標を持つMOLファイルからでも正しく2Dの立体表現が生成される
|
|
134
|
+
AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
|
|
135
|
+
conf = mol.GetConformer()
|
|
136
|
+
AllChem.WedgeMolBonds(mol, conf)
|
|
137
|
+
|
|
138
|
+
conf = mol.GetConformer()
|
|
139
|
+
|
|
140
|
+
SCALE_FACTOR = 50.0
|
|
141
|
+
|
|
142
|
+
view_center = self.view_2d.mapToScene(self.view_2d.viewport().rect().center())
|
|
143
|
+
|
|
144
|
+
positions = [conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
|
|
145
|
+
if positions:
|
|
146
|
+
mol_center_x = sum(p.x for p in positions) / len(positions)
|
|
147
|
+
mol_center_y = sum(p.y for p in positions) / len(positions)
|
|
148
|
+
else:
|
|
149
|
+
mol_center_x, mol_center_y = 0.0, 0.0
|
|
150
|
+
|
|
151
|
+
rdkit_idx_to_my_id = {}
|
|
152
|
+
for i in range(mol.GetNumAtoms()):
|
|
153
|
+
atom = mol.GetAtomWithIdx(i)
|
|
154
|
+
pos = conf.GetAtomPosition(i)
|
|
155
|
+
charge = atom.GetFormalCharge()
|
|
156
|
+
|
|
157
|
+
relative_x = pos.x - mol_center_x
|
|
158
|
+
relative_y = pos.y - mol_center_y
|
|
159
|
+
|
|
160
|
+
scene_x = (relative_x * SCALE_FACTOR) + view_center.x()
|
|
161
|
+
scene_y = (-relative_y * SCALE_FACTOR) + view_center.y()
|
|
162
|
+
|
|
163
|
+
atom_id = self.scene.create_atom(atom.GetSymbol(), QPointF(scene_x, scene_y), charge=charge)
|
|
164
|
+
rdkit_idx_to_my_id[i] = atom_id
|
|
165
|
+
|
|
166
|
+
for bond in mol.GetBonds():
|
|
167
|
+
b_idx,e_idx=bond.GetBeginAtomIdx(),bond.GetEndAtomIdx()
|
|
168
|
+
b_type = bond.GetBondTypeAsDouble(); b_dir = bond.GetBondDir()
|
|
169
|
+
stereo = 0
|
|
170
|
+
# Check for single bond Wedge/Dash
|
|
171
|
+
if b_dir == Chem.BondDir.BEGINWEDGE:
|
|
172
|
+
stereo = 1
|
|
173
|
+
elif b_dir == Chem.BondDir.BEGINDASH:
|
|
174
|
+
stereo = 2
|
|
175
|
+
# ADDED: Check for double bond E/Z stereochemistry
|
|
176
|
+
if bond.GetBondType() == Chem.BondType.DOUBLE:
|
|
177
|
+
if bond.GetStereo() == Chem.BondStereo.STEREOZ:
|
|
178
|
+
stereo = 3 # Z
|
|
179
|
+
elif bond.GetStereo() == Chem.BondStereo.STEREOE:
|
|
180
|
+
stereo = 4 # E
|
|
181
|
+
|
|
182
|
+
a1_id, a2_id = rdkit_idx_to_my_id[b_idx], rdkit_idx_to_my_id[e_idx]
|
|
183
|
+
a1_item,a2_item=self.data.atoms[a1_id]['item'],self.data.atoms[a2_id]['item']
|
|
184
|
+
|
|
185
|
+
self.scene.create_bond(a1_item, a2_item, bond_order=int(b_type), bond_stereo=stereo)
|
|
186
|
+
|
|
187
|
+
self.statusBar().showMessage(f"Successfully loaded {file_path}")
|
|
188
|
+
self.reset_undo_stack()
|
|
189
|
+
# NEWファイル扱い: ファイルパスをクリアし未保存状態はFalse(変更なければ保存警告なし)
|
|
190
|
+
self.current_file_path = file_path
|
|
191
|
+
self.has_unsaved_changes = False
|
|
192
|
+
self.update_window_title()
|
|
193
|
+
QTimer.singleShot(0, self.fit_to_view)
|
|
194
|
+
|
|
195
|
+
except FileNotFoundError:
|
|
196
|
+
self.statusBar().showMessage(f"File not found: {file_path}")
|
|
197
|
+
except ValueError as e:
|
|
198
|
+
self.statusBar().showMessage(f"Invalid MOL file format: {e}")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
self.statusBar().showMessage(f"Error loading file: {e}")
|
|
201
|
+
|
|
202
|
+
traceback.print_exc()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_xyz_file(self, file_path):
|
|
207
|
+
"""XYZファイルを読み込んでRDKitのMolオブジェクトを作成する"""
|
|
208
|
+
|
|
209
|
+
if not self.check_unsaved_changes():
|
|
210
|
+
return # ユーザーがキャンセルした場合は何もしない
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# We will attempt one silent load with default charge=0 (no dialog).
|
|
214
|
+
# If RDKit emits chemistry warnings (for example "Explicit valence ..."),
|
|
215
|
+
# prompt the user once for an overall charge and retry. Only one retry is allowed.
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Helper: prompt for charge once when needed
|
|
219
|
+
# Returns a tuple: (charge_value_or_0, accepted:bool, skip_chemistry:bool)
|
|
220
|
+
def prompt_for_charge():
|
|
221
|
+
try:
|
|
222
|
+
# Create a custom dialog so we can provide a "Skip chemistry" button
|
|
223
|
+
dialog = QDialog(self)
|
|
224
|
+
dialog.setWindowTitle("Import XYZ Charge")
|
|
225
|
+
layout = QVBoxLayout(dialog)
|
|
226
|
+
|
|
227
|
+
label = QLabel("Enter total molecular charge:")
|
|
228
|
+
line_edit = QLineEdit(dialog)
|
|
229
|
+
line_edit.setText("")
|
|
230
|
+
|
|
231
|
+
# Standard OK/Cancel buttons
|
|
232
|
+
btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
|
233
|
+
|
|
234
|
+
# Additional Skip chemistry button
|
|
235
|
+
skip_btn = QPushButton("Skip chemistry", dialog)
|
|
236
|
+
|
|
237
|
+
# Horizontal layout for buttons
|
|
238
|
+
hl = QHBoxLayout()
|
|
239
|
+
hl.addWidget(btn_box)
|
|
240
|
+
hl.addWidget(skip_btn)
|
|
241
|
+
|
|
242
|
+
layout.addWidget(label)
|
|
243
|
+
layout.addWidget(line_edit)
|
|
244
|
+
layout.addLayout(hl)
|
|
245
|
+
|
|
246
|
+
result = {"accepted": False, "skip": False}
|
|
247
|
+
|
|
248
|
+
def on_ok():
|
|
249
|
+
result["accepted"] = True
|
|
250
|
+
dialog.accept()
|
|
251
|
+
|
|
252
|
+
def on_cancel():
|
|
253
|
+
dialog.reject()
|
|
254
|
+
|
|
255
|
+
def on_skip():
|
|
256
|
+
# Mark skip and accept so caller can proceed with skip behavior
|
|
257
|
+
result["skip"] = True
|
|
258
|
+
dialog.accept()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
btn_box.button(QDialogButtonBox.Ok).clicked.connect(on_ok)
|
|
262
|
+
btn_box.button(QDialogButtonBox.Cancel).clicked.connect(on_cancel)
|
|
263
|
+
except Exception:
|
|
264
|
+
# Fallback if button lookup fails
|
|
265
|
+
btn_box.accepted.connect(on_ok)
|
|
266
|
+
btn_box.rejected.connect(on_cancel)
|
|
267
|
+
|
|
268
|
+
skip_btn.clicked.connect(on_skip)
|
|
269
|
+
|
|
270
|
+
# Execute dialog modally
|
|
271
|
+
if dialog.exec_() != QDialog.Accepted:
|
|
272
|
+
return None, False, False
|
|
273
|
+
|
|
274
|
+
if result["skip"]:
|
|
275
|
+
# User chose to skip chemistry checks; return skip flag
|
|
276
|
+
return 0, True, True
|
|
277
|
+
|
|
278
|
+
if not result["accepted"]:
|
|
279
|
+
return None, False, False
|
|
280
|
+
|
|
281
|
+
charge_text = line_edit.text()
|
|
282
|
+
except Exception:
|
|
283
|
+
# On any dialog creation error, fall back to simple input dialog
|
|
284
|
+
try:
|
|
285
|
+
charge_text, ok = QInputDialog.getText(self, "Import XYZ Charge", "Enter total molecular charge:", text="0")
|
|
286
|
+
except Exception:
|
|
287
|
+
return 0, True, False
|
|
288
|
+
if not ok:
|
|
289
|
+
return None, False, False
|
|
290
|
+
try:
|
|
291
|
+
return int(str(charge_text).strip()), True, False
|
|
292
|
+
except Exception:
|
|
293
|
+
try:
|
|
294
|
+
return int(float(str(charge_text).strip())), True, False
|
|
295
|
+
except Exception:
|
|
296
|
+
return 0, True, False
|
|
297
|
+
|
|
298
|
+
if charge_text is None:
|
|
299
|
+
return None, False, False
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
return int(str(charge_text).strip()), True, False
|
|
303
|
+
except Exception:
|
|
304
|
+
try:
|
|
305
|
+
return int(float(str(charge_text).strip())), True, False
|
|
306
|
+
except Exception:
|
|
307
|
+
return 0, True, False
|
|
308
|
+
|
|
309
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
310
|
+
lines = f.readlines()
|
|
311
|
+
|
|
312
|
+
# 空行とコメント行を除去(但し、先頭2行は保持)
|
|
313
|
+
non_empty_lines = []
|
|
314
|
+
for i, line in enumerate(lines):
|
|
315
|
+
stripped = line.strip()
|
|
316
|
+
if i < 2: # 最初の2行は原子数とコメント行なので保持
|
|
317
|
+
non_empty_lines.append(stripped)
|
|
318
|
+
elif stripped and not stripped.startswith('#'): # 空行とコメント行をスキップ
|
|
319
|
+
non_empty_lines.append(stripped)
|
|
320
|
+
|
|
321
|
+
if len(non_empty_lines) < 2:
|
|
322
|
+
raise ValueError("XYZ file format error: too few lines")
|
|
323
|
+
|
|
324
|
+
# 原子数を読み取り
|
|
325
|
+
try:
|
|
326
|
+
num_atoms = int(non_empty_lines[0])
|
|
327
|
+
except ValueError:
|
|
328
|
+
raise ValueError("XYZ file format error: invalid atom count")
|
|
329
|
+
|
|
330
|
+
if num_atoms <= 0:
|
|
331
|
+
raise ValueError("XYZ file format error: atom count must be positive")
|
|
332
|
+
|
|
333
|
+
# コメント行(2行目)
|
|
334
|
+
comment = non_empty_lines[1] if len(non_empty_lines) > 1 else ""
|
|
335
|
+
|
|
336
|
+
# 原子データを読み取り
|
|
337
|
+
atoms_data = []
|
|
338
|
+
data_lines = non_empty_lines[2:]
|
|
339
|
+
|
|
340
|
+
if len(data_lines) < num_atoms:
|
|
341
|
+
raise ValueError(f"XYZ file format error: expected {num_atoms} atom lines, found {len(data_lines)}")
|
|
342
|
+
|
|
343
|
+
for i, line in enumerate(data_lines[:num_atoms]):
|
|
344
|
+
parts = line.split()
|
|
345
|
+
if len(parts) < 4:
|
|
346
|
+
raise ValueError(f"XYZ file format error: invalid atom data at line {i+3}")
|
|
347
|
+
|
|
348
|
+
symbol = parts[0].strip()
|
|
349
|
+
|
|
350
|
+
# 元素記号の妥当性をチェック
|
|
351
|
+
try:
|
|
352
|
+
# RDKitで認識される元素かどうかをチェック
|
|
353
|
+
test_atom = Chem.Atom(symbol)
|
|
354
|
+
except Exception:
|
|
355
|
+
# 認識されない場合、最初の文字を大文字にして再試行
|
|
356
|
+
symbol = symbol.capitalize()
|
|
357
|
+
try:
|
|
358
|
+
test_atom = Chem.Atom(symbol)
|
|
359
|
+
except Exception:
|
|
360
|
+
# If user requested to skip chemistry checks, coerce unknown symbols to C
|
|
361
|
+
if self.settings.get('skip_chemistry_checks', False):
|
|
362
|
+
symbol = 'C'
|
|
363
|
+
else:
|
|
364
|
+
raise ValueError(f"Unrecognized element symbol: {parts[0]} at line {i+3}")
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
x, y, z = float(parts[1]), float(parts[2]), float(parts[3])
|
|
368
|
+
except ValueError:
|
|
369
|
+
raise ValueError(f"XYZ file format error: invalid coordinates at line {i+3}")
|
|
370
|
+
|
|
371
|
+
atoms_data.append((symbol, x, y, z))
|
|
372
|
+
|
|
373
|
+
if len(atoms_data) == 0:
|
|
374
|
+
raise ValueError("XYZ file format error: no atoms found")
|
|
375
|
+
|
|
376
|
+
# RDKitのMolオブジェクトを作成
|
|
377
|
+
mol = Chem.RWMol()
|
|
378
|
+
|
|
379
|
+
# 原子を追加
|
|
380
|
+
for i, (symbol, x, y, z) in enumerate(atoms_data):
|
|
381
|
+
atom = Chem.Atom(symbol)
|
|
382
|
+
# XYZファイルでの原子のUniqueID(0ベースのインデックス)を保存
|
|
383
|
+
atom.SetIntProp("xyz_unique_id", i)
|
|
384
|
+
mol.AddAtom(atom)
|
|
385
|
+
|
|
386
|
+
# 3D座標を設定
|
|
387
|
+
conf = Chem.Conformer(len(atoms_data))
|
|
388
|
+
for i, (symbol, x, y, z) in enumerate(atoms_data):
|
|
389
|
+
conf.SetAtomPosition(i, rdGeometry.Point3D(x, y, z))
|
|
390
|
+
mol.AddConformer(conf)
|
|
391
|
+
# If user requested to skip chemistry checks, bypass RDKit's
|
|
392
|
+
# DetermineBonds/sanitization flow entirely and use only the
|
|
393
|
+
# distance-based bond estimation. Treat the resulting molecule
|
|
394
|
+
# as "XYZ-derived" (disable 3D optimization) and return it.
|
|
395
|
+
try:
|
|
396
|
+
skip_checks = bool(self.settings.get('skip_chemistry_checks', False))
|
|
397
|
+
except Exception:
|
|
398
|
+
skip_checks = False
|
|
399
|
+
|
|
400
|
+
if skip_checks:
|
|
401
|
+
used_rd_determine = False
|
|
402
|
+
try:
|
|
403
|
+
# Use the conservative distance-based heuristic to add bonds
|
|
404
|
+
self.estimate_bonds_from_distances(mol)
|
|
405
|
+
except Exception:
|
|
406
|
+
# Non-fatal: continue even if distance-based estimation fails
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
# Finalize and return a plain Mol object
|
|
410
|
+
try:
|
|
411
|
+
candidate_mol = mol.GetMol()
|
|
412
|
+
except Exception:
|
|
413
|
+
try:
|
|
414
|
+
candidate_mol = Chem.Mol(mol)
|
|
415
|
+
except Exception:
|
|
416
|
+
candidate_mol = None
|
|
417
|
+
|
|
418
|
+
if candidate_mol is None:
|
|
419
|
+
raise ValueError("Failed to create valid molecule object when skip_chemistry_checks=True")
|
|
420
|
+
|
|
421
|
+
# Attach a default charge property
|
|
422
|
+
try:
|
|
423
|
+
candidate_mol.SetIntProp("_xyz_charge", 0)
|
|
424
|
+
except Exception:
|
|
425
|
+
try:
|
|
426
|
+
candidate_mol._xyz_charge = 0
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
# Mark that this molecule was produced via the skip-chemistry path
|
|
431
|
+
try:
|
|
432
|
+
candidate_mol.SetIntProp("_xyz_skip_checks", 1)
|
|
433
|
+
except Exception:
|
|
434
|
+
try:
|
|
435
|
+
candidate_mol._xyz_skip_checks = True
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
# Set UI flags consistently: mark as XYZ-derived and disable optimize
|
|
440
|
+
try:
|
|
441
|
+
self.current_mol = candidate_mol
|
|
442
|
+
self.is_xyz_derived = True
|
|
443
|
+
if hasattr(self, 'optimize_3d_button'):
|
|
444
|
+
try:
|
|
445
|
+
self.optimize_3d_button.setEnabled(False)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
# Store atom data for later analysis and return
|
|
452
|
+
candidate_mol._xyz_atom_data = atoms_data
|
|
453
|
+
return candidate_mol
|
|
454
|
+
# We'll attempt silently first with charge=0 and only prompt the user
|
|
455
|
+
# for a charge when the RDKit processing block fails (raises an
|
|
456
|
+
# exception). If the user provides a charge, retry; allow repeated
|
|
457
|
+
# prompts until the user cancels. This preserves the previous
|
|
458
|
+
# fallback behaviors (skip_chemistry_checks, distance-based bond
|
|
459
|
+
# estimation) and property attachments.
|
|
460
|
+
used_rd_determine = False
|
|
461
|
+
final_mol = None
|
|
462
|
+
|
|
463
|
+
# First, try silently with charge=0. If that raises an exception we
|
|
464
|
+
# will enter a loop prompting the user for a charge and retrying as
|
|
465
|
+
# long as the user provides values. If the user cancels, return None.
|
|
466
|
+
def _process_with_charge(charge_val):
|
|
467
|
+
"""Inner helper: attempt to build/finalize molecule with given charge.
|
|
468
|
+
|
|
469
|
+
Returns the finalized RDKit Mol on success. May raise exceptions
|
|
470
|
+
which will be propagated to the caller.
|
|
471
|
+
"""
|
|
472
|
+
nonlocal used_rd_determine
|
|
473
|
+
# Capture RDKit stderr while we run the processing to avoid
|
|
474
|
+
# spamming the console. We won't treat warnings specially here;
|
|
475
|
+
# only exceptions will trigger a prompt/retry. We also want to
|
|
476
|
+
# distinguish failures originating from DetermineBonds so the
|
|
477
|
+
# outer logic can decide whether to prompt the user repeatedly
|
|
478
|
+
# for different charge values.
|
|
479
|
+
buf = io.StringIO()
|
|
480
|
+
determine_failed = False
|
|
481
|
+
with contextlib.redirect_stderr(buf):
|
|
482
|
+
# Try DetermineBonds if available
|
|
483
|
+
try:
|
|
484
|
+
from rdkit.Chem import rdDetermineBonds
|
|
485
|
+
try:
|
|
486
|
+
try:
|
|
487
|
+
mol_candidate = Chem.RWMol(Chem.Mol(mol))
|
|
488
|
+
except Exception:
|
|
489
|
+
mol_candidate = Chem.RWMol(mol)
|
|
490
|
+
|
|
491
|
+
# This call may raise. If it does, mark determine_failed
|
|
492
|
+
# so the caller can prompt for a different charge.
|
|
493
|
+
rdDetermineBonds.DetermineBonds(mol_candidate, charge=charge_val)
|
|
494
|
+
mol_to_finalize = mol_candidate
|
|
495
|
+
used_rd_determine = True
|
|
496
|
+
except Exception:
|
|
497
|
+
# DetermineBonds failed for this charge value. We
|
|
498
|
+
# should allow the caller to prompt for another
|
|
499
|
+
# charge (or cancel). Mark the flag and re-raise a
|
|
500
|
+
# dedicated exception to be handled by the outer
|
|
501
|
+
# loop.
|
|
502
|
+
determine_failed = True
|
|
503
|
+
used_rd_determine = False
|
|
504
|
+
mol_to_finalize = mol
|
|
505
|
+
# Raise a sentinel exception to indicate DetermineBonds failure
|
|
506
|
+
raise RuntimeError("DetermineBondsFailed")
|
|
507
|
+
except RuntimeError:
|
|
508
|
+
# Propagate our sentinel so outer code can catch it.
|
|
509
|
+
raise
|
|
510
|
+
except Exception:
|
|
511
|
+
# rdDetermineBonds not available or import failed; use
|
|
512
|
+
# distance-based fallback below.
|
|
513
|
+
used_rd_determine = False
|
|
514
|
+
mol_to_finalize = mol
|
|
515
|
+
|
|
516
|
+
if not used_rd_determine:
|
|
517
|
+
# distance-based fallback
|
|
518
|
+
self.estimate_bonds_from_distances(mol_to_finalize)
|
|
519
|
+
|
|
520
|
+
# Finalize molecule
|
|
521
|
+
try:
|
|
522
|
+
candidate_mol = mol_to_finalize.GetMol()
|
|
523
|
+
except Exception:
|
|
524
|
+
candidate_mol = None
|
|
525
|
+
|
|
526
|
+
if candidate_mol is None:
|
|
527
|
+
# Try salvage path
|
|
528
|
+
try:
|
|
529
|
+
candidate_mol = mol.GetMol()
|
|
530
|
+
except Exception:
|
|
531
|
+
candidate_mol = None
|
|
532
|
+
|
|
533
|
+
if candidate_mol is None:
|
|
534
|
+
raise ValueError("Failed to create valid molecule object")
|
|
535
|
+
|
|
536
|
+
# Attach charge property if possible
|
|
537
|
+
try:
|
|
538
|
+
try:
|
|
539
|
+
candidate_mol.SetIntProp("_xyz_charge", int(charge_val))
|
|
540
|
+
except Exception:
|
|
541
|
+
try:
|
|
542
|
+
candidate_mol._xyz_charge = int(charge_val)
|
|
543
|
+
except Exception:
|
|
544
|
+
pass
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
# Preserve whether the user requested skip_chemistry_checks
|
|
549
|
+
try:
|
|
550
|
+
if bool(self.settings.get('skip_chemistry_checks', False)):
|
|
551
|
+
try:
|
|
552
|
+
candidate_mol.SetIntProp("_xyz_skip_checks", 1)
|
|
553
|
+
except Exception:
|
|
554
|
+
try:
|
|
555
|
+
candidate_mol._xyz_skip_checks = True
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
except Exception:
|
|
559
|
+
pass
|
|
560
|
+
|
|
561
|
+
# Run chemistry checks which may emit warnings to stderr
|
|
562
|
+
self._apply_chem_check_and_set_flags(candidate_mol, source_desc='XYZ')
|
|
563
|
+
|
|
564
|
+
# Accept the candidate
|
|
565
|
+
return candidate_mol
|
|
566
|
+
|
|
567
|
+
# Decide whether to silently try charge=0 first, or prompt user first.
|
|
568
|
+
always_ask = bool(self.settings.get('always_ask_charge', False))
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
if not always_ask:
|
|
572
|
+
# Silent first attempt (existing behavior)
|
|
573
|
+
try:
|
|
574
|
+
final_mol = _process_with_charge(0)
|
|
575
|
+
except RuntimeError:
|
|
576
|
+
# DetermineBonds explicitly failed for charge=0. In this
|
|
577
|
+
# situation, repeatedly prompt the user for charges until
|
|
578
|
+
# DetermineBonds succeeds or the user cancels.
|
|
579
|
+
while True:
|
|
580
|
+
charge_val, ok, skip_flag = prompt_for_charge()
|
|
581
|
+
if not ok:
|
|
582
|
+
# user cancelled the prompt -> abort
|
|
583
|
+
return None
|
|
584
|
+
if skip_flag:
|
|
585
|
+
# User selected Skip chemistry: attempt distance-based salvage
|
|
586
|
+
try:
|
|
587
|
+
self.estimate_bonds_from_distances(mol)
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
salvaged = None
|
|
591
|
+
try:
|
|
592
|
+
salvaged = mol.GetMol()
|
|
593
|
+
except Exception:
|
|
594
|
+
salvaged = None
|
|
595
|
+
|
|
596
|
+
if salvaged is not None:
|
|
597
|
+
try:
|
|
598
|
+
salvaged.SetIntProp("_xyz_skip_checks", 1)
|
|
599
|
+
except Exception:
|
|
600
|
+
try:
|
|
601
|
+
salvaged._xyz_skip_checks = True
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
final_mol = salvaged
|
|
605
|
+
break
|
|
606
|
+
else:
|
|
607
|
+
# Could not salvage; abort
|
|
608
|
+
try:
|
|
609
|
+
self.statusBar().showMessage("Skip chemistry selected but failed to create salvaged molecule.")
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
final_mol = _process_with_charge(charge_val)
|
|
616
|
+
# success -> break out of prompt loop
|
|
617
|
+
break
|
|
618
|
+
except RuntimeError:
|
|
619
|
+
# DetermineBonds still failing for this charge -> loop again
|
|
620
|
+
try:
|
|
621
|
+
self.statusBar().showMessage("DetermineBonds failed for that charge; please try a different total charge or cancel.")
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
continue
|
|
625
|
+
except Exception as e_prompt:
|
|
626
|
+
# Some other failure occurred after DetermineBonds or in
|
|
627
|
+
# finalization. If skip_chemistry_checks is enabled we
|
|
628
|
+
# try the salvaged mol once; otherwise prompt again.
|
|
629
|
+
try:
|
|
630
|
+
skip_checks = bool(self.settings.get('skip_chemistry_checks', False))
|
|
631
|
+
except Exception:
|
|
632
|
+
skip_checks = False
|
|
633
|
+
|
|
634
|
+
salvaged = None
|
|
635
|
+
try:
|
|
636
|
+
salvaged = mol.GetMol()
|
|
637
|
+
except Exception:
|
|
638
|
+
salvaged = None
|
|
639
|
+
|
|
640
|
+
if skip_checks and salvaged is not None:
|
|
641
|
+
final_mol = salvaged
|
|
642
|
+
# mark salvaged molecule as produced under skip_checks
|
|
643
|
+
try:
|
|
644
|
+
final_mol.SetIntProp("_xyz_skip_checks", 1)
|
|
645
|
+
except Exception:
|
|
646
|
+
try:
|
|
647
|
+
final_mol._xyz_skip_checks = True
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
break
|
|
651
|
+
else:
|
|
652
|
+
try:
|
|
653
|
+
self.statusBar().showMessage(f"Retry failed: {e_prompt}")
|
|
654
|
+
except Exception:
|
|
655
|
+
pass
|
|
656
|
+
# Continue prompting
|
|
657
|
+
continue
|
|
658
|
+
else:
|
|
659
|
+
# User has requested to always be asked for charge — prompt before any silent try
|
|
660
|
+
while True:
|
|
661
|
+
charge_val, ok, skip_flag = prompt_for_charge()
|
|
662
|
+
if not ok:
|
|
663
|
+
# user cancelled the prompt -> abort
|
|
664
|
+
return None
|
|
665
|
+
if skip_flag:
|
|
666
|
+
# User selected Skip chemistry: attempt distance-based salvage
|
|
667
|
+
try:
|
|
668
|
+
self.estimate_bonds_from_distances(mol)
|
|
669
|
+
except Exception:
|
|
670
|
+
pass
|
|
671
|
+
salvaged = None
|
|
672
|
+
try:
|
|
673
|
+
salvaged = mol.GetMol()
|
|
674
|
+
except Exception:
|
|
675
|
+
salvaged = None
|
|
676
|
+
|
|
677
|
+
if salvaged is not None:
|
|
678
|
+
try:
|
|
679
|
+
salvaged.SetIntProp("_xyz_skip_checks", 1)
|
|
680
|
+
except Exception:
|
|
681
|
+
try:
|
|
682
|
+
salvaged._xyz_skip_checks = True
|
|
683
|
+
except Exception:
|
|
684
|
+
pass
|
|
685
|
+
final_mol = salvaged
|
|
686
|
+
break
|
|
687
|
+
else:
|
|
688
|
+
try:
|
|
689
|
+
self.statusBar().showMessage("Skip chemistry selected but failed to create salvaged molecule.")
|
|
690
|
+
except Exception:
|
|
691
|
+
pass
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
final_mol = _process_with_charge(charge_val)
|
|
696
|
+
# success -> break out of prompt loop
|
|
697
|
+
break
|
|
698
|
+
except RuntimeError:
|
|
699
|
+
# DetermineBonds still failing for this charge -> loop again
|
|
700
|
+
try:
|
|
701
|
+
self.statusBar().showMessage("DetermineBonds failed for that charge; please try a different total charge or cancel.")
|
|
702
|
+
except Exception:
|
|
703
|
+
pass
|
|
704
|
+
continue
|
|
705
|
+
except Exception as e_prompt:
|
|
706
|
+
try:
|
|
707
|
+
skip_checks = bool(self.settings.get('skip_chemistry_checks', False))
|
|
708
|
+
except Exception:
|
|
709
|
+
skip_checks = False
|
|
710
|
+
|
|
711
|
+
salvaged = None
|
|
712
|
+
try:
|
|
713
|
+
salvaged = mol.GetMol()
|
|
714
|
+
except Exception:
|
|
715
|
+
salvaged = None
|
|
716
|
+
|
|
717
|
+
if skip_checks and salvaged is not None:
|
|
718
|
+
final_mol = salvaged
|
|
719
|
+
try:
|
|
720
|
+
final_mol.SetIntProp("_xyz_skip_checks", 1)
|
|
721
|
+
except Exception:
|
|
722
|
+
try:
|
|
723
|
+
final_mol._xyz_skip_checks = True
|
|
724
|
+
except Exception:
|
|
725
|
+
pass
|
|
726
|
+
break
|
|
727
|
+
else:
|
|
728
|
+
try:
|
|
729
|
+
self.statusBar().showMessage(f"Retry failed: {e_prompt}")
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
continue
|
|
733
|
+
|
|
734
|
+
except Exception:
|
|
735
|
+
# If the silent attempt failed for reasons other than
|
|
736
|
+
# DetermineBonds failing (e.g., finalization errors), fall
|
|
737
|
+
# back to salvaging or prompting depending on settings.
|
|
738
|
+
salvaged = None
|
|
739
|
+
try:
|
|
740
|
+
salvaged = mol.GetMol()
|
|
741
|
+
except Exception:
|
|
742
|
+
salvaged = None
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
skip_checks = bool(self.settings.get('skip_chemistry_checks', False))
|
|
746
|
+
except Exception:
|
|
747
|
+
skip_checks = False
|
|
748
|
+
|
|
749
|
+
if skip_checks and salvaged is not None:
|
|
750
|
+
final_mol = salvaged
|
|
751
|
+
else:
|
|
752
|
+
# Repeatedly prompt until the user cancels or processing
|
|
753
|
+
# succeeds.
|
|
754
|
+
while True:
|
|
755
|
+
charge_val, ok, skip_flag = prompt_for_charge()
|
|
756
|
+
if not ok:
|
|
757
|
+
# user cancelled the prompt -> abort
|
|
758
|
+
return None
|
|
759
|
+
if skip_flag:
|
|
760
|
+
# User selected Skip chemistry: attempt distance-based salvage
|
|
761
|
+
try:
|
|
762
|
+
self.estimate_bonds_from_distances(mol)
|
|
763
|
+
except Exception:
|
|
764
|
+
pass
|
|
765
|
+
salvaged = None
|
|
766
|
+
try:
|
|
767
|
+
salvaged = mol.GetMol()
|
|
768
|
+
except Exception:
|
|
769
|
+
salvaged = None
|
|
770
|
+
|
|
771
|
+
if salvaged is not None:
|
|
772
|
+
try:
|
|
773
|
+
salvaged.SetIntProp("_xyz_skip_checks", 1)
|
|
774
|
+
except Exception:
|
|
775
|
+
try:
|
|
776
|
+
salvaged._xyz_skip_checks = True
|
|
777
|
+
except Exception:
|
|
778
|
+
pass
|
|
779
|
+
final_mol = salvaged
|
|
780
|
+
break
|
|
781
|
+
else:
|
|
782
|
+
try:
|
|
783
|
+
self.statusBar().showMessage("Skip chemistry selected but failed to create salvaged molecule.")
|
|
784
|
+
except Exception:
|
|
785
|
+
pass
|
|
786
|
+
return None
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
final_mol = _process_with_charge(charge_val)
|
|
790
|
+
# success -> break out of prompt loop
|
|
791
|
+
break
|
|
792
|
+
except RuntimeError:
|
|
793
|
+
# DetermineBonds failed for this charge -> let the
|
|
794
|
+
# user try another
|
|
795
|
+
try:
|
|
796
|
+
self.statusBar().showMessage("DetermineBonds failed for that charge; please try a different total charge or cancel.")
|
|
797
|
+
except Exception:
|
|
798
|
+
pass
|
|
799
|
+
continue
|
|
800
|
+
except Exception as e_prompt:
|
|
801
|
+
try:
|
|
802
|
+
self.statusBar().showMessage(f"Retry failed: {e_prompt}")
|
|
803
|
+
except Exception:
|
|
804
|
+
pass
|
|
805
|
+
continue
|
|
806
|
+
|
|
807
|
+
# If we have a finalized molecule, apply the same UI flags and return
|
|
808
|
+
if final_mol is not None:
|
|
809
|
+
mol = final_mol
|
|
810
|
+
try:
|
|
811
|
+
self.current_mol = mol
|
|
812
|
+
|
|
813
|
+
self.is_xyz_derived = not used_rd_determine
|
|
814
|
+
if hasattr(self, 'optimize_3d_button'):
|
|
815
|
+
try:
|
|
816
|
+
has_bonds = mol.GetNumBonds() > 0
|
|
817
|
+
# Respect the XYZ-derived flag: if the molecule is XYZ-derived,
|
|
818
|
+
# keep Optimize disabled regardless of bond detection.
|
|
819
|
+
if getattr(self, 'is_xyz_derived', False):
|
|
820
|
+
self.optimize_3d_button.setEnabled(False)
|
|
821
|
+
else:
|
|
822
|
+
self.optimize_3d_button.setEnabled(bool(has_bonds))
|
|
823
|
+
except Exception:
|
|
824
|
+
pass
|
|
825
|
+
except Exception:
|
|
826
|
+
pass
|
|
827
|
+
|
|
828
|
+
# Store original atom data for analysis
|
|
829
|
+
mol._xyz_atom_data = atoms_data
|
|
830
|
+
return mol
|
|
831
|
+
|
|
832
|
+
# 元のXYZ原子データを分子オブジェクトに保存(分析用)
|
|
833
|
+
mol._xyz_atom_data = atoms_data
|
|
834
|
+
|
|
835
|
+
return mol
|
|
836
|
+
|
|
837
|
+
except (OSError, IOError) as e:
|
|
838
|
+
raise ValueError(f"File I/O error: {e}")
|
|
839
|
+
except Exception as e:
|
|
840
|
+
if "XYZ file format error" in str(e) or "Unrecognized element" in str(e):
|
|
841
|
+
raise e
|
|
842
|
+
else:
|
|
843
|
+
raise ValueError(f"Error parsing XYZ file: {e}")
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def estimate_bonds_from_distances(self, mol):
|
|
848
|
+
"""原子間距離に基づいて結合を推定する"""
|
|
849
|
+
|
|
850
|
+
# 一般的な共有結合半径(Ångström)- より正確な値
|
|
851
|
+
covalent_radii = {
|
|
852
|
+
'H': 0.31, 'He': 0.28, 'Li': 1.28, 'Be': 0.96, 'B': 0.84, 'C': 0.76,
|
|
853
|
+
'N': 0.75, 'O': 0.73, 'F': 0.71, 'Ne': 0.58, 'Na': 1.66, 'Mg': 1.41,
|
|
854
|
+
'Al': 1.21, 'Si': 1.11, 'P': 1.07, 'S': 1.05, 'Cl': 1.02, 'Ar': 1.06,
|
|
855
|
+
'K': 2.03, 'Ca': 1.76, 'Sc': 1.70, 'Ti': 1.60, 'V': 1.53, 'Cr': 1.39,
|
|
856
|
+
'Mn': 1.39, 'Fe': 1.32, 'Co': 1.26, 'Ni': 1.24, 'Cu': 1.32, 'Zn': 1.22,
|
|
857
|
+
'Ga': 1.22, 'Ge': 1.20, 'As': 1.19, 'Se': 1.20, 'Br': 1.14, 'Kr': 1.16,
|
|
858
|
+
'Rb': 2.20, 'Sr': 1.95, 'Y': 1.90, 'Zr': 1.75, 'Nb': 1.64, 'Mo': 1.54,
|
|
859
|
+
'Tc': 1.47, 'Ru': 1.46, 'Rh': 1.42, 'Pd': 1.39, 'Ag': 1.45, 'Cd': 1.44,
|
|
860
|
+
'In': 1.42, 'Sn': 1.39, 'Sb': 1.39, 'Te': 1.38, 'I': 1.33, 'Xe': 1.40
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
conf = mol.GetConformer()
|
|
864
|
+
num_atoms = mol.GetNumAtoms()
|
|
865
|
+
|
|
866
|
+
# 追加された結合をトラッキング
|
|
867
|
+
bonds_added = []
|
|
868
|
+
|
|
869
|
+
for i in range(num_atoms):
|
|
870
|
+
for j in range(i + 1, num_atoms):
|
|
871
|
+
atom_i = mol.GetAtomWithIdx(i)
|
|
872
|
+
atom_j = mol.GetAtomWithIdx(j)
|
|
873
|
+
|
|
874
|
+
# 原子間距離を計算
|
|
875
|
+
distance = rdMolTransforms.GetBondLength(conf, i, j)
|
|
876
|
+
|
|
877
|
+
# 期待される結合距離を計算
|
|
878
|
+
symbol_i = atom_i.GetSymbol()
|
|
879
|
+
symbol_j = atom_j.GetSymbol()
|
|
880
|
+
|
|
881
|
+
radius_i = covalent_radii.get(symbol_i, 1.0) # デフォルト半径
|
|
882
|
+
radius_j = covalent_radii.get(symbol_j, 1.0)
|
|
883
|
+
|
|
884
|
+
expected_bond_length = radius_i + radius_j
|
|
885
|
+
|
|
886
|
+
# 結合タイプによる許容範囲を調整
|
|
887
|
+
# 水素結合は通常の共有結合より短い
|
|
888
|
+
if symbol_i == 'H' or symbol_j == 'H':
|
|
889
|
+
tolerance_factor = 1.2 # 水素は結合が短くなりがち
|
|
890
|
+
else:
|
|
891
|
+
tolerance_factor = 1.3 # 他の原子は少し余裕を持たせる
|
|
892
|
+
|
|
893
|
+
max_bond_length = expected_bond_length * tolerance_factor
|
|
894
|
+
min_bond_length = expected_bond_length * 0.5 # 最小距離も設定
|
|
895
|
+
|
|
896
|
+
# 距離が期待値の範囲内なら結合を追加
|
|
897
|
+
if min_bond_length <= distance <= max_bond_length:
|
|
898
|
+
try:
|
|
899
|
+
mol.AddBond(i, j, Chem.BondType.SINGLE)
|
|
900
|
+
bonds_added.append((i, j, distance))
|
|
901
|
+
except Exception:
|
|
902
|
+
# 既に結合が存在する場合はスキップ
|
|
903
|
+
pass
|
|
904
|
+
|
|
905
|
+
# デバッグ情報(オプション)
|
|
906
|
+
# Added bonds based on distance analysis
|
|
907
|
+
|
|
908
|
+
return len(bonds_added)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def save_as_mol(self):
|
|
913
|
+
try:
|
|
914
|
+
mol_block = self.data.to_mol_block()
|
|
915
|
+
if not mol_block:
|
|
916
|
+
self.statusBar().showMessage("Error: No 2D data to save.")
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
lines = mol_block.split('\n')
|
|
920
|
+
if len(lines) > 1 and 'RDKit' in lines[1]:
|
|
921
|
+
lines[1] = ' MoleditPy Ver. ' + VERSION + ' 2D'
|
|
922
|
+
modified_mol_block = '\n'.join(lines)
|
|
923
|
+
|
|
924
|
+
# default filename: based on current_file_path, append -2d for 2D mol
|
|
925
|
+
default_name = "untitled-2d"
|
|
926
|
+
try:
|
|
927
|
+
if self.current_file_path:
|
|
928
|
+
base = os.path.basename(self.current_file_path)
|
|
929
|
+
name = os.path.splitext(base)[0]
|
|
930
|
+
default_name = f"{name}-2d"
|
|
931
|
+
except Exception:
|
|
932
|
+
default_name = "untitled-2d"
|
|
933
|
+
|
|
934
|
+
# prefer same directory as current file when available
|
|
935
|
+
default_path = default_name
|
|
936
|
+
try:
|
|
937
|
+
if self.current_file_path:
|
|
938
|
+
default_path = os.path.join(os.path.dirname(self.current_file_path), default_name)
|
|
939
|
+
except Exception:
|
|
940
|
+
default_path = default_name
|
|
941
|
+
|
|
942
|
+
file_path, _ = QFileDialog.getSaveFileName(self, "Save 2D MOL File", default_path, "MOL Files (*.mol);;All Files (*)")
|
|
943
|
+
if not file_path:
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
if not file_path.lower().endswith('.mol'):
|
|
947
|
+
file_path += '.mol'
|
|
948
|
+
|
|
949
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
950
|
+
f.write(modified_mol_block)
|
|
951
|
+
self.statusBar().showMessage(f"2D data saved to {file_path}")
|
|
952
|
+
|
|
953
|
+
except (OSError, IOError) as e:
|
|
954
|
+
self.statusBar().showMessage(f"File I/O error: {e}")
|
|
955
|
+
except UnicodeEncodeError as e:
|
|
956
|
+
self.statusBar().showMessage(f"Text encoding error: {e}")
|
|
957
|
+
except Exception as e:
|
|
958
|
+
self.statusBar().showMessage(f"Error saving file: {e}")
|
|
959
|
+
|
|
960
|
+
traceback.print_exc()
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def save_as_xyz(self):
|
|
965
|
+
if not self.current_mol: self.statusBar().showMessage("Error: Please generate a 3D structure first."); return
|
|
966
|
+
# default filename based on current file
|
|
967
|
+
default_name = "untitled"
|
|
968
|
+
try:
|
|
969
|
+
if self.current_file_path:
|
|
970
|
+
base = os.path.basename(self.current_file_path)
|
|
971
|
+
name = os.path.splitext(base)[0]
|
|
972
|
+
default_name = f"{name}"
|
|
973
|
+
except Exception:
|
|
974
|
+
default_name = "untitled"
|
|
975
|
+
|
|
976
|
+
# prefer same directory as current file when available
|
|
977
|
+
default_path = default_name
|
|
978
|
+
try:
|
|
979
|
+
if self.current_file_path:
|
|
980
|
+
default_path = os.path.join(os.path.dirname(self.current_file_path), default_name)
|
|
981
|
+
except Exception:
|
|
982
|
+
default_path = default_name
|
|
983
|
+
|
|
984
|
+
file_path,_=QFileDialog.getSaveFileName(self,"Save 3D XYZ File",default_path,"XYZ Files (*.xyz);;All Files (*)")
|
|
985
|
+
if file_path:
|
|
986
|
+
if not file_path.lower().endswith('.xyz'): file_path += '.xyz'
|
|
987
|
+
try:
|
|
988
|
+
conf=self.current_mol.GetConformer(); num_atoms=self.current_mol.GetNumAtoms()
|
|
989
|
+
xyz_lines=[str(num_atoms)]
|
|
990
|
+
# 電荷と多重度を計算
|
|
991
|
+
try:
|
|
992
|
+
charge = Chem.GetFormalCharge(self.current_mol)
|
|
993
|
+
except Exception:
|
|
994
|
+
charge = 0 # 取得失敗時は0
|
|
995
|
+
|
|
996
|
+
try:
|
|
997
|
+
# 全原子のラジカル電子の合計を取得
|
|
998
|
+
num_radicals = Descriptors.NumRadicalElectrons(self.current_mol)
|
|
999
|
+
# スピン多重度を計算 (M = N + 1, N=ラジカル電子数)
|
|
1000
|
+
multiplicity = num_radicals + 1
|
|
1001
|
+
except Exception:
|
|
1002
|
+
multiplicity = 1 # 取得失敗時は 1 (singlet)
|
|
1003
|
+
|
|
1004
|
+
smiles=Chem.MolToSmiles(Chem.RemoveHs(self.current_mol))
|
|
1005
|
+
xyz_lines.append(f"chrg = {charge} mult = {multiplicity} | Generated by MoleditPy Ver. {VERSION}")
|
|
1006
|
+
for i in range(num_atoms):
|
|
1007
|
+
pos=conf.GetAtomPosition(i); symbol=self.current_mol.GetAtomWithIdx(i).GetSymbol()
|
|
1008
|
+
xyz_lines.append(f"{symbol} {pos.x:.6f} {pos.y:.6f} {pos.z:.6f}")
|
|
1009
|
+
with open(file_path,'w') as f: f.write("\n".join(xyz_lines) + "\n")
|
|
1010
|
+
self.statusBar().showMessage(f"Successfully saved to {file_path}")
|
|
1011
|
+
except Exception as e: self.statusBar().showMessage(f"Error saving file: {e}")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def fix_mol_counts_line(self, line: str) -> str:
|
|
1015
|
+
"""
|
|
1016
|
+
Check and fix the CTAB counts line in a MOL file.
|
|
1017
|
+
If the line already contains 'V3000' or 'V2000' it is left unchanged.
|
|
1018
|
+
Otherwise the line is treated as V2000 and the proper 39-character
|
|
1019
|
+
format (33 chars of counts + ' V2000') is returned.
|
|
1020
|
+
"""
|
|
1021
|
+
# If already V3000 or V2000, leave as-is
|
|
1022
|
+
if 'V3000' in line or 'V2000' in line:
|
|
1023
|
+
return line
|
|
1024
|
+
|
|
1025
|
+
# Prepare prefix (first 33 characters for the 11 * I3 fields)
|
|
1026
|
+
prefix = line.rstrip().ljust(33)[0:33]
|
|
1027
|
+
version_str = ' V2000'
|
|
1028
|
+
return prefix + version_str
|
|
1029
|
+
|
|
1030
|
+
def fix_mol_block(self, mol_block: str) -> str:
|
|
1031
|
+
"""
|
|
1032
|
+
Given an entire MOL block as a string, ensure the 4th line (CTAB counts
|
|
1033
|
+
line) is valid. If the file has fewer than 4 lines, return as-is.
|
|
1034
|
+
"""
|
|
1035
|
+
lines = mol_block.splitlines()
|
|
1036
|
+
if len(lines) < 4:
|
|
1037
|
+
# Not a valid MOL block — return unchanged
|
|
1038
|
+
return mol_block
|
|
1039
|
+
|
|
1040
|
+
counts_line = lines[3]
|
|
1041
|
+
fixed_counts_line = self.fix_mol_counts_line(counts_line)
|
|
1042
|
+
lines[3] = fixed_counts_line
|
|
1043
|
+
return "\n".join(lines)
|
|
1044
|
+
|