moleditpy 1.9.0__tar.gz → 1.9.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleditpy
3
- Version: 1.9.0
3
+ Version: 1.9.2
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/HiroYokoyama/python_molecular_editor
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "moleditpy"
7
7
 
8
- version = "1.9.0"
8
+ version = "1.9.2"
9
9
 
10
10
  authors = [
11
11
  { name="HiroYokoyama", email="titech.yoko.hiro@gmail.com" },
@@ -11,7 +11,7 @@ DOI 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  #Version
14
- VERSION = '1.9.0'
14
+ VERSION = '1.9.2'
15
15
 
16
16
  print("-----------------------------------------------------")
17
17
  print("MoleditPy — A Python-based molecular editing software")
@@ -37,7 +37,7 @@ from PyQt6.QtWidgets import (
37
37
  QPushButton, QSplitter, QGraphicsView, QGraphicsScene, QGraphicsItem,
38
38
  QToolBar, QStatusBar, QGraphicsTextItem, QGraphicsLineItem, QDialog, QGridLayout,
39
39
  QFileDialog, QSizePolicy, QLabel, QLineEdit, QToolButton, QMenu, QMessageBox, QInputDialog,
40
- QColorDialog, QCheckBox, QSlider, QFormLayout, QRadioButton, QComboBox, QListWidget, QListWidgetItem, QButtonGroup, QTabWidget, QScrollArea
40
+ QColorDialog, QCheckBox, QSlider, QFormLayout, QRadioButton, QComboBox, QListWidget, QListWidgetItem, QButtonGroup, QTabWidget, QScrollArea, QFrame
41
41
  )
42
42
 
43
43
  from PyQt6.QtGui import (
@@ -58,7 +58,14 @@ from rdkit.Chem import rdMolDescriptors
58
58
 
59
59
 
60
60
  # Open Babel Python binding (optional; required for fallback)
61
- from openbabel import pybel
61
+ try:
62
+ from openbabel import pybel
63
+ OBABEL_AVAILABLE = True
64
+ except Exception:
65
+ # pybel (Open Babel Python bindings) is optional. If not present, disable OBabel features.
66
+ pybel = None
67
+ OBABEL_AVAILABLE = False
68
+ print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
62
69
 
63
70
  # PyVista
64
71
  import pyvista as pv
@@ -1025,9 +1032,6 @@ class TranslationDialog(Dialog3DPickingMixin, QDialog):
1025
1032
  # 移動ベクトルを計算
1026
1033
  translation_vector = target_pos - current_centroid
1027
1034
 
1028
- # Undo状態を保存
1029
- self.main_window.push_undo_state()
1030
-
1031
1035
  # 全原子を平行移動
1032
1036
  conf = self.mol.GetConformer()
1033
1037
  for i in range(self.mol.GetNumAtoms()):
@@ -1036,6 +1040,7 @@ class TranslationDialog(Dialog3DPickingMixin, QDialog):
1036
1040
  conf.SetAtomPosition(i, new_pos.tolist())
1037
1041
  self.main_window.atom_positions_3d[i] = new_pos
1038
1042
 
1043
+
1039
1044
  # 3D表示を更新
1040
1045
  self.main_window.draw_molecule_3d(self.mol)
1041
1046
 
@@ -1045,6 +1050,9 @@ class TranslationDialog(Dialog3DPickingMixin, QDialog):
1045
1050
  # Apply後に選択解除
1046
1051
  self.clear_selection()
1047
1052
 
1053
+ # Undo状態を保存
1054
+ self.main_window.push_undo_state()
1055
+
1048
1056
  except Exception as e:
1049
1057
  QMessageBox.critical(self, "Error", f"Failed to apply translation: {str(e)}")
1050
1058
 
@@ -1522,8 +1530,6 @@ class SymmetrizeDialog(QDialog):
1522
1530
  key, point_group = self.get_selected_point_group()
1523
1531
 
1524
1532
  try:
1525
- # Undo状態を保存(操作前の状態のみ)
1526
- self.main_window.push_undo_state()
1527
1533
 
1528
1534
  # 現在の分子の座標を取得
1529
1535
  conf = self.mol.GetConformer()
@@ -1544,6 +1550,9 @@ class SymmetrizeDialog(QDialog):
1544
1550
 
1545
1551
  # 3D表示のみを更新
1546
1552
  self.main_window.draw_molecule_3d(self.mol)
1553
+
1554
+ # Undo状態を保存(操作前の状態のみ)
1555
+ self.main_window.push_undo_state()
1547
1556
 
1548
1557
  # 成功メッセージ(ダイアログを閉じる前に表示)
1549
1558
  QMessageBox.information(
@@ -2659,7 +2668,8 @@ class MirrorDialog(QDialog):
2659
2668
 
2660
2669
  plane_names = ["XY", "XZ", "YZ"]
2661
2670
  self.main_window.statusBar().showMessage(f"Molecule mirrored across {plane_names[plane_id]} plane.")
2662
-
2671
+
2672
+
2663
2673
  except Exception as e:
2664
2674
  QMessageBox.critical(self, "Error", f"Failed to apply mirror transformation: {str(e)}")
2665
2675
 
@@ -2816,26 +2826,26 @@ class PlanarizationDialog(Dialog3DPickingMixin, QDialog):
2816
2826
  if len(self.selected_atoms) < 3:
2817
2827
  QMessageBox.warning(self, "Warning", "Please select at least 3 atoms for planarization.")
2818
2828
  return
2819
-
2820
2829
  try:
2830
+
2821
2831
  # 選択された原子の位置を取得
2822
2832
  selected_indices = list(self.selected_atoms)
2823
2833
  selected_positions = self.main_window.atom_positions_3d[selected_indices].copy()
2824
-
2834
+
2825
2835
  # 重心を計算
2826
2836
  centroid = np.mean(selected_positions, axis=0)
2827
-
2837
+
2828
2838
  # 重心を原点に移動
2829
2839
  centered_positions = selected_positions - centroid
2830
-
2840
+
2831
2841
  # 主成分分析で最適な平面を見つける
2832
2842
  # 選択された原子の座標の共分散行列を計算
2833
2843
  cov_matrix = np.cov(centered_positions.T)
2834
2844
  eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
2835
-
2845
+
2836
2846
  # 固有値が最も小さい固有ベクトルが平面の法線方向
2837
2847
  normal_vector = eigenvectors[:, 0] # 最小固有値に対応する固有ベクトル
2838
-
2848
+
2839
2849
  # 目標の平面の法線ベクトルを定義
2840
2850
  if self.plane == 'xy':
2841
2851
  target_normal = np.array([0, 0, 1]) # Z軸方向
@@ -2843,27 +2853,27 @@ class PlanarizationDialog(Dialog3DPickingMixin, QDialog):
2843
2853
  target_normal = np.array([0, 1, 0]) # Y軸方向
2844
2854
  elif self.plane == 'yz':
2845
2855
  target_normal = np.array([1, 0, 0]) # X軸方向
2846
-
2856
+
2847
2857
  # 法線ベクトルの向きを調整(内積が正になるように)
2848
2858
  if np.dot(normal_vector, target_normal) < 0:
2849
2859
  normal_vector = -normal_vector
2850
-
2860
+
2851
2861
  # 回転軸と回転角度を計算
2852
2862
  rotation_axis = np.cross(normal_vector, target_normal)
2853
2863
  rotation_axis_norm = np.linalg.norm(rotation_axis)
2854
-
2864
+
2855
2865
  if rotation_axis_norm > 1e-10: # 回転が必要な場合
2856
2866
  rotation_axis = rotation_axis / rotation_axis_norm
2857
2867
  cos_angle = np.dot(normal_vector, target_normal)
2858
2868
  cos_angle = np.clip(cos_angle, -1.0, 1.0)
2859
2869
  rotation_angle = np.arccos(cos_angle)
2860
-
2870
+
2861
2871
  # Rodrigues回転公式を使用して全分子を回転
2862
2872
  def rodrigues_rotation(v, axis, angle):
2863
2873
  cos_a = np.cos(angle)
2864
2874
  sin_a = np.sin(angle)
2865
2875
  return v * cos_a + np.cross(axis, v) * sin_a + axis * np.dot(axis, v) * (1 - cos_a)
2866
-
2876
+
2867
2877
  # 分子全体を回転させる
2868
2878
  conf = self.mol.GetConformer()
2869
2879
  for i in range(self.mol.GetNumAtoms()):
@@ -2874,13 +2884,16 @@ class PlanarizationDialog(Dialog3DPickingMixin, QDialog):
2874
2884
  new_pos = rotated_pos + centroid
2875
2885
  conf.SetAtomPosition(i, new_pos.tolist())
2876
2886
  self.main_window.atom_positions_3d[i] = new_pos
2877
-
2887
+
2878
2888
  # 3D表示を更新
2879
2889
  self.main_window.draw_molecule_3d(self.mol)
2880
-
2890
+
2881
2891
  # キラルラベルを更新
2882
2892
  self.main_window.update_chiral_labels()
2883
-
2893
+
2894
+ # Undo状態を保存
2895
+ self.main_window.push_undo_state()
2896
+
2884
2897
  except Exception as e:
2885
2898
  QMessageBox.critical(self, "Error", f"Failed to apply planarization: {str(e)}")
2886
2899
 
@@ -3056,27 +3069,27 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
3056
3069
  if len(self.selected_atoms) != 2:
3057
3070
  QMessageBox.warning(self, "Warning", "Please select exactly 2 atoms for alignment.")
3058
3071
  return
3059
-
3060
3072
  try:
3073
+
3061
3074
  selected_list = sorted(list(self.selected_atoms))
3062
3075
  atom1_idx, atom2_idx = selected_list[0], selected_list[1]
3063
-
3076
+
3064
3077
  conf = self.mol.GetConformer()
3065
-
3078
+
3066
3079
  # 原子の現在位置を取得
3067
3080
  pos1 = np.array(conf.GetAtomPosition(atom1_idx))
3068
3081
  pos2 = np.array(conf.GetAtomPosition(atom2_idx))
3069
-
3082
+
3070
3083
  # 最初に全分子を移動して、atom1を原点に配置
3071
3084
  translation = -pos1
3072
3085
  for i in range(self.mol.GetNumAtoms()):
3073
3086
  current_pos = np.array(conf.GetAtomPosition(i))
3074
3087
  new_pos = current_pos + translation
3075
3088
  conf.SetAtomPosition(i, new_pos.tolist())
3076
-
3089
+
3077
3090
  # atom2の新しい位置を取得(移動後)
3078
3091
  pos2_translated = pos2 + translation
3079
-
3092
+
3080
3093
  # atom2を選択した軸上に配置するための回転を計算
3081
3094
  axis_vectors = {
3082
3095
  'x': np.array([1.0, 0.0, 0.0]),
@@ -3126,6 +3139,9 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
3126
3139
 
3127
3140
  # キラルラベルを更新
3128
3141
  self.main_window.update_chiral_labels()
3142
+
3143
+ # Undo状態を保存
3144
+ self.main_window.push_undo_state()
3129
3145
 
3130
3146
  QMessageBox.information(self, "Success", f"Alignment to {self.axis.upper()}-axis completed.")
3131
3147
 
@@ -4903,7 +4919,7 @@ class MoleculeScene(QGraphicsScene):
4903
4919
  return points
4904
4920
 
4905
4921
  def delete_items(self, items_to_delete):
4906
- """指定されたアイテムセット(原子・結合)を安全に削除する共通メソッド"""
4922
+ """指定されたアイテムセット(原子・結合)を安全な順序で削除する修正版"""
4907
4923
  try:
4908
4924
  if not items_to_delete:
4909
4925
  return False
@@ -4916,7 +4932,7 @@ class MoleculeScene(QGraphicsScene):
4916
4932
  if hasattr(atom, 'bonds'):
4917
4933
  bonds_to_delete.update(atom.bonds)
4918
4934
 
4919
- # 影響を受ける(が削除はされない)原子を特定する
4935
+ # 削除はされないが、影響を受ける原子(結合リストの更新が必要)を特定する
4920
4936
  atoms_to_update = set()
4921
4937
  for bond in bonds_to_delete:
4922
4938
  if bond.atom1 and bond.atom1 not in atoms_to_delete:
@@ -4924,62 +4940,49 @@ class MoleculeScene(QGraphicsScene):
4924
4940
  if bond.atom2 and bond.atom2 not in atoms_to_delete:
4925
4941
  atoms_to_update.add(bond.atom2)
4926
4942
 
4927
- # --- データモデルからの削除 ---
4928
- # 最初に原子をデータモデルから削除(関連する結合も内部で削除される)
4929
- for atom in atoms_to_delete:
4930
- if hasattr(atom, 'atom_id'):
4931
- self.data.remove_atom(atom.atom_id)
4932
-
4933
- # 次に、明示的に選択された結合(まだ残っているもの)を削除
4934
- for bond in bonds_to_delete:
4935
- if bond.atom1 and bond.atom2 and hasattr(bond.atom1, 'atom_id') and hasattr(bond.atom2, 'atom_id'):
4936
- self.data.remove_bond(bond.atom1.atom_id, bond.atom2.atom_id)
4937
-
4938
- # --- シーンからのグラフィックアイテム削除(必ず結合を先に)---
4939
- # まずremoveItem
4943
+ # --- 安全な順序で削除処理を実行 ---
4944
+
4945
+ # 1. まず、生き残る原子の内部状態を更新する
4946
+ for atom in atoms_to_update:
4947
+ if hasattr(atom, 'bonds'):
4948
+ # これから削除される結合を自身の結合リストから除去する
4949
+ atom.bonds = [b for b in atom.bonds if b not in bonds_to_delete]
4950
+
4951
+ # 2. 次に、グラフィックシーンからアイテムを削除する(必ず結合を先に)
4940
4952
  for bond in bonds_to_delete:
4941
4953
  if bond.scene():
4942
4954
  self.removeItem(bond)
4943
-
4944
4955
  for atom in atoms_to_delete:
4945
4956
  if atom.scene():
4946
4957
  self.removeItem(atom)
4947
4958
 
4948
- # その後BondItem/AtomItemの参照をクリア
4959
+ # 3. データモデル(MolecularData)からデータを削除する
4960
+ # ここでは安全なIDを使って操作する
4949
4961
  for bond in bonds_to_delete:
4950
- # atom1/atom2のbondsリストからこのbondを除去
4951
- try:
4952
- if bond.atom1 and hasattr(bond.atom1, 'bonds') and bond in bond.atom1.bonds:
4953
- bond.atom1.bonds = [b for b in bond.atom1.bonds if b is not bond]
4954
- if bond.atom2 and hasattr(bond.atom2, 'bonds') and bond in bond.atom2.bonds:
4955
- bond.atom2.bonds = [b for b in bond.atom2.bonds if b is not bond]
4956
- # Clear bond references to prevent dangling pointers
4957
- bond.atom1 = None
4958
- bond.atom2 = None
4959
- except Exception as bond_cleanup_error:
4960
- print(f"Error cleaning up bond references: {bond_cleanup_error}")
4961
-
4962
+ # 参照が既にクリアされている可能性を考慮
4963
+ if bond.atom1 and bond.atom2:
4964
+ self.data.remove_bond(bond.atom1.atom_id, bond.atom2.atom_id)
4962
4965
  for atom in atoms_to_delete:
4963
- # Clear all bond references from deleted atoms
4964
- try:
4965
- if hasattr(atom, 'bonds'):
4966
- atom.bonds.clear()
4967
- except Exception as atom_cleanup_error:
4968
- print(f"Error cleaning up atom references: {atom_cleanup_error}")
4966
+ if hasattr(atom, 'atom_id'):
4967
+ self.data.remove_atom(atom.atom_id)
4969
4968
 
4970
- # --- 生き残った原子の内部参照とスタイルを更新 ---
4969
+ # 4. 削除されたオブジェクト間の参照をクリアし、メモリ解放を助ける
4970
+ for bond in bonds_to_delete:
4971
+ bond.atom1 = None
4972
+ bond.atom2 = None
4973
+ for atom in atoms_to_delete:
4974
+ if hasattr(atom, 'bonds'):
4975
+ atom.bonds.clear()
4976
+
4977
+ # 5. 最後に、生き残った原子の表示スタイルを更新する
4971
4978
  for atom in atoms_to_update:
4972
- try:
4973
- if hasattr(atom, 'bonds'):
4974
- atom.bonds = [b for b in atom.bonds if b not in bonds_to_delete]
4975
- if hasattr(atom, 'update_style'):
4976
- atom.update_style()
4977
- except Exception as update_error:
4978
- print(f"Error updating atom style: {update_error}")
4979
+ if hasattr(atom, 'update_style'):
4980
+ atom.update_style()
4979
4981
 
4980
- return True
4982
+ return True # 変更が行われたことを示す
4981
4983
 
4982
4984
  except Exception as e:
4985
+ # 予期せぬエラーが発生した場合でもクラッシュを防ぐ
4983
4986
  print(f"Error during delete_items operation: {e}")
4984
4987
  import traceback
4985
4988
  traceback.print_exc()
@@ -5720,6 +5723,7 @@ class CalculationWorker(QObject):
5720
5723
  raise ValueError("No atoms to convert.")
5721
5724
 
5722
5725
  self.status_update.emit("Creating 3D structure...")
5726
+
5723
5727
  mol = Chem.MolFromMolBlock(mol_block, removeHs=False)
5724
5728
  if mol is None:
5725
5729
  raise ValueError("Failed to create molecule from MOL block.")
@@ -5926,6 +5930,9 @@ class CalculationWorker(QObject):
5926
5930
  self.status_update.emit("RDKit embedding failed. Attempting Open Babel fallback...")
5927
5931
 
5928
5932
  try:
5933
+ # Check availability first
5934
+ if not OBABEL_AVAILABLE:
5935
+ raise RuntimeError("Open Babel (pybel) is not available in this Python environment.")
5929
5936
  # pybel expects an input format; provide mol block
5930
5937
  # pybel.readstring accepts format strings like "mol" or "smi"
5931
5938
  ob_mol = pybel.readstring("mol", mol_block)
@@ -6246,6 +6253,11 @@ class SettingsDialog(QDialog):
6246
6253
  'triple_bond_offset_factor': 2.0,
6247
6254
  'double_bond_radius_factor': 0.8,
6248
6255
  'triple_bond_radius_factor': 0.7,
6256
+ # If True, attempts to be permissive when RDKit raises chemical/sanitization errors
6257
+ # during file import (useful for viewing malformed XYZ/MOL files). When enabled,
6258
+ # element symbol recognition will be coerced where possible and Chem.SanitizeMol
6259
+ # failures will be ignored so the 3D viewer can still display the structure.
6260
+ 'skip_chemistry_checks': True,
6249
6261
  }
6250
6262
 
6251
6263
  # --- 選択された色を管理する専用のインスタンス変数 ---
@@ -6258,9 +6270,6 @@ class SettingsDialog(QDialog):
6258
6270
  self.tab_widget = QTabWidget()
6259
6271
  layout.addWidget(self.tab_widget)
6260
6272
 
6261
- # 基本設定タブ
6262
- self.create_general_tab()
6263
-
6264
6273
  # Common設定タブ
6265
6274
  self.create_common_tab()
6266
6275
 
@@ -6276,6 +6285,9 @@ class SettingsDialog(QDialog):
6276
6285
  # Stick設定タブ
6277
6286
  self.create_stick_tab()
6278
6287
 
6288
+ # 基本設定タブ
6289
+ self.create_general_tab()
6290
+
6279
6291
  # 渡された設定でUIと内部変数を初期化
6280
6292
  self.update_ui_from_settings(current_settings)
6281
6293
 
@@ -6366,7 +6378,23 @@ class SettingsDialog(QDialog):
6366
6378
  """Common設定タブを作成"""
6367
6379
  common_widget = QWidget()
6368
6380
  form_layout = QFormLayout(common_widget)
6381
+
6382
+ # 化学チェックスキップオプション(Commonタブに移動)
6383
+ self.skip_chem_checks_checkbox = QCheckBox()
6384
+ self.skip_chem_checks_checkbox.setToolTip("When enabled, file import will try to ignore chemical/sanitization errors and allow viewing malformed files.")
6385
+ # Immediately persist change to settings when user toggles the checkbox
6386
+ try:
6387
+ self.skip_chem_checks_checkbox.stateChanged.connect(lambda s: self._on_skip_chem_checks_changed(s))
6388
+ except Exception:
6389
+ pass
6390
+ form_layout.addRow("Skip chemistry checks on import:", self.skip_chem_checks_checkbox)
6369
6391
 
6392
+ # --- 区切り線(水平ライン) ---
6393
+ line = QFrame()
6394
+ line.setFrameShape(QFrame.Shape.HLine)
6395
+ line.setFrameShadow(QFrame.Shadow.Sunken)
6396
+ form_layout.addRow(line)
6397
+
6370
6398
  # 二重結合オフセット倍率
6371
6399
  self.double_offset_slider = QSlider(Qt.Orientation.Horizontal)
6372
6400
  self.double_offset_slider.setRange(100, 400) # 1.0 ~ 4.0
@@ -6406,6 +6434,8 @@ class SettingsDialog(QDialog):
6406
6434
  triple_radius_layout.addWidget(self.triple_radius_slider)
6407
6435
  triple_radius_layout.addWidget(self.triple_radius_label)
6408
6436
  form_layout.addRow("Triple Bond Thickness:", triple_radius_layout)
6437
+
6438
+
6409
6439
 
6410
6440
  self.tab_widget.addTab(common_widget, "Common")
6411
6441
 
@@ -6735,6 +6765,9 @@ class SettingsDialog(QDialog):
6735
6765
  triple_radius = int(settings_dict.get('triple_bond_radius_factor', self.default_settings['triple_bond_radius_factor']) * 100)
6736
6766
  self.triple_radius_slider.setValue(triple_radius)
6737
6767
  self.triple_radius_label.setText(f"{triple_radius/100:.2f}")
6768
+
6769
+ # skip chemistry checks
6770
+ self.skip_chem_checks_checkbox.setChecked(settings_dict.get('skip_chemistry_checks', self.default_settings.get('skip_chemistry_checks', False)))
6738
6771
 
6739
6772
  def select_color(self):
6740
6773
  """カラーピッカーを開き、選択された色を内部変数とUIに反映させる"""
@@ -6777,6 +6810,7 @@ class SettingsDialog(QDialog):
6777
6810
  'triple_bond_offset_factor': self.triple_offset_slider.value() / 100.0,
6778
6811
  'double_bond_radius_factor': self.double_radius_slider.value() / 100.0,
6779
6812
  'triple_bond_radius_factor': self.triple_radius_slider.value() / 100.0,
6813
+ 'skip_chemistry_checks': self.skip_chem_checks_checkbox.isChecked(),
6780
6814
  }
6781
6815
 
6782
6816
  def apply_settings(self):
@@ -6792,7 +6826,21 @@ class SettingsDialog(QDialog):
6792
6826
  if hasattr(self.parent_window, 'current_mol') and self.parent_window.current_mol:
6793
6827
  self.parent_window.draw_molecule_3d(self.parent_window.current_mol)
6794
6828
  # ステータスバーに適用完了を表示
6795
- self.parent_window.statusBar().showMessage("Settings applied successfully", 2000)
6829
+ self.parent_window.statusBar().showMessage("Settings applied successfully")
6830
+
6831
+ def _on_skip_chem_checks_changed(self, state):
6832
+ """Handle user toggling of skip chemistry checks: persist and update UI.
6833
+
6834
+ state: Qt.Checked (2) or Qt.Unchecked (0)
6835
+ """
6836
+ try:
6837
+ enabled = bool(state)
6838
+ self.settings['skip_chemistry_checks'] = enabled
6839
+ self.save_settings()
6840
+ # If skip is enabled, allow Optimize button; otherwise, respect chem_check flags
6841
+
6842
+ except Exception:
6843
+ pass
6796
6844
 
6797
6845
  def accept(self):
6798
6846
  """ダイアログの設定を適用してから閉じる"""
@@ -7048,7 +7096,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
7048
7096
 
7049
7097
  class MainWindow(QMainWindow):
7050
7098
 
7051
- start_calculation = pyqtSignal(str)
7099
+ # start_calculation carries the MOL block and a boolean skip_chemistry_checks flag
7100
+ start_calculation = pyqtSignal(str, bool)
7052
7101
  def __init__(self, initial_file=None):
7053
7102
  super().__init__()
7054
7103
  self.setAcceptDrops(True)
@@ -7068,6 +7117,11 @@ class MainWindow(QMainWindow):
7068
7117
  self.atom_actor = None
7069
7118
  self.is_2d_editable = True
7070
7119
  self.is_xyz_derived = False # XYZ由来の分子かどうかのフラグ
7120
+ # Chemical check flags: whether a chemical/sanitization check was attempted and whether it failed
7121
+ self.chem_check_tried = False
7122
+ self.chem_check_failed = False
7123
+ # 3D最適化のデフォルト手法
7124
+ self.optimization_method = self.settings.get('optimization_method', 'MMFF_RDKIT')
7071
7125
  self.axes_actor = None
7072
7126
  self.axes_widget = None
7073
7127
  self._template_dialog = None # テンプレートダイアログの参照
@@ -7699,6 +7753,63 @@ class MainWindow(QMainWindow):
7699
7753
  optimize_3d_action.triggered.connect(self.optimize_3d_structure)
7700
7754
  edit_menu.addAction(optimize_3d_action)
7701
7755
 
7756
+ # --- 3D Optimization Settings submenu (below Optimize 3D) ---
7757
+ opt3d_settings_menu = edit_menu.addMenu("3D Optimization Settings")
7758
+ opt3d_group = QActionGroup(self)
7759
+ opt3d_group.setExclusive(True)
7760
+
7761
+ # Only RDKit-backed optimization methods are offered here.
7762
+ # Open Babel-based optimization options have been removed; Open Babel is
7763
+ # retained only as a conversion fallback when RDKit embedding fails.
7764
+ opt_methods = [
7765
+ ("MMFF", "MMFF_RDKIT"),
7766
+ ("UFF", "UFF_RDKIT"),
7767
+ ]
7768
+
7769
+ # Map key -> human-readable label for status messages and later lookups
7770
+ try:
7771
+ self.opt3d_method_labels = {key.upper(): label for (label, key) in opt_methods}
7772
+ except Exception:
7773
+ self.opt3d_method_labels = {}
7774
+
7775
+ opt_actions = {}
7776
+ for label, key in opt_methods:
7777
+ action = QAction(label, self)
7778
+ action.setCheckable(True)
7779
+ # Ensure explicit association with the action group so exclusive checking works reliably
7780
+ try:
7781
+ action.setActionGroup(opt3d_group)
7782
+ except Exception:
7783
+ # Older PyQt variants may not have setActionGroup; adding to group below still helps
7784
+ pass
7785
+ action.triggered.connect(lambda checked, m=key: self.set_optimization_method(m))
7786
+ opt3d_settings_menu.addAction(action)
7787
+ opt3d_group.addAction(action)
7788
+ opt_actions[key] = action
7789
+
7790
+ # Persist the actions mapping so other methods can update the checked state
7791
+ self.opt3d_actions = opt_actions
7792
+
7793
+ # Determine the initial checked menu item from saved settings (fall back to MMFF_RDKIT)
7794
+ try:
7795
+ saved = (self.settings.get('optimization_method') or self.optimization_method or 'MMFF_RDKIT').upper()
7796
+ except Exception:
7797
+ saved = 'MMFF_RDKIT'
7798
+
7799
+ if saved in self.opt3d_actions and self.opt3d_actions[saved].isEnabled():
7800
+ self.opt3d_actions[saved].setChecked(True)
7801
+ # Ensure internal state matches
7802
+ self.optimization_method = saved
7803
+ else:
7804
+ # fallback
7805
+ if 'MMFF_RDKIT' in self.opt3d_actions:
7806
+ self.opt3d_actions['MMFF_RDKIT'].setChecked(True)
7807
+ self.optimization_method = 'MMFF_RDKIT'
7808
+
7809
+ # Note: Open Babel-based optimization menu entries were intentionally
7810
+ # removed above. Open Babel (pybel) is still available for conversion
7811
+ # fallback elsewhere in the code, so we don't disable menu items here.
7812
+
7702
7813
  edit_menu.addSeparator()
7703
7814
 
7704
7815
  # Templates
@@ -8058,6 +8169,53 @@ class MainWindow(QMainWindow):
8058
8169
  if self.current_mol:
8059
8170
  self.draw_molecule_3d(self.current_mol)
8060
8171
 
8172
+ def set_optimization_method(self, method_name):
8173
+ """Set preferred 3D optimization method and persist to settings.
8174
+
8175
+ Supported values: 'GAFF', 'MMFF'
8176
+ """
8177
+ # Normalize input and validate
8178
+ if not method_name:
8179
+ return
8180
+ method = str(method_name).strip().upper()
8181
+ valid_methods = (
8182
+ 'MMFF_RDKIT', 'UFF_RDKIT',
8183
+ 'UFF_OBABEL', 'GAFF_OBABEL', 'MMFF94_OBABEL', 'GHEMICAL_OBABEL'
8184
+ )
8185
+ if method not in valid_methods:
8186
+ # Unknown method: ignore but notify
8187
+ self.statusBar().showMessage(f"Unknown 3D optimization method: {method_name}")
8188
+ return
8189
+
8190
+ # Update internal state (store canonical uppercase key)
8191
+ self.optimization_method = method
8192
+
8193
+ # Persist to settings
8194
+ try:
8195
+ self.settings['optimization_method'] = self.optimization_method
8196
+ self.save_settings()
8197
+ except Exception:
8198
+ pass
8199
+
8200
+ # Update menu checked state if actions mapping exists
8201
+ try:
8202
+ if hasattr(self, 'opt3d_actions') and self.opt3d_actions:
8203
+ for k, act in self.opt3d_actions.items():
8204
+ try:
8205
+ # keys in opt3d_actions may be mixed-case; compare uppercased
8206
+ act.setChecked(k.upper() == method)
8207
+ except Exception:
8208
+ pass
8209
+ except Exception:
8210
+ pass
8211
+
8212
+ # Also show user-friendly label if available
8213
+ try:
8214
+ label = self.opt3d_method_labels.get(self.optimization_method, self.optimization_method)
8215
+ except Exception:
8216
+ label = self.optimization_method
8217
+ self.statusBar().showMessage(f"3D optimization method set to: {label}")
8218
+
8061
8219
  def copy_selection(self):
8062
8220
  """選択された原子と結合をクリップボードにコピーする"""
8063
8221
  try:
@@ -8109,13 +8267,13 @@ class MainWindow(QMainWindow):
8109
8267
  mime_data = QMimeData()
8110
8268
  mime_data.setData(CLIPBOARD_MIME_TYPE, byte_array)
8111
8269
  QApplication.clipboard().setMimeData(mime_data)
8112
- self.statusBar().showMessage(f"Copied {len(fragment_atoms)} atoms and {len(fragment_bonds)} bonds.", 2000)
8270
+ self.statusBar().showMessage(f"Copied {len(fragment_atoms)} atoms and {len(fragment_bonds)} bonds.")
8113
8271
 
8114
8272
  except Exception as e:
8115
8273
  print(f"Error during copy operation: {e}")
8116
8274
  import traceback
8117
8275
  traceback.print_exc()
8118
- self.statusBar().showMessage(f"Error during copy operation: {e}", 5000)
8276
+ self.statusBar().showMessage(f"Error during copy operation: {e}")
8119
8277
 
8120
8278
  def cut_selection(self):
8121
8279
  """選択されたアイテムを切り取り(コピーしてから削除)"""
@@ -8135,7 +8293,7 @@ class MainWindow(QMainWindow):
8135
8293
  print(f"Error during cut operation: {e}")
8136
8294
  import traceback
8137
8295
  traceback.print_exc()
8138
- self.statusBar().showMessage(f"Error during cut operation: {e}", 5000)
8296
+ self.statusBar().showMessage(f"Error during cut operation: {e}")
8139
8297
 
8140
8298
  def paste_from_clipboard(self):
8141
8299
  """クリップボードから分子フラグメントを貼り付け"""
@@ -8150,7 +8308,7 @@ class MainWindow(QMainWindow):
8150
8308
  try:
8151
8309
  fragment_data = pickle.load(buffer)
8152
8310
  except pickle.UnpicklingError:
8153
- self.statusBar().showMessage("Error: Invalid clipboard data format", 3000)
8311
+ self.statusBar().showMessage("Error: Invalid clipboard data format")
8154
8312
  return
8155
8313
 
8156
8314
  paste_center_pos = self.view_2d.mapToScene(self.view_2d.mapFromGlobal(QCursor.pos()))
@@ -8184,7 +8342,7 @@ class MainWindow(QMainWindow):
8184
8342
  print(f"Error during paste operation: {e}")
8185
8343
  import traceback
8186
8344
  traceback.print_exc()
8187
- self.statusBar().showMessage(f"Error during paste operation: {e}", 5000)
8345
+ self.statusBar().showMessage(f"Error during paste operation: {e}")
8188
8346
  self.statusBar().showMessage(f"Pasted {len(new_atoms)} atoms.", 2000)
8189
8347
  self.activate_select_mode()
8190
8348
 
@@ -8207,13 +8365,13 @@ class MainWindow(QMainWindow):
8207
8365
  self.push_undo_state()
8208
8366
  self.statusBar().showMessage(f"Removed {len(hydrogen_atoms)} hydrogen atoms.", 2000)
8209
8367
  else:
8210
- self.statusBar().showMessage("Failed to remove hydrogen atoms.", 3000)
8368
+ self.statusBar().showMessage("Failed to remove hydrogen atoms.")
8211
8369
 
8212
8370
  except Exception as e:
8213
8371
  print(f"Error during hydrogen removal: {e}")
8214
8372
  import traceback
8215
8373
  traceback.print_exc()
8216
- self.statusBar().showMessage(f"Error removing hydrogen atoms: {e}", 5000)
8374
+ self.statusBar().showMessage(f"Error removing hydrogen atoms: {e}")
8217
8375
 
8218
8376
  def update_edit_menu_actions(self):
8219
8377
  """選択状態やクリップボードの状態に応じて編集メニューを更新"""
@@ -8245,7 +8403,7 @@ class MainWindow(QMainWindow):
8245
8403
  return
8246
8404
 
8247
8405
  mol = self.data.to_rdkit_mol(use_2d_stereo=False)
8248
-
8406
+
8249
8407
  # 分子オブジェクトが作成できない場合でも化学的問題をチェック
8250
8408
  if not mol or mol.GetNumAtoms() == 0:
8251
8409
  # RDKitでの変換に失敗した場合は、独自の化学的問題チェックを実行
@@ -8269,7 +8427,7 @@ class MainWindow(QMainWindow):
8269
8427
  self.statusBar().showMessage(f"Error: {len(problems)} chemistry problem(s) found.")
8270
8428
  # 既存の選択状態をクリア
8271
8429
  self.scene.clearSelection()
8272
-
8430
+
8273
8431
  # 問題のある原子に赤枠フラグを立てる
8274
8432
  for prob in problems:
8275
8433
  atom_idx = prob.GetAtomIdx()
@@ -8281,7 +8439,7 @@ class MainWindow(QMainWindow):
8281
8439
  item = self.data.atoms[original_id]['item']
8282
8440
  item.has_problem = True
8283
8441
  item.update()
8284
-
8442
+
8285
8443
  self.view_2d.setFocus()
8286
8444
  return
8287
8445
 
@@ -8360,7 +8518,12 @@ class MainWindow(QMainWindow):
8360
8518
  )
8361
8519
  text_actor.GetTextProperty().SetOpacity(1)
8362
8520
  self.plotter.render()
8363
- self.start_calculation.emit(mol_block)
8521
+ # Emit skip flag so the worker can ignore sanitization errors if user requested
8522
+ self.start_calculation.emit(mol_block, False)
8523
+
8524
+ # 状態をUndo履歴に保存
8525
+ self.push_undo_state()
8526
+ self.update_chiral_labels()
8364
8527
 
8365
8528
  self.view_2d.setFocus()
8366
8529
 
@@ -8427,33 +8590,91 @@ class MainWindow(QMainWindow):
8427
8590
  self.statusBar().showMessage("No 3D molecule to optimize.")
8428
8591
  return
8429
8592
 
8593
+ # If a prior chemical/sanitization check was attempted and failed, do not run optimization
8594
+ if getattr(self, 'chem_check_tried', False) and getattr(self, 'chem_check_failed', False):
8595
+ self.statusBar().showMessage("3D optimization disabled: molecule failed chemical sanitization.")
8596
+ # Ensure the Optimize 3D button is disabled to reflect this
8597
+ if hasattr(self, 'optimize_3d_button'):
8598
+ try:
8599
+ self.optimize_3d_button.setEnabled(False)
8600
+ except Exception:
8601
+ pass
8602
+ return
8603
+
8430
8604
  self.statusBar().showMessage("Optimizing 3D structure...")
8431
8605
  QApplication.processEvents() # UIの更新を確実に行う
8432
8606
 
8433
8607
  try:
8434
- # MMFF力場での最適化を試みる
8435
- AllChem.MMFFOptimizeMolecule(self.current_mol)
8436
- except Exception:
8437
- # MMFFが失敗した場合、UFF力場でフォールバック
8438
- try:
8439
- AllChem.UFFOptimizeMolecule(self.current_mol)
8440
- except Exception as e:
8441
- self.statusBar().showMessage(f"3D optimization failed: {e}")
8608
+ method = getattr(self, 'optimization_method', 'MMFF_RDKIT')
8609
+ method = method.upper() if method else 'MMFF_RDKIT'
8610
+ # 事前チェック:コンフォーマがあるか
8611
+ if self.current_mol.GetNumConformers() == 0:
8612
+ self.statusBar().showMessage("No conformer found: cannot optimize. Embed molecule first.")
8442
8613
  return
8614
+ if method == 'MMFF_RDKIT':
8615
+ try:
8616
+ res = AllChem.MMFFOptimizeMolecule(self.current_mol, maxIters=2000)
8617
+ if res != 0:
8618
+ # 非収束や何らかの問題が起きた可能性 -> ForceField API で詳細に試す
8619
+ try:
8620
+ mmff_props = AllChem.MMFFGetMoleculeProperties(self.current_mol)
8621
+ ff = AllChem.MMFFGetMoleculeForceField(self.current_mol, mmff_props, confId=0)
8622
+ ff_ret = ff.Minimize(maxIts=2000)
8623
+ if ff_ret != 0:
8624
+ self.statusBar().showMessage(f"MMFF minimize returned non-zero status: {ff_ret}")
8625
+ return
8626
+ except Exception as e:
8627
+ self.statusBar().showMessage(f"MMFF parameterization/minimize failed: {e}")
8628
+ return
8629
+ except Exception as e:
8630
+ self.statusBar().showMessage(f"MMFF (RDKit) optimization error: {e}")
8631
+ return
8632
+ elif method == 'UFF_RDKIT':
8633
+ try:
8634
+ res = AllChem.UFFOptimizeMolecule(self.current_mol, maxIters=2000)
8635
+ if res != 0:
8636
+ try:
8637
+ ff = AllChem.UFFGetMoleculeForceField(self.current_mol, confId=0)
8638
+ ff_ret = ff.Minimize(maxIts=2000)
8639
+ if ff_ret != 0:
8640
+ self.statusBar().showMessage(f"UFF minimize returned non-zero status: {ff_ret}")
8641
+ return
8642
+ except Exception as e:
8643
+ self.statusBar().showMessage(f"UFF parameterization/minimize failed: {e}")
8644
+ return
8645
+ except Exception as e:
8646
+ self.statusBar().showMessage(f"UFF (RDKit) optimization error: {e}")
8647
+ return
8648
+ else:
8649
+ self.statusBar().showMessage("Selected optimization method is not available. Use MMFF (RDKit) or UFF (RDKit).")
8650
+ return
8651
+ except Exception as e:
8652
+ self.statusBar().showMessage(f"3D optimization error: {e}")
8443
8653
 
8444
8654
  # 最適化後の構造で3Dビューを再描画
8445
8655
  try:
8446
8656
  # 3D最適化後は3D座標から立体化学を再計算(2回目以降は3D優先)
8447
8657
  if self.current_mol.GetNumConformers() > 0:
8448
8658
  Chem.AssignAtomChiralTagsFromStructure(self.current_mol, confId=0)
8449
-
8450
8659
  self.update_chiral_labels() # キラル中心のラベルも更新
8451
8660
  except Exception:
8452
8661
  pass
8453
8662
 
8454
8663
  self.draw_molecule_3d(self.current_mol)
8455
8664
 
8456
- self.statusBar().showMessage("3D structure optimization successful.")
8665
+ # Show which method was used in the status bar (prefer human-readable label)
8666
+ try:
8667
+ used_method = getattr(self, 'optimization_method', None)
8668
+ used_label = None
8669
+ if used_method:
8670
+ used_label = (getattr(self, 'opt3d_method_labels', {}) or {}).get(used_method.upper(), used_method)
8671
+ except Exception:
8672
+ used_label = None
8673
+
8674
+ if used_label:
8675
+ self.statusBar().showMessage(f"3D structure optimization successful. Method: {used_label}")
8676
+ else:
8677
+ self.statusBar().showMessage("3D structure optimization successful.")
8457
8678
  self.push_undo_state() # Undo履歴に保存
8458
8679
  self.view_2d.setFocus()
8459
8680
 
@@ -9253,13 +9474,13 @@ class MainWindow(QMainWindow):
9253
9474
  self.update_window_title()
9254
9475
 
9255
9476
  except FileNotFoundError:
9256
- self.statusBar().showMessage(f"File not found: {file_path}", 5000)
9477
+ self.statusBar().showMessage(f"File not found: {file_path}")
9257
9478
  self.restore_ui_for_editing()
9258
9479
  except ValueError as e:
9259
- self.statusBar().showMessage(f"Invalid 3D MOL file: {e}", 5000)
9480
+ self.statusBar().showMessage(f"Invalid 3D MOL file: {e}")
9260
9481
  self.restore_ui_for_editing()
9261
9482
  except Exception as e:
9262
- self.statusBar().showMessage(f"Error loading 3D file: {e}", 5000)
9483
+ self.statusBar().showMessage(f"Error loading 3D file: {e}")
9263
9484
  self.restore_ui_for_editing()
9264
9485
  import traceback
9265
9486
  traceback.print_exc()
@@ -9310,13 +9531,13 @@ class MainWindow(QMainWindow):
9310
9531
  self.update_window_title()
9311
9532
 
9312
9533
  except FileNotFoundError:
9313
- self.statusBar().showMessage(f"File not found: {file_path}", 5000)
9534
+ self.statusBar().showMessage(f"File not found: {file_path}")
9314
9535
  self.restore_ui_for_editing()
9315
9536
  except ValueError as e:
9316
- self.statusBar().showMessage(f"Invalid XYZ file: {e}", 5000)
9537
+ self.statusBar().showMessage(f"Invalid XYZ file: {e}")
9317
9538
  self.restore_ui_for_editing()
9318
9539
  except Exception as e:
9319
- self.statusBar().showMessage(f"Error loading XYZ file: {e}", 5000)
9540
+ self.statusBar().showMessage(f"Error loading XYZ file: {e}")
9320
9541
  self.restore_ui_for_editing()
9321
9542
  import traceback
9322
9543
  traceback.print_exc()
@@ -9374,13 +9595,17 @@ class MainWindow(QMainWindow):
9374
9595
  try:
9375
9596
  # RDKitで認識される元素かどうかをチェック
9376
9597
  test_atom = Chem.Atom(symbol)
9377
- except:
9598
+ except Exception:
9378
9599
  # 認識されない場合、最初の文字を大文字にして再試行
9379
9600
  symbol = symbol.capitalize()
9380
9601
  try:
9381
9602
  test_atom = Chem.Atom(symbol)
9382
- except:
9383
- raise ValueError(f"Unrecognized element symbol: {parts[0]} at line {i+3}")
9603
+ except Exception:
9604
+ # If user requested to skip chemistry checks, coerce unknown symbols to C
9605
+ if self.settings.get('skip_chemistry_checks', False):
9606
+ symbol = 'C'
9607
+ else:
9608
+ raise ValueError(f"Unrecognized element symbol: {parts[0]} at line {i+3}")
9384
9609
 
9385
9610
  try:
9386
9611
  x, y, z = float(parts[1]), float(parts[2]), float(parts[3])
@@ -9411,13 +9636,14 @@ class MainWindow(QMainWindow):
9411
9636
  # 結合を推定(距離ベース)
9412
9637
  self.estimate_bonds_from_distances(mol)
9413
9638
 
9414
- # 分子を最終化
9639
+ # 分子を最終化
9415
9640
  try:
9416
9641
  mol = mol.GetMol()
9417
9642
  # 基本的な妥当性チェック
9418
9643
  if mol is None:
9419
9644
  raise ValueError("Failed to create valid molecule object")
9420
- Chem.SanitizeMol(mol)
9645
+ # Centralized chemical/sanitization handling
9646
+ self._apply_chem_check_and_set_flags(mol, source_desc='XYZ')
9421
9647
  except Exception as e:
9422
9648
  # 化学的に不正な構造でも表示は可能にする
9423
9649
  mol = mol.GetMol()
@@ -9506,9 +9732,10 @@ class MainWindow(QMainWindow):
9506
9732
  if not self.data.atoms and not self.current_mol:
9507
9733
  self.statusBar().showMessage("Error: Nothing to save.")
9508
9734
  return
9509
-
9510
- if self.current_file_path:
9511
- # 既存のファイルに上書き保存
9735
+ # 非ネイティブ形式(.mol, .sdf, .xyz など)は上書き保存せず、必ず「名前を付けて保存」にする
9736
+ native_exts = ['.pmeprj', '.pmeraw']
9737
+ if self.current_file_path and any(self.current_file_path.lower().endswith(ext) for ext in native_exts):
9738
+ # 既存のPMEPRJ/PMERAWファイルの場合は上書き保存
9512
9739
  try:
9513
9740
  if self.current_file_path.lower().endswith('.pmeraw'):
9514
9741
  # 既存のPMERAWファイルの場合はPMERAW形式で保存
@@ -9536,7 +9763,7 @@ class MainWindow(QMainWindow):
9536
9763
  import traceback
9537
9764
  traceback.print_exc()
9538
9765
  else:
9539
- # ファイルパスがない場合は名前を付けて保存
9766
+ # MOL/SDF/XYZなどは上書き保存せず、必ず「名前を付けて保存」にする
9540
9767
  self.save_project_as()
9541
9768
 
9542
9769
  def save_project_as(self):
@@ -10564,7 +10791,7 @@ class MainWindow(QMainWindow):
10564
10791
 
10565
10792
  def export_2d_png(self):
10566
10793
  if not self.data.atoms:
10567
- self.statusBar().showMessage("Nothing to export.", 2000)
10794
+ self.statusBar().showMessage("Nothing to export.")
10568
10795
  return
10569
10796
 
10570
10797
  options = QFileDialog.Option.DontUseNativeDialog
@@ -10605,7 +10832,7 @@ class MainWindow(QMainWindow):
10605
10832
  molecule_bounds = molecule_bounds.united(item.sceneBoundingRect())
10606
10833
 
10607
10834
  if molecule_bounds.isEmpty() or not molecule_bounds.isValid():
10608
- self.statusBar().showMessage("Error: Could not determine molecule bounds for export.", 5000)
10835
+ self.statusBar().showMessage("Error: Could not determine molecule bounds for export.")
10609
10836
  return
10610
10837
 
10611
10838
  if is_transparent:
@@ -10619,7 +10846,7 @@ class MainWindow(QMainWindow):
10619
10846
  h = max(1, int(math.ceil(rect_to_render.height())))
10620
10847
 
10621
10848
  if w <= 0 or h <= 0:
10622
- self.statusBar().showMessage("Error: Invalid image size calculated.", 5000)
10849
+ self.statusBar().showMessage("Error: Invalid image size calculated.")
10623
10850
  return
10624
10851
 
10625
10852
  image = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
@@ -10631,7 +10858,7 @@ class MainWindow(QMainWindow):
10631
10858
  painter = QPainter()
10632
10859
  ok = painter.begin(image)
10633
10860
  if not ok or not painter.isActive():
10634
- self.statusBar().showMessage("Failed to start QPainter for image rendering.", 5000)
10861
+ self.statusBar().showMessage("Failed to start QPainter for image rendering.")
10635
10862
  return
10636
10863
 
10637
10864
  try:
@@ -10644,12 +10871,12 @@ class MainWindow(QMainWindow):
10644
10871
 
10645
10872
  saved = image.save(filePath, "PNG")
10646
10873
  if saved:
10647
- self.statusBar().showMessage(f"2D view exported to {filePath}", 3000)
10874
+ self.statusBar().showMessage(f"2D view exported to {filePath}")
10648
10875
  else:
10649
- self.statusBar().showMessage(f"Failed to save image. Check file path or permissions.", 5000)
10876
+ self.statusBar().showMessage(f"Failed to save image. Check file path or permissions.")
10650
10877
 
10651
10878
  except Exception as e:
10652
- self.statusBar().showMessage(f"An unexpected error occurred during 2D export: {e}", 5000)
10879
+ self.statusBar().showMessage(f"An unexpected error occurred during 2D export: {e}")
10653
10880
 
10654
10881
  finally:
10655
10882
  for item, was_visible in items_to_restore.items():
@@ -10686,7 +10913,7 @@ class MainWindow(QMainWindow):
10686
10913
  self.plotter.screenshot(filePath, transparent_background=is_transparent)
10687
10914
  self.statusBar().showMessage(f"3D view exported to {filePath}", 3000)
10688
10915
  except Exception as e:
10689
- self.statusBar().showMessage(f"Error exporting 3D PNG: {e}", 3000)
10916
+ self.statusBar().showMessage(f"Error exporting 3D PNG: {e}")
10690
10917
 
10691
10918
 
10692
10919
  def open_periodic_table_dialog(self):
@@ -11071,6 +11298,8 @@ class MainWindow(QMainWindow):
11071
11298
  begin_color_rgb = [int(c * 255) for c in begin_color]
11072
11299
  end_color_rgb = [int(c * 255) for c in end_color]
11073
11300
 
11301
+ # UI応答性維持のためイベント処理
11302
+ QApplication.processEvents()
11074
11303
  if bt == Chem.rdchem.BondType.SINGLE or bt == Chem.rdchem.BondType.AROMATIC:
11075
11304
  if self.current_3d_style == 'ball_and_stick':
11076
11305
  # Ball and stickは全結合をまとめて処理(高速化)
@@ -11726,6 +11955,46 @@ class MainWindow(QMainWindow):
11726
11955
  # 調査の結果、'style' プロパティへの代入が正しい設定方法と判明
11727
11956
  self.plotter.interactor.SetInteractorStyle(style)
11728
11957
  self.plotter.interactor.Initialize()
11958
+
11959
+ def _apply_chem_check_and_set_flags(self, mol, source_desc=None):
11960
+ """Central helper to apply chemical sanitization (or skip it) and set
11961
+ chem_check_tried / chem_check_failed flags consistently.
11962
+
11963
+ When sanitization fails, a warning is shown and the Optimize 3D button
11964
+ is disabled. If the user setting 'skip_chemistry_checks' is True, no
11965
+ sanitization is attempted and both flags remain False.
11966
+ """
11967
+ try:
11968
+ self.chem_check_tried = False
11969
+ self.chem_check_failed = False
11970
+ except Exception:
11971
+ # Ensure attributes exist even if called very early
11972
+ self.chem_check_tried = False
11973
+ self.chem_check_failed = False
11974
+
11975
+ if self.settings.get('skip_chemistry_checks', False):
11976
+ # User asked to skip chemistry checks entirely
11977
+ return
11978
+
11979
+ try:
11980
+ Chem.SanitizeMol(mol)
11981
+ self.chem_check_tried = True
11982
+ self.chem_check_failed = False
11983
+ except Exception:
11984
+ # Mark that we tried sanitization and it failed
11985
+ self.chem_check_tried = True
11986
+ self.chem_check_failed = True
11987
+ try:
11988
+ desc = f" ({source_desc})" if source_desc else ''
11989
+ self.statusBar().showMessage(f"Molecule sanitization failed{desc}; file may be malformed.")
11990
+ except Exception:
11991
+ pass
11992
+ # Disable 3D optimization UI to prevent running on invalid molecules
11993
+ if hasattr(self, 'optimize_3d_button'):
11994
+ try:
11995
+ self.optimize_3d_button.setEnabled(False)
11996
+ except Exception:
11997
+ pass
11729
11998
 
11730
11999
  def load_mol_file_for_3d_viewing(self, file_path=None):
11731
12000
  """MOL/SDFファイルを3Dビューアーで開く"""
@@ -11755,13 +12024,28 @@ class MainWindow(QMainWindow):
11755
12024
  if mol.GetNumConformers() == 0:
11756
12025
  self.statusBar().showMessage("No 3D coordinates found. Converting to 3D...")
11757
12026
  try:
11758
- AllChem.EmbedMolecule(mol)
11759
- # 最適化は実行しない
12027
+ try:
12028
+ AllChem.EmbedMolecule(mol)
12029
+ # 最適化は実行しない
12030
+ # 3D変換直後にUndoスタックに積む
12031
+ self.current_mol = mol
12032
+ self.push_undo_state()
12033
+ except Exception as e_embed:
12034
+ # If skipping chemistry checks, allow molecule to be displayed without 3D embedding
12035
+ if self.settings.get('skip_chemistry_checks', False):
12036
+ self.statusBar().showMessage("Warning: failed to generate 3D coordinates but skip_chemistry_checks is enabled; continuing.")
12037
+ # Keep mol as-is (may lack conformer); downstream code checks for conformers
12038
+ else:
12039
+ raise
11760
12040
  except:
11761
12041
  self.statusBar().showMessage("Failed to generate 3D coordinates")
11762
12042
  return
11763
12043
 
11764
12044
  # 3Dビューアーに表示
12045
+ # Centralized chemical/sanitization handling
12046
+ # Ensure the skip_chemistry_checks setting is respected and flags are set
12047
+ self._apply_chem_check_and_set_flags(mol, source_desc='MOL/SDF')
12048
+
11765
12049
  self.current_mol = mol
11766
12050
  self.draw_molecule_3d(mol)
11767
12051
 
@@ -11888,7 +12172,14 @@ class MainWindow(QMainWindow):
11888
12172
 
11889
12173
  for action_name in basic_3d_actions:
11890
12174
  if hasattr(self, action_name):
11891
- getattr(self, action_name).setEnabled(enabled)
12175
+ # If enabling globally but chemical sanitization failed earlier, keep Optimize 3D disabled
12176
+ if enabled and action_name == 'optimize_3d_button' and getattr(self, 'chem_check_tried', False) and getattr(self, 'chem_check_failed', False):
12177
+ try:
12178
+ getattr(self, action_name).setEnabled(False)
12179
+ except Exception:
12180
+ pass
12181
+ else:
12182
+ getattr(self, action_name).setEnabled(enabled)
11892
12183
 
11893
12184
  # 3D Selectボタンは常に有効にする
11894
12185
  if hasattr(self, 'measurement_action'):
@@ -13884,14 +14175,14 @@ class DihedralDialog(Dialog3DPickingMixin, QDialog):
13884
14175
  QMessageBox.warning(self, "Invalid Input", "Please enter a valid number.")
13885
14176
  return
13886
14177
 
13887
- # Undo状態を保存
13888
- self.main_window.push_undo_state()
13889
-
13890
14178
  # Apply the dihedral angle change
13891
14179
  self.adjust_dihedral(new_dihedral)
13892
14180
 
13893
14181
  # キラルラベルを更新
13894
14182
  self.main_window.update_chiral_labels()
14183
+
14184
+ # Undo状態を保存
14185
+ self.main_window.push_undo_state()
13895
14186
 
13896
14187
  def adjust_dihedral(self, new_dihedral_deg):
13897
14188
  """二面角を調整(改善されたアルゴリズム)"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleditpy
3
- Version: 1.9.0
3
+ Version: 1.9.2
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/HiroYokoyama/python_molecular_editor
File without changes
File without changes