MoleditPy-linux 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+