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

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