moleditpy 1.3.1__tar.gz → 1.4.0__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.3.1
3
+ Version: 1.4.0
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.3.1"
8
+ version = "1.4.0"
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.3.1'
14
+ VERSION = '1.4.0'
15
15
 
16
16
  print("-----------------------------------------------------")
17
17
  print("MoleditPy — A Python-based molecular editing software")
@@ -26,6 +26,7 @@ import io
26
26
  import os
27
27
  import ctypes
28
28
  import itertools
29
+ import json
29
30
  import vtk
30
31
 
31
32
  from collections import deque
@@ -35,12 +36,16 @@ from PyQt6.QtWidgets import (
35
36
  QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
36
37
  QPushButton, QSplitter, QGraphicsView, QGraphicsScene, QGraphicsItem,
37
38
  QToolBar, QStatusBar, QGraphicsTextItem, QGraphicsLineItem, QDialog, QGridLayout,
38
- QFileDialog, QSizePolicy, QLabel, QLineEdit, QToolButton, QMenu, QMessageBox, QInputDialog
39
+ QFileDialog, QSizePolicy, QLabel, QLineEdit, QToolButton, QMenu, QMessageBox, QInputDialog,
40
+ QColorDialog, QCheckBox, QSlider, QFormLayout
39
41
  )
42
+
40
43
  from PyQt6.QtGui import (
41
44
  QPen, QBrush, QColor, QPainter, QAction, QActionGroup, QFont, QPolygonF,
42
- QPainterPath, QPainterPathStroker, QFontMetrics, QFontMetricsF, QKeySequence, QTransform, QCursor, QPixmap, QIcon, QShortcut, QDesktopServices
45
+ QPainterPath, QPainterPathStroker, QFontMetrics, QFontMetricsF, QKeySequence, QTransform, QCursor, QPixmap, QIcon, QShortcut, QDesktopServices, QImage
43
46
  )
47
+
48
+
44
49
  from PyQt6.QtCore import Qt, QPointF, QRectF, QLineF, QObject, QThread, pyqtSignal, QEvent, QMimeData, QByteArray, QUrl, QTimer
45
50
 
46
51
  from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera
@@ -363,8 +368,6 @@ class AtomItem(QGraphicsItem):
363
368
  path.addEllipse(QPointF(0, 0), scene_radius, scene_radius)
364
369
  return path
365
370
 
366
- # In class AtomItem:
367
-
368
371
  def paint(self, painter, option, widget):
369
372
  color = CPK_COLORS.get(self.symbol, CPK_COLORS['DEFAULT'])
370
373
  if self.is_visible:
@@ -730,6 +733,9 @@ class MoleculeScene(QGraphicsScene):
730
733
  self.data_changed_in_event = False
731
734
  self.initial_positions_in_event = {item: item.pos() for item in self.items() if isinstance(item, AtomItem)}
732
735
 
736
+ if not self.window.is_2d_editable:
737
+ return
738
+
733
739
  if event.button() == Qt.MouseButton.RightButton:
734
740
  item = self.itemAt(event.scenePos(), self.views()[0].transform())
735
741
  if not isinstance(item, (AtomItem, BondItem)):
@@ -794,6 +800,9 @@ class MoleculeScene(QGraphicsScene):
794
800
  else: super().mousePressEvent(event)
795
801
 
796
802
  def mouseMoveEvent(self, event):
803
+ if not self.window.is_2d_editable:
804
+ return
805
+
797
806
  if self.mode.startswith('template'):
798
807
  self.update_template_preview(event.scenePos())
799
808
 
@@ -830,6 +839,10 @@ class MoleculeScene(QGraphicsScene):
830
839
  super().mouseMoveEvent(event)
831
840
 
832
841
  def mouseReleaseEvent(self, event):
842
+
843
+ if not self.window.is_2d_editable:
844
+ return
845
+
833
846
  end_pos = event.scenePos()
834
847
  is_click = self.press_pos and (end_pos - self.press_pos).manhattanLength() < QApplication.startDragDistance()
835
848
 
@@ -1409,6 +1422,9 @@ class MoleculeScene(QGraphicsScene):
1409
1422
  key = event.key()
1410
1423
  modifiers = event.modifiers()
1411
1424
 
1425
+ if not self.window.is_2d_editable:
1426
+ return
1427
+
1412
1428
  if key == Qt.Key.Key_4:
1413
1429
  # --- 動作1: カーソルが原子/結合上にある場合 (ワンショットでテンプレート配置) ---
1414
1430
  if isinstance(item_at_cursor, (AtomItem, BondItem)):
@@ -1522,32 +1538,78 @@ class MoleculeScene(QGraphicsScene):
1522
1538
  target_bonds = [it for it in self.selectedItems() if isinstance(it, BondItem)]
1523
1539
 
1524
1540
  if target_bonds:
1525
- needs_update = False
1541
+ any_bond_changed = False
1526
1542
  for bond in target_bonds:
1543
+ # 1. 結合の向きを考慮して、データ辞書内の現在のキーを正しく特定する
1527
1544
  id1, id2 = bond.atom1.atom_id, bond.atom2.atom_id
1528
- if id1 > id2: id1, id2 = id2, id1
1545
+ current_key = None
1546
+ if (id1, id2) in self.data.bonds:
1547
+ current_key = (id1, id2)
1548
+ elif (id2, id1) in self.data.bonds:
1549
+ current_key = (id2, id1)
1550
+
1551
+ if not current_key: continue
1552
+
1553
+ # 2. 変更前の状態を保存
1554
+ old_order, old_stereo = bond.order, bond.stereo
1555
+
1556
+ # 3. キー入力に応じてBondItemのプロパティを変更
1557
+ if key == Qt.Key.Key_W:
1558
+ if bond.stereo == 1:
1559
+ bond_data = self.data.bonds.pop(current_key)
1560
+ new_key = (current_key[1], current_key[0])
1561
+ self.data.bonds[new_key] = bond_data
1562
+ bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1563
+ bond.update_position()
1564
+ was_reversed = True
1565
+ else:
1566
+ bond.order = 1; bond.stereo = 1
1567
+
1568
+ elif key == Qt.Key.Key_D:
1569
+ if bond.stereo == 2:
1570
+ bond_data = self.data.bonds.pop(current_key)
1571
+ new_key = (current_key[1], current_key[0])
1572
+ self.data.bonds[new_key] = bond_data
1573
+ bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1574
+ bond.update_position()
1575
+ was_reversed = True
1576
+ else:
1577
+ bond.order = 1; bond.stereo = 2
1529
1578
 
1530
- if key == Qt.Key.Key_1 and (bond.order != 1 or bond.stereo != 0):
1531
- bond.order = 1; bond.stereo = 0; needs_update = True
1579
+ elif key == Qt.Key.Key_1 and (bond.order != 1 or bond.stereo != 0):
1580
+ bond.order = 1; bond.stereo = 0
1532
1581
  elif key == Qt.Key.Key_2 and bond.order != 2:
1533
1582
  bond.order = 2; bond.stereo = 0; needs_update = True
1534
1583
  elif key == Qt.Key.Key_3 and bond.order != 3:
1535
1584
  bond.order = 3; bond.stereo = 0; needs_update = True
1536
- elif key == Qt.Key.Key_W and bond.stereo != 1: # Wedge
1537
- bond.order = 1; bond.stereo = 1; needs_update = True
1538
- elif key == Qt.Key.Key_D and bond.stereo != 2: # Dash
1539
- bond.order = 1; bond.stereo = 2; needs_update = True
1540
-
1541
- if needs_update:
1542
- self.data.bonds[(id1, id2)]['order'] = bond.order
1543
- self.data.bonds[(id1, id2)]['stereo'] = bond.stereo
1585
+
1586
+ # 4. 実際に変更があった場合のみデータモデルを更新
1587
+ if old_order != bond.order or old_stereo != bond.stereo:
1588
+ any_bond_changed = True
1589
+
1590
+ # 5. 古いキーでデータを辞書から一度削除
1591
+ bond_data = self.data.bonds.pop(current_key)
1592
+ bond_data['order'] = bond.order
1593
+ bond_data['stereo'] = bond.stereo
1594
+
1595
+ # 6. 変更後の種類に応じて新しいキーを決定し、再登録する
1596
+ new_key_id1, new_key_id2 = bond.atom1.atom_id, bond.atom2.atom_id
1597
+ if bond.stereo == 0:
1598
+ if new_key_id1 > new_key_id2:
1599
+ new_key_id1, new_key_id2 = new_key_id2, new_key_id1
1600
+
1601
+ new_key = (new_key_id1, new_key_id2)
1602
+ self.data.bonds[new_key] = bond_data
1603
+
1544
1604
  bond.update()
1545
1605
 
1546
- if needs_update:
1606
+ if any_bond_changed:
1547
1607
  self.window.push_undo_state()
1608
+
1609
+ if key in [Qt.Key.Key_1, Qt.Key.Key_2, Qt.Key.Key_3, Qt.Key.Key_W, Qt.Key.Key_D]:
1548
1610
  event.accept()
1549
1611
  return
1550
-
1612
+
1551
1613
  # --- 3. Atomに対する操作 (原子の追加 - マージされた機能) ---
1552
1614
  if key == Qt.Key.Key_1:
1553
1615
  start_atom = None
@@ -1731,8 +1793,6 @@ class MoleculeScene(QGraphicsScene):
1731
1793
  # --- どの操作にも当てはまらない場合 ---
1732
1794
  super().keyPressEvent(event)
1733
1795
 
1734
-
1735
- # In MoleculeScene class
1736
1796
  def find_atom_near(self, pos, tol=14.0):
1737
1797
  # Create a small search rectangle around the position
1738
1798
  search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
@@ -1763,7 +1823,7 @@ class ZoomableView(QGraphicsView):
1763
1823
  self.setDragMode(QGraphicsView.DragMode.NoDrag)
1764
1824
 
1765
1825
  self.main_window = parent
1766
- self.setAcceptDrops(True)
1826
+ self.setAcceptDrops(False)
1767
1827
 
1768
1828
  self._is_panning = False
1769
1829
  self._pan_start_pos = QPointF()
@@ -1841,44 +1901,7 @@ class ZoomableView(QGraphicsView):
1841
1901
  else:
1842
1902
  super().mouseReleaseEvent(event)
1843
1903
 
1844
- # ZoomableView クラス内 (約1691行目あたりから)
1845
-
1846
- def dragEnterEvent(self, event):
1847
- """ファイルがこのウィジェット上にドラッグされたときに呼び出される"""
1848
- # ドラッグされたデータにファイルパスが含まれている場合のみ受け入れる
1849
- if event.mimeData().hasUrls():
1850
- event.acceptProposedAction()
1851
- else:
1852
- event.ignore()
1853
-
1854
- def dragMoveEvent(self, event):
1855
- """
1856
- ウィジェット上でドラッグが移動したときに呼び出される。
1857
- """
1858
- if event.mimeData().hasUrls():
1859
- event.acceptProposedAction()
1860
- else:
1861
- super().dragMoveEvent(event)
1862
1904
 
1863
- def dropEvent(self, event):
1864
- """ファイルがこのウィジェット上でドロップされたときに呼び出される"""
1865
- urls = event.mimeData().urls()
1866
- if urls and urls[0].isLocalFile():
1867
- file_path = urls[0].toLocalFile()
1868
-
1869
- # MainWindowのファイル読み込みメソッドを呼び出す
1870
- if self.main_window:
1871
- # 拡張子によって適切な読み込み処理を呼び出す
1872
- if file_path.lower().endswith('.pmeraw'):
1873
- self.main_window.load_raw_data(file_path=file_path)
1874
- elif file_path.lower().endswith(('.mol', '.sdf')):
1875
- self.main_window.load_mol_file(file_path=file_path)
1876
-
1877
- event.acceptProposedAction()
1878
- else:
1879
- event.ignore()
1880
- else:
1881
- event.ignore()
1882
1905
 
1883
1906
  class CalculationWorker(QObject):
1884
1907
  status_update = pyqtSignal(str)
@@ -2123,6 +2146,143 @@ class AnalysisWindow(QDialog):
2123
2146
  if self.parent() and hasattr(self.parent(), 'statusBar'):
2124
2147
  self.parent().statusBar().showMessage(f"Copied '{text}' to clipboard.", 2000)
2125
2148
 
2149
+
2150
+ class SettingsDialog(QDialog):
2151
+ def __init__(self, current_settings, parent=None):
2152
+ super().__init__(parent)
2153
+ self.setWindowTitle("3D View Settings")
2154
+
2155
+ # デフォルト設定をクラス内で定義
2156
+ self.default_settings = {
2157
+ 'background_color': '#919191',
2158
+ 'lighting_enabled': True,
2159
+ 'specular': 0.20,
2160
+ 'specular_power': 20,
2161
+ 'light_intensity': 1.0,
2162
+ 'show_3d_axes': True,
2163
+ }
2164
+
2165
+ # --- 選択された色を管理する専用のインスタンス変数 ---
2166
+ self.current_bg_color = None
2167
+
2168
+ # --- UI要素の作成 ---
2169
+ layout = QVBoxLayout(self)
2170
+ form_layout = QFormLayout()
2171
+
2172
+ # 1. 背景色
2173
+ self.bg_button = QPushButton()
2174
+ self.bg_button.setToolTip("Click to select a color")
2175
+ self.bg_button.clicked.connect(self.select_color)
2176
+ form_layout.addRow("Background Color:", self.bg_button)
2177
+
2178
+ # 1a. 軸の表示/非表示
2179
+ self.axes_checkbox = QCheckBox()
2180
+ form_layout.addRow("Show 3D Axes:", self.axes_checkbox)
2181
+
2182
+ # 2. ライトの有効/無効
2183
+ self.light_checkbox = QCheckBox()
2184
+ form_layout.addRow("Enable Lighting:", self.light_checkbox)
2185
+
2186
+ # 光の強さスライダーを追加
2187
+ self.intensity_slider = QSlider(Qt.Orientation.Horizontal)
2188
+ self.intensity_slider.setRange(0, 200) # 0.0 ~ 2.0 の範囲
2189
+ form_layout.addRow("Light Intensity:", self.intensity_slider)
2190
+
2191
+ # 3. 光沢 (Specular)
2192
+ self.specular_slider = QSlider(Qt.Orientation.Horizontal)
2193
+ self.specular_slider.setRange(0, 100)
2194
+ form_layout.addRow("Shininess (Specular):", self.specular_slider)
2195
+
2196
+ # 4. 光沢の強さ (Specular Power)
2197
+ self.spec_power_slider = QSlider(Qt.Orientation.Horizontal)
2198
+ self.spec_power_slider.setRange(0, 100)
2199
+ form_layout.addRow("Shininess Power:", self.spec_power_slider)
2200
+
2201
+ # 渡された設定でUIと内部変数を初期化
2202
+ self.update_ui_from_settings(current_settings)
2203
+
2204
+ layout.addLayout(form_layout)
2205
+
2206
+ # --- ボタンの配置 ---
2207
+ buttons = QHBoxLayout()
2208
+ reset_button = QPushButton("Reset to Defaults")
2209
+ reset_button.clicked.connect(self.reset_to_defaults)
2210
+
2211
+ ok_button = QPushButton("OK")
2212
+ cancel_button = QPushButton("Cancel")
2213
+ ok_button.clicked.connect(self.accept)
2214
+ cancel_button.clicked.connect(self.reject)
2215
+
2216
+ buttons.addWidget(reset_button)
2217
+ buttons.addStretch(1)
2218
+ buttons.addWidget(ok_button)
2219
+ buttons.addWidget(cancel_button)
2220
+ layout.addLayout(buttons)
2221
+
2222
+ def reset_to_defaults(self):
2223
+ """UIをデフォルト設定に戻す"""
2224
+ self.update_ui_from_settings(self.default_settings)
2225
+
2226
+ def update_ui_from_settings(self, settings_dict):
2227
+ self.current_bg_color = settings_dict.get('background_color', self.default_settings['background_color'])
2228
+ self.update_color_button(self.current_bg_color)
2229
+ self.axes_checkbox.setChecked(settings_dict.get('show_3d_axes', self.default_settings['show_3d_axes']))
2230
+ self.light_checkbox.setChecked(settings_dict.get('lighting_enabled', self.default_settings['lighting_enabled']))
2231
+ self.intensity_slider.setValue(int(settings_dict.get('light_intensity', self.default_settings['light_intensity']) * 100))
2232
+ self.specular_slider.setValue(int(settings_dict.get('specular', self.default_settings['specular']) * 100))
2233
+ self.spec_power_slider.setValue(settings_dict.get('specular_power', self.default_settings['specular_power']))
2234
+
2235
+ def select_color(self):
2236
+ """カラーピッカーを開き、選択された色を内部変数とUIに反映させる"""
2237
+ # 内部変数から現在の色を取得してカラーピッカーを初期化
2238
+ color = QColorDialog.getColor(QColor(self.current_bg_color), self)
2239
+ if color.isValid():
2240
+ # 内部変数を更新
2241
+ self.current_bg_color = color.name()
2242
+ # UIの見た目を更新
2243
+ self.update_color_button(self.current_bg_color)
2244
+
2245
+ def update_color_button(self, color_hex):
2246
+ """ボタンの背景色と境界線を設定する"""
2247
+ self.bg_button.setStyleSheet(f"background-color: {color_hex}; border: 1px solid #888;")
2248
+
2249
+ def get_settings(self):
2250
+ return {
2251
+ 'background_color': self.current_bg_color,
2252
+ 'show_3d_axes': self.axes_checkbox.isChecked(),
2253
+ 'lighting_enabled': self.light_checkbox.isChecked(),
2254
+ 'light_intensity': self.intensity_slider.value() / 100.0,
2255
+ 'specular': self.specular_slider.value() / 100.0,
2256
+ 'specular_power': self.spec_power_slider.value()
2257
+ }
2258
+
2259
+
2260
+ class CustomQtInteractor(QtInteractor):
2261
+ def __init__(self, parent=None, main_window=None, **kwargs):
2262
+ super().__init__(parent, **kwargs)
2263
+ self.main_window = main_window
2264
+
2265
+ def wheelEvent(self, event):
2266
+ """
2267
+ マウスホイールイベントをオーバーライドする。
2268
+ """
2269
+ # 最初に親クラスのイベントを呼び、通常のズーム処理を実行させる
2270
+ super().wheelEvent(event)
2271
+
2272
+ # ズーム処理の完了後、2Dビューにフォーカスを強制的に戻す
2273
+ if self.main_window and hasattr(self.main_window, 'view_2d'):
2274
+ self.main_window.view_2d.setFocus()
2275
+
2276
+
2277
+ def mouseReleaseEvent(self, event):
2278
+ """
2279
+ Qtのマウスリリースイベントをオーバーライドし、
2280
+ 3Dビューでの全ての操作完了後に2Dビューへフォーカスを戻す。
2281
+ """
2282
+ super().mouseReleaseEvent(event) # 親クラスのイベントを先に処理
2283
+ if self.main_window and hasattr(self.main_window, 'view_2d'):
2284
+ self.main_window.view_2d.setFocus()
2285
+
2126
2286
  # --- 3Dインタラクションを管理する専用クラス ---
2127
2287
  class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2128
2288
  def __init__(self, main_window):
@@ -2146,7 +2306,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2146
2306
  is_temp_mode = bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
2147
2307
  is_edit_active = mw.is_3d_edit_mode or is_temp_mode
2148
2308
 
2149
- if is_edit_active:
2309
+ # 3D分子(mw.current_mol)が存在する場合のみ、原子の選択処理を実行
2310
+ if is_edit_active and mw.current_mol:
2150
2311
  click_pos = self.GetInteractor().GetEventPosition()
2151
2312
  picker = mw.plotter.picker
2152
2313
  picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
@@ -2156,17 +2317,20 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2156
2317
  distances = np.linalg.norm(mw.atom_positions_3d - picked_position, axis=1)
2157
2318
  closest_atom_idx = np.argmin(distances)
2158
2319
 
2159
- atomic_num = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx)).GetAtomicNum()
2160
- vdw_radius = pt.GetRvdw(atomic_num)
2161
- click_threshold = vdw_radius * 1.5
2162
-
2163
- if distances[closest_atom_idx] < click_threshold:
2164
- # 原子を掴むことに成功した場合
2165
- self._is_dragging_atom = True
2166
- self.is_dragging = False
2167
- mw.dragged_atom_info = {'id': int(closest_atom_idx)}
2168
- mw.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
2169
- return # 親クラスのカメラ回転を呼ばないように、ここで処理を終了します
2320
+ # RDKitのMolオブジェクトから原子を安全に取得
2321
+ atom = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx))
2322
+ if atom:
2323
+ atomic_num = atom.GetAtomicNum()
2324
+ vdw_radius = pt.GetRvdw(atomic_num)
2325
+ click_threshold = vdw_radius * 1.5
2326
+
2327
+ if distances[closest_atom_idx] < click_threshold:
2328
+ # 原子を掴むことに成功した場合
2329
+ self._is_dragging_atom = True
2330
+ self.is_dragging = False
2331
+ mw.dragged_atom_info = {'id': int(closest_atom_idx)}
2332
+ mw.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
2333
+ return # 親クラスのカメラ回転を呼ばない
2170
2334
 
2171
2335
  self._is_dragging_atom = False
2172
2336
  super().OnLeftButtonDown()
@@ -2240,12 +2404,14 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2240
2404
 
2241
2405
  # 状態をリセット
2242
2406
  self._is_dragging_atom = False
2407
+ self.is_dragging = False # is_draggingもリセット
2243
2408
 
2244
- # ボタンを離した後のカーソル表示を最新の状態に更新するため、
2245
- # on_mouse_moveを一度呼び出します
2409
+ # ボタンを離した後のカーソル表示を最新の状態に更新
2246
2410
  self.on_mouse_move(obj, event)
2247
2411
 
2248
- mw.view_2d.setFocus()
2412
+ # 2Dビューにフォーカスを戻し、ショートカットキーなどが使えるようにする
2413
+ if mw and mw.view_2d:
2414
+ mw.view_2d.setFocus()
2249
2415
 
2250
2416
  class MainWindow(QMainWindow):
2251
2417
 
@@ -2253,6 +2419,11 @@ class MainWindow(QMainWindow):
2253
2419
  def __init__(self, initial_file=None):
2254
2420
  super().__init__()
2255
2421
  self.setAcceptDrops(True)
2422
+ self.settings_dir = os.path.join(os.path.expanduser('~'), '.moleditpy')
2423
+ self.settings_file = os.path.join(self.settings_dir, 'settings.json')
2424
+ self.settings = {}
2425
+ self.load_settings()
2426
+ self.initial_settings = self.settings.copy()
2256
2427
  self.setWindowTitle("MoleditPy -- Python Molecular Editor Ver. " + VERSION); self.setGeometry(100, 100, 1400, 800)
2257
2428
  self.data = MolecularData(); self.current_mol = None
2258
2429
  self.current_3d_style = 'ball_and_stick'
@@ -2260,6 +2431,9 @@ class MainWindow(QMainWindow):
2260
2431
  self.is_3d_edit_mode = False
2261
2432
  self.dragged_atom_info = None
2262
2433
  self.atom_actor = None
2434
+ self.is_2d_editable = True
2435
+ self.axes_actor = None
2436
+ self.axes_widget = None
2263
2437
  self.undo_stack = []
2264
2438
  self.redo_stack = []
2265
2439
  self.mode_actions = {}
@@ -2287,6 +2461,8 @@ class MainWindow(QMainWindow):
2287
2461
 
2288
2462
  if initial_file:
2289
2463
  self.load_raw_data(file_path=initial_file)
2464
+
2465
+ QTimer.singleShot(0, self.apply_initial_settings)
2290
2466
 
2291
2467
  def init_ui(self):
2292
2468
  # 1. 現在のスクリプトがあるディレクトリのパスを取得
@@ -2306,8 +2482,8 @@ class MainWindow(QMainWindow):
2306
2482
 
2307
2483
  self.init_menu_bar()
2308
2484
 
2309
- splitter=QSplitter(Qt.Orientation.Horizontal)
2310
- self.setCentralWidget(splitter)
2485
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
2486
+ self.setCentralWidget(self.splitter)
2311
2487
 
2312
2488
  left_pane=QWidget()
2313
2489
  left_pane.setAcceptDrops(True)
@@ -2337,13 +2513,13 @@ class MainWindow(QMainWindow):
2337
2513
  left_buttons_layout.addWidget(self.convert_button)
2338
2514
 
2339
2515
  left_layout.addLayout(left_buttons_layout)
2340
- splitter.addWidget(left_pane)
2516
+ self.splitter.addWidget(left_pane)
2341
2517
 
2342
2518
  # --- 右パネルとボタンレイアウト ---
2343
2519
  right_pane = QWidget()
2344
2520
  # 1. 右パネル全体は「垂直」レイアウトにする
2345
2521
  right_layout = QVBoxLayout(right_pane)
2346
- self.plotter = QtInteractor(right_pane)
2522
+ self.plotter = CustomQtInteractor(right_pane, main_window=self, lighting='none')
2347
2523
  self.plotter.setAcceptDrops(False)
2348
2524
  self.plotter.setSizePolicy(
2349
2525
  QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
@@ -2383,9 +2559,9 @@ class MainWindow(QMainWindow):
2383
2559
 
2384
2560
  # 4. 水平のボタンレイアウトを、全体の垂直レイアウトに追加
2385
2561
  right_layout.addLayout(right_buttons_layout)
2386
- splitter.addWidget(right_pane)
2562
+ self.splitter.addWidget(right_pane)
2387
2563
 
2388
- splitter.setSizes([600, 600])
2564
+ self.splitter.setSizes([600, 600])
2389
2565
 
2390
2566
  # ステータスバーを左右に分離するための設定
2391
2567
  self.status_bar = self.statusBar()
@@ -2417,6 +2593,7 @@ class MainWindow(QMainWindow):
2417
2593
 
2418
2594
  if mode == 'atom_other':
2419
2595
  action.triggered.connect(self.open_periodic_table_dialog)
2596
+ self.other_atom_action = action
2420
2597
  else:
2421
2598
  action.triggered.connect(lambda c, m=mode: self.set_mode(m))
2422
2599
  self.mode_actions[mode] = action
@@ -2639,6 +2816,12 @@ class MainWindow(QMainWindow):
2639
2816
  import_inchi_action = QAction("Import InChI...", self)
2640
2817
  import_inchi_action.triggered.connect(self.import_inchi_dialog)
2641
2818
  file_menu.addAction(import_inchi_action)
2819
+
2820
+ file_menu.addSeparator()
2821
+ load_3d_mol_action = QAction("Load 3D MOL (3D Only)...", self)
2822
+ load_3d_mol_action.triggered.connect(self.load_mol_for_3d_viewing)
2823
+ file_menu.addAction(load_3d_mol_action)
2824
+
2642
2825
  file_menu.addSeparator()
2643
2826
  save_mol_action = QAction("Save 2D as MOL...", self); save_mol_action.triggered.connect(self.save_as_mol)
2644
2827
  file_menu.addAction(save_mol_action)
@@ -2656,6 +2839,16 @@ class MainWindow(QMainWindow):
2656
2839
  load_raw_action.setShortcut(QKeySequence.StandardKey.Open)
2657
2840
  file_menu.addAction(load_raw_action)
2658
2841
 
2842
+ file_menu.addSeparator()
2843
+
2844
+ export_2d_png_action = QAction("Export 2D as PNG...", self)
2845
+ export_2d_png_action.triggered.connect(self.export_2d_png)
2846
+ file_menu.addAction(export_2d_png_action)
2847
+
2848
+ export_3d_png_action = QAction("Export 3D as PNG...", self)
2849
+ export_3d_png_action.triggered.connect(self.export_3d_png)
2850
+ file_menu.addAction(export_3d_png_action)
2851
+
2659
2852
  file_menu.addSeparator()
2660
2853
  quit_action = QAction("Quit", self)
2661
2854
  quit_action.triggered.connect(self.close)
@@ -2735,6 +2928,13 @@ class MainWindow(QMainWindow):
2735
2928
 
2736
2929
  view_menu.addSeparator()
2737
2930
 
2931
+ reset_3d_view_action = QAction("Reset 3D View", self)
2932
+ reset_3d_view_action.triggered.connect(lambda: self.plotter.reset_camera() if hasattr(self, 'plotter') else None)
2933
+ reset_3d_view_action.setShortcut(QKeySequence("Ctrl+R"))
2934
+ view_menu.addAction(reset_3d_view_action)
2935
+
2936
+ view_menu.addSeparator()
2937
+
2738
2938
  self.toggle_chiral_action = QAction("Show Chiral Labels", self, checkable=True)
2739
2939
  self.toggle_chiral_action.setChecked(self.show_chiral_labels)
2740
2940
  self.toggle_chiral_action.triggered.connect(self.toggle_chiral_labels_display)
@@ -2746,6 +2946,11 @@ class MainWindow(QMainWindow):
2746
2946
  self.analysis_action.setEnabled(False)
2747
2947
  analysis_menu.addAction(self.analysis_action)
2748
2948
 
2949
+ settings_menu = menu_bar.addMenu("&Settings")
2950
+ view_settings_action = QAction("3D View Settings...", self)
2951
+ view_settings_action.triggered.connect(self.open_settings_dialog)
2952
+ settings_menu.addAction(view_settings_action)
2953
+
2749
2954
  help_menu = menu_bar.addMenu("&Help")
2750
2955
  about_action = QAction("About", self)
2751
2956
  about_action.triggered.connect(lambda: QMessageBox.about(
@@ -2774,7 +2979,6 @@ class MainWindow(QMainWindow):
2774
2979
 
2775
2980
  def set_mode(self, mode_str):
2776
2981
  self.scene.mode = mode_str
2777
-
2778
2982
  self.view_2d.setMouseTracking(True)
2779
2983
  if not mode_str.startswith('template'):
2780
2984
  self.scene.template_preview.hide()
@@ -3020,14 +3224,23 @@ class MainWindow(QMainWindow):
3020
3224
  self.edit_3d_action.setEnabled(False)
3021
3225
  self.statusBar().showMessage("Calculating 3D structure...")
3022
3226
  self.plotter.clear()
3227
+ bg_color_hex = self.settings.get('background_color', '#919191')
3228
+ bg_qcolor = QColor(bg_color_hex)
3229
+
3230
+ if bg_qcolor.isValid():
3231
+ luminance = bg_qcolor.toHsl().lightness()
3232
+ text_color = 'black' if luminance > 128 else 'white'
3233
+ else:
3234
+ text_color = 'white'
3235
+
3023
3236
  text_actor = self.plotter.add_text(
3024
3237
  "Calculating...",
3025
- position='lower_left',
3238
+ position='lower_right',
3026
3239
  font_size=15,
3027
- color='gray',
3240
+ color=text_color,
3028
3241
  name='calculating_text'
3029
3242
  )
3030
- text_actor.GetTextProperty().SetOpacity(0.5)
3243
+ text_actor.GetTextProperty().SetOpacity(1)
3031
3244
  self.plotter.render()
3032
3245
  self.start_calculation.emit(mol_block)
3033
3246
 
@@ -3112,6 +3325,8 @@ class MainWindow(QMainWindow):
3112
3325
  state['version'] = VERSION
3113
3326
 
3114
3327
  if self.current_mol: state['mol_3d'] = self.current_mol.ToBinary()
3328
+
3329
+ state['is_3d_viewer_mode'] = not self.is_2d_editable
3115
3330
 
3116
3331
  return state
3117
3332
 
@@ -3188,6 +3403,12 @@ class MainWindow(QMainWindow):
3188
3403
 
3189
3404
  self.update_implicit_hydrogens()
3190
3405
  self.update_chiral_labels()
3406
+
3407
+ if loaded_data.get('is_3d_viewer_mode', False):
3408
+ self._enter_3d_viewer_ui_mode()
3409
+ self.statusBar().showMessage("Project loaded in 3D Viewer Mode.")
3410
+ else:
3411
+ self.restore_ui_for_editing()
3191
3412
 
3192
3413
 
3193
3414
  def push_undo_state(self):
@@ -3272,10 +3493,13 @@ class MainWindow(QMainWindow):
3272
3493
  item.setSelected(True)
3273
3494
 
3274
3495
  def clear_all(self):
3496
+
3497
+ self.restore_ui_for_editing()
3498
+
3275
3499
  # データが存在しない場合は何もしない
3276
3500
  if not self.data.atoms and self.current_mol is None:
3277
3501
  return
3278
-
3502
+
3279
3503
  self.dragged_atom_info = None
3280
3504
 
3281
3505
  # 2Dエディタをクリアする(Undoスタックにはプッシュしない)
@@ -3384,7 +3608,8 @@ class MainWindow(QMainWindow):
3384
3608
  AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
3385
3609
  conf = mol.GetConformer()
3386
3610
  AllChem.WedgeMolBonds(mol, conf)
3387
-
3611
+
3612
+ self.restore_ui_for_editing()
3388
3613
  self.clear_2d_editor(push_to_undo=False)
3389
3614
  self.current_mol = None
3390
3615
  self.plotter.clear()
@@ -3453,7 +3678,8 @@ class MainWindow(QMainWindow):
3453
3678
  AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
3454
3679
  conf = mol.GetConformer()
3455
3680
  AllChem.WedgeMolBonds(mol, conf)
3456
-
3681
+
3682
+ self.restore_ui_for_editing()
3457
3683
  self.clear_2d_editor(push_to_undo=False)
3458
3684
  self.current_mol = None
3459
3685
  self.plotter.clear()
@@ -3505,9 +3731,12 @@ class MainWindow(QMainWindow):
3505
3731
  except Exception as e:
3506
3732
  self.statusBar().showMessage(f"Error loading from InChI: {e}")
3507
3733
 
3508
- def load_mol_file(self):
3509
- options = QFileDialog.Option.DontUseNativeDialog
3510
- file_path, _ = QFileDialog.getOpenFileName(self, "Import MOL File", "", "Chemical Files (*.mol *.sdf);;All Files (*)", options=options)
3734
+ def load_mol_file(self, file_path=None):
3735
+ if not file_path:
3736
+ options = QFileDialog.Option.DontUseNativeDialog
3737
+ file_path, _ = QFileDialog.getOpenFileName(self, "Import MOL File", "", "Chemical Files (*.mol *.sdf);;All Files (*)", options=options)
3738
+ if not file_path: return
3739
+
3511
3740
  if not file_path: return
3512
3741
  try:
3513
3742
  self.dragged_atom_info = None
@@ -3516,7 +3745,8 @@ class MainWindow(QMainWindow):
3516
3745
  if mol is None: raise ValueError("Failed to read molecule from file.")
3517
3746
 
3518
3747
  Chem.Kekulize(mol)
3519
-
3748
+
3749
+ self.restore_ui_for_editing()
3520
3750
  self.clear_2d_editor(push_to_undo=False)
3521
3751
  self.current_mol = None; self.plotter.clear(); self.analysis_action.setEnabled(False)
3522
3752
 
@@ -3573,9 +3803,44 @@ class MainWindow(QMainWindow):
3573
3803
  self.reset_undo_stack()
3574
3804
  QTimer.singleShot(0, self.fit_to_view)
3575
3805
  except Exception as e: self.statusBar().showMessage(f"Error loading file: {e}")
3806
+
3807
+ def load_mol_for_3d_viewing(self):
3808
+ options = QFileDialog.Option.DontUseNativeDialog
3809
+ file_path, _ = QFileDialog.getOpenFileName(self, "Load 3D MOL (View Only)", "", "Chemical Files (*.mol *.sdf);;All Files (*)", options=options)
3810
+ if not file_path:
3811
+ return
3812
+
3813
+ try:
3814
+ suppl = Chem.SDMolSupplier(file_path, removeHs=False)
3815
+ mol = next(suppl, None)
3816
+ if mol is None:
3817
+ raise ValueError("Failed to read molecule.")
3818
+ if mol.GetNumConformers() == 0:
3819
+ raise ValueError("MOL file has no 3D coordinates.")
3820
+
3821
+ # 2Dエディタをクリア
3822
+ self.clear_2d_editor(push_to_undo=False)
3823
+
3824
+ # 3D構造をセットして描画
3825
+ self.current_mol = mol
3826
+ self.draw_molecule_3d(self.current_mol)
3827
+ self.plotter.reset_camera()
3828
+
3829
+ # UIを3Dビューアモードに設定
3830
+ self._enter_3d_viewer_ui_mode()
3831
+
3832
+ self.statusBar().showMessage(f"3D Viewer Mode: Loaded {os.path.basename(file_path)}")
3833
+ self.reset_undo_stack()
3834
+
3835
+ except Exception as e:
3836
+ self.statusBar().showMessage(f"Error loading 3D file: {e}", 5000)
3837
+ self.restore_ui_for_editing()
3838
+
3576
3839
 
3577
3840
  def save_raw_data(self):
3578
- if not self.data.atoms: self.statusBar().showMessage("Error: Nothing to save."); return
3841
+ if not self.data.atoms and not self.current_mol:
3842
+ self.statusBar().showMessage("Error: Nothing to save.")
3843
+ return
3579
3844
  save_data = self.get_current_state()
3580
3845
  options = QFileDialog.Option.DontUseNativeDialog
3581
3846
  file_path, _ = QFileDialog.getSaveFileName(self, "Save Project File", "", "Project Files (*.pmeraw);;All Files (*)", options=options)
@@ -3586,6 +3851,7 @@ class MainWindow(QMainWindow):
3586
3851
  self.statusBar().showMessage(f"Project saved to {file_path}")
3587
3852
  except Exception as e: self.statusBar().showMessage(f"Error saving project file: {e}")
3588
3853
 
3854
+
3589
3855
  def load_raw_data(self, file_path=None):
3590
3856
  if not file_path:
3591
3857
  options = QFileDialog.Option.DontUseNativeDialog
@@ -3594,6 +3860,7 @@ class MainWindow(QMainWindow):
3594
3860
 
3595
3861
  try:
3596
3862
  with open(file_path, 'rb') as f: loaded_data = pickle.load(f)
3863
+ self.restore_ui_for_editing()
3597
3864
  self.set_state_from_data(loaded_data)
3598
3865
  self.statusBar().showMessage(f"Project loaded from {file_path}")
3599
3866
  self.reset_undo_stack()
@@ -3626,7 +3893,13 @@ class MainWindow(QMainWindow):
3626
3893
  if not file_path.lower().endswith('.mol'):
3627
3894
  file_path += '.mol'
3628
3895
  try:
3629
- mol_block = Chem.MolToMolBlock(self.current_mol, includeStereo=True)
3896
+
3897
+ mol_to_save = Chem.Mol(self.current_mol)
3898
+
3899
+ if mol_to_save.HasProp("_2D"):
3900
+ mol_to_save.ClearProp("_2D")
3901
+
3902
+ mol_block = Chem.MolToMolBlock(mol_to_save, includeStereo=True)
3630
3903
  lines = mol_block.split('\n')
3631
3904
  if len(lines) > 1 and 'RDKit' in lines[1]:
3632
3905
  lines[1] = ' MoleditPy Ver. ' + VERSION + ' 3D'
@@ -3653,6 +3926,133 @@ class MainWindow(QMainWindow):
3653
3926
  self.statusBar().showMessage(f"Successfully saved to {file_path}")
3654
3927
  except Exception as e: self.statusBar().showMessage(f"Error saving file: {e}")
3655
3928
 
3929
+ def export_2d_png(self):
3930
+ if not self.data.atoms:
3931
+ self.statusBar().showMessage("Nothing to export.", 2000)
3932
+ return
3933
+
3934
+ options = QFileDialog.Option.DontUseNativeDialog
3935
+ filePath, _ = QFileDialog.getSaveFileName(self, "Export 2D as PNG", "", "PNG Files (*.png)", options=options)
3936
+ if not filePath:
3937
+ return
3938
+
3939
+ if not (filePath.lower().endswith(".png")):
3940
+ filePath += ".png"
3941
+
3942
+ reply = QMessageBox.question(self, 'Choose Background',
3943
+ 'Do you want a transparent background?\n(Choose "No" for a white background)',
3944
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
3945
+ QMessageBox.StandardButton.No)
3946
+
3947
+ if reply == QMessageBox.StandardButton.Cancel:
3948
+ self.statusBar().showMessage("Export cancelled.", 2000)
3949
+ return
3950
+
3951
+ is_transparent = (reply == QMessageBox.StandardButton.Yes)
3952
+
3953
+ QApplication.processEvents()
3954
+
3955
+ items_to_restore = {}
3956
+ original_background = self.scene.backgroundBrush()
3957
+
3958
+ try:
3959
+ all_items = list(self.scene.items())
3960
+ for item in all_items:
3961
+ is_mol_part = isinstance(item, (AtomItem, BondItem))
3962
+ if not (is_mol_part and item.isVisible()):
3963
+ items_to_restore[item] = item.isVisible()
3964
+ item.hide()
3965
+
3966
+ molecule_bounds = QRectF()
3967
+ for item in self.scene.items():
3968
+ if isinstance(item, (AtomItem, BondItem)) and item.isVisible():
3969
+ molecule_bounds = molecule_bounds.united(item.sceneBoundingRect())
3970
+
3971
+ if molecule_bounds.isEmpty() or not molecule_bounds.isValid():
3972
+ self.statusBar().showMessage("Error: Could not determine molecule bounds for export.", 5000)
3973
+ return
3974
+
3975
+ if is_transparent:
3976
+ self.scene.setBackgroundBrush(QBrush(Qt.BrushStyle.NoBrush))
3977
+ else:
3978
+ self.scene.setBackgroundBrush(QBrush(QColor("#FFFFFF")))
3979
+
3980
+ rect_to_render = molecule_bounds.adjusted(-20, -20, 20, 20)
3981
+
3982
+ w = max(1, int(math.ceil(rect_to_render.width())))
3983
+ h = max(1, int(math.ceil(rect_to_render.height())))
3984
+
3985
+ if w <= 0 or h <= 0:
3986
+ self.statusBar().showMessage("Error: Invalid image size calculated.", 5000)
3987
+ return
3988
+
3989
+ image = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
3990
+ if is_transparent:
3991
+ image.fill(Qt.GlobalColor.transparent)
3992
+ else:
3993
+ image.fill(Qt.GlobalColor.white)
3994
+
3995
+ painter = QPainter()
3996
+ ok = painter.begin(image)
3997
+ if not ok or not painter.isActive():
3998
+ self.statusBar().showMessage("Failed to start QPainter for image rendering.", 5000)
3999
+ return
4000
+
4001
+ try:
4002
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
4003
+ target_rect = QRectF(0, 0, w, h)
4004
+ source_rect = rect_to_render
4005
+ self.scene.render(painter, target_rect, source_rect)
4006
+ finally:
4007
+ painter.end()
4008
+
4009
+ saved = image.save(filePath, "PNG")
4010
+ if saved:
4011
+ self.statusBar().showMessage(f"2D view exported to {filePath}", 3000)
4012
+ else:
4013
+ self.statusBar().showMessage(f"Failed to save image. Check file path or permissions.", 5000)
4014
+
4015
+ except Exception as e:
4016
+ self.statusBar().showMessage(f"An unexpected error occurred during 2D export: {e}", 5000)
4017
+
4018
+ finally:
4019
+ for item, was_visible in items_to_restore.items():
4020
+ item.setVisible(was_visible)
4021
+ self.scene.setBackgroundBrush(original_background)
4022
+ if self.view_2d:
4023
+ self.view_2d.viewport().update()
4024
+
4025
+ def export_3d_png(self):
4026
+ if not self.current_mol:
4027
+ self.statusBar().showMessage("No 3D molecule to export.", 2000)
4028
+ return
4029
+
4030
+ options = QFileDialog.Option.DontUseNativeDialog
4031
+ filePath, _ = QFileDialog.getSaveFileName(self, "Export 3D as PNG", "", "PNG Files (*.png)", options=options)
4032
+ if not filePath:
4033
+ return
4034
+
4035
+ if not (filePath.lower().endswith(".png")):
4036
+ filePath += ".png"
4037
+
4038
+ reply = QMessageBox.question(self, 'Choose Background',
4039
+ 'Do you want a transparent background?\n(Choose "No" for current background)',
4040
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
4041
+ QMessageBox.StandardButton.No)
4042
+
4043
+ if reply == QMessageBox.StandardButton.Cancel:
4044
+ self.statusBar().showMessage("Export cancelled.", 2000)
4045
+ return
4046
+
4047
+ is_transparent = (reply == QMessageBox.StandardButton.Yes)
4048
+
4049
+ try:
4050
+ self.plotter.screenshot(filePath, transparent_background=is_transparent)
4051
+ self.statusBar().showMessage(f"3D view exported to {filePath}", 3000)
4052
+ except Exception as e:
4053
+ self.statusBar().showMessage(f"Error exporting 3D PNG: {e}", 3000)
4054
+
4055
+
3656
4056
  def open_periodic_table_dialog(self):
3657
4057
  dialog=PeriodicTableDialog(self); dialog.element_selected.connect(self.set_atom_from_periodic_table)
3658
4058
  checked_action=self.tool_group.checkedAction()
@@ -3874,32 +4274,80 @@ class MainWindow(QMainWindow):
3874
4274
  self.statusBar().showMessage("Resolved overlapping groups.", 2000)
3875
4275
 
3876
4276
 
4277
+
3877
4278
  def draw_molecule_3d(self, mol):
4279
+ """3D 分子を描画し、軸アクターの参照をクリアする(軸の再制御は apply_3d_settings に任せる)"""
4280
+
4281
+ # 1. カメラ状態とクリア
3878
4282
  camera_state = self.plotter.camera.copy()
4283
+
4284
+ # **残留防止のための強制削除**
4285
+ if self.axes_actor is not None:
4286
+ try:
4287
+ self.plotter.remove_actor(self.axes_actor)
4288
+ except Exception:
4289
+ pass
4290
+ self.axes_actor = None
4291
+
3879
4292
  self.plotter.clear()
4293
+
4294
+ # 2. 背景色の設定
4295
+ self.plotter.set_background(self.settings.get('background_color', '#4f4f4f'))
4296
+
4297
+ # 3. mol が None または原子数ゼロの場合は、背景と軸のみで終了
4298
+ if mol is None or mol.GetNumAtoms() == 0:
4299
+ self.atom_actor = None
4300
+ self.current_mol = None
4301
+ self.plotter.render()
4302
+ return
4303
+
4304
+ # 4. ライティングの設定
4305
+ is_lighting_enabled = self.settings.get('lighting_enabled', True)
4306
+
4307
+ if is_lighting_enabled:
4308
+ light = pv.Light(
4309
+ position=(1, 1, 2),
4310
+ light_type='cameralight',
4311
+ intensity=self.settings.get('light_intensity', 1.2)
4312
+ )
4313
+ self.plotter.add_light(light)
4314
+
4315
+ # 5. 分子描画ロジック
3880
4316
  conf = mol.GetConformer()
3881
-
3882
- # 3D原子座標をNumPy配列としてクラス変数に保持し、効率的な更新を可能にする
4317
+
3883
4318
  self.atom_positions_3d = np.array([list(conf.GetAtomPosition(i)) for i in range(mol.GetNumAtoms())])
3884
-
4319
+
3885
4320
  sym = [a.GetSymbol() for a in mol.GetAtoms()]
3886
4321
  col = np.array([CPK_COLORS_PV.get(s, [0.5, 0.5, 0.5]) for s in sym])
3887
-
4322
+
3888
4323
  if self.current_3d_style == 'cpk':
3889
4324
  rad = np.array([pt.GetRvdw(pt.GetAtomicNumber(s)) * 1.0 for s in sym])
3890
- else: # ball_and_stick (default)
4325
+ else:
3891
4326
  rad = np.array([VDW_RADII.get(s, 0.4) for s in sym])
3892
4327
 
3893
- # 原子球(グリフ)の元となるPolyDataをクラス変数として保持
3894
4328
  self.glyph_source = pv.PolyData(self.atom_positions_3d)
3895
4329
  self.glyph_source['colors'] = col
3896
4330
  self.glyph_source['radii'] = rad
3897
-
3898
- glyphs = self.glyph_source.glyph(scale='radii', geom=pv.Sphere(radius=1.0), orient=False)
3899
-
3900
- edge_color = '#505050'
3901
- # ピック(選択)対象となる原子のアクターをクラス変数として保持
3902
- self.atom_actor = self.plotter.add_mesh(glyphs, scalars='colors', rgb=True, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3, line_width=0.1)
4331
+
4332
+ glyphs = self.glyph_source.glyph(scale='radii', geom=pv.Sphere(radius=1.0, theta_resolution=32, phi_resolution=32), orient=False)
4333
+
4334
+ mesh_props = dict(
4335
+ smooth_shading=True,
4336
+ specular=self.settings.get('specular', 0.2),
4337
+ specular_power=self.settings.get('specular_power', 20),
4338
+ lighting=is_lighting_enabled,
4339
+ )
4340
+
4341
+ if is_lighting_enabled:
4342
+ self.atom_actor = self.plotter.add_mesh(glyphs, scalars='colors', rgb=True, **mesh_props)
4343
+ else:
4344
+ self.atom_actor = self.plotter.add_mesh(
4345
+ glyphs, scalars='colors', rgb=True,
4346
+ style='surface', show_edges=True, edge_color='grey',
4347
+ **mesh_props
4348
+ )
4349
+ self.atom_actor.GetProperty().SetEdgeOpacity(0.3)
4350
+
3903
4351
 
3904
4352
  if self.current_3d_style == 'ball_and_stick':
3905
4353
  bond_meshes = []
@@ -3912,8 +4360,9 @@ class MainWindow(QMainWindow):
3912
4360
  h = np.linalg.norm(d)
3913
4361
  if h == 0: continue
3914
4362
 
4363
+ cyl_radius = 0.1
3915
4364
  if bt == Chem.rdchem.BondType.SINGLE or bt == Chem.rdchem.BondType.AROMATIC:
3916
- cyl = pv.Cylinder(center=c, direction=d, radius=0.1, height=h)
4365
+ cyl = pv.Cylinder(center=c, direction=d, radius=cyl_radius, height=h, resolution=16)
3917
4366
  bond_meshes.append(cyl)
3918
4367
  else:
3919
4368
  v1 = d / h
@@ -3921,20 +4370,19 @@ class MainWindow(QMainWindow):
3921
4370
  if np.allclose(np.abs(np.dot(v1, v_arb)), 1.0): v_arb = np.array([0, 1, 0])
3922
4371
  off_dir = np.cross(v1, v_arb)
3923
4372
  off_dir /= np.linalg.norm(off_dir)
4373
+ r, s = cyl_radius * 0.8, cyl_radius * 2.0
3924
4374
  if bt == Chem.rdchem.BondType.DOUBLE:
3925
- r, s = 0.08, 0.20
3926
4375
  c1, c2 = c + off_dir * (s / 2), c - off_dir * (s / 2)
3927
- bond_meshes.append(pv.Cylinder(center=c1, direction=d, radius=r, height=h))
3928
- bond_meshes.append(pv.Cylinder(center=c2, direction=d, radius=r, height=h))
4376
+ bond_meshes.append(pv.Cylinder(center=c1, direction=d, radius=r, height=h, resolution=16))
4377
+ bond_meshes.append(pv.Cylinder(center=c2, direction=d, radius=r, height=h, resolution=16))
3929
4378
  elif bt == Chem.rdchem.BondType.TRIPLE:
3930
- r, s = 0.08, 0.20
3931
- bond_meshes.append(pv.Cylinder(center=c, direction=d, radius=r, height=h))
3932
- bond_meshes.append(pv.Cylinder(center=c + off_dir * s, direction=d, radius=r, height=h))
3933
- bond_meshes.append(pv.Cylinder(center=c - off_dir * s, direction=d, radius=r, height=h))
4379
+ bond_meshes.append(pv.Cylinder(center=c, direction=d, radius=r, height=h, resolution=16))
4380
+ bond_meshes.append(pv.Cylinder(center=c + off_dir * s, direction=d, radius=r, height=h, resolution=16))
4381
+ bond_meshes.append(pv.Cylinder(center=c - off_dir * s, direction=d, radius=r, height=h, resolution=16))
3934
4382
 
3935
4383
  if bond_meshes:
3936
4384
  combined_bonds = pv.merge(bond_meshes)
3937
- self.plotter.add_mesh(combined_bonds, color=[0.5, 0.5, 0.5], smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
4385
+ self.plotter.add_mesh(combined_bonds, color='grey', **mesh_props)
3938
4386
 
3939
4387
  if getattr(self, 'show_chiral_labels', False):
3940
4388
  try:
@@ -3949,7 +4397,7 @@ class MainWindow(QMainWindow):
3949
4397
  except Exception: pass
3950
4398
  self.plotter.add_point_labels(np.array(pts), labels, font_size=20, point_size=0, text_color='k', name='chiral_labels', always_visible=True, tolerance=0.01, show_points=False)
3951
4399
  except Exception as e: self.statusBar().showMessage(f"3D chiral label drawing error: {e}")
3952
-
4400
+
3953
4401
  self.plotter.camera = camera_state
3954
4402
 
3955
4403
 
@@ -4035,6 +4483,8 @@ class MainWindow(QMainWindow):
4035
4483
  self.statusBar().showMessage("Please generate a 3D structure first to show analysis.")
4036
4484
 
4037
4485
  def closeEvent(self, event):
4486
+ if self.settings != self.initial_settings:
4487
+ self.save_settings()
4038
4488
  reply = QMessageBox.question(self, 'Confirm Exit',
4039
4489
  "Are you sure you want to exit?",
4040
4490
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4103,36 +4553,170 @@ class MainWindow(QMainWindow):
4103
4553
  style = CustomInteractorStyle(self)
4104
4554
 
4105
4555
  # 調査の結果、'style' プロパティへの代入が正しい設定方法と判明
4106
- self.plotter.iren.style = style
4556
+ self.plotter.interactor.SetInteractorStyle(style)
4557
+ self.plotter.interactor.Initialize()
4107
4558
 
4108
- # 新しいスタイルを確実に読み込ませるためにインタラクタを初期化
4109
- self.plotter.iren.initialize()
4110
-
4111
4559
  def dragEnterEvent(self, event):
4112
- """ファイルがウィンドウ上にドラッグされたときに呼び出される"""
4113
- # ドラッグされたデータにファイルパスが含まれているかチェック
4560
+ """ウィンドウ全体で .pmeraw ファイルのドラッグのみを受け入れる"""
4114
4561
  if event.mimeData().hasUrls():
4115
- event.acceptProposedAction() # 受け入れ可能であることをカーソルで示す
4116
- else:
4117
- event.ignore()
4562
+ urls = event.mimeData().urls()
4563
+ if urls and urls[0].isLocalFile():
4564
+ file_path = urls[0].toLocalFile()
4565
+ if file_path.lower().endswith(('.pmeraw', '.mol', '.sdf')):
4566
+ event.acceptProposedAction()
4567
+ return
4568
+ event.ignore()
4118
4569
 
4119
4570
  def dropEvent(self, event):
4120
4571
  """ファイルがウィンドウ上でドロップされたときに呼び出される"""
4121
4572
  urls = event.mimeData().urls()
4122
- # ドロップされたのがローカルファイルの場合
4123
4573
  if urls and urls[0].isLocalFile():
4124
4574
  file_path = urls[0].toLocalFile()
4125
4575
 
4126
- # ファイルの拡張子が .pmeraw であることを確認
4576
+ # 拡張子に応じて適切な読み込みメソッドを呼び出す
4127
4577
  if file_path.lower().endswith('.pmeraw'):
4128
- # 既存のファイル読み込み関数を呼び出す
4129
4578
  self.load_raw_data(file_path=file_path)
4130
4579
  event.acceptProposedAction()
4580
+ elif file_path.lower().endswith(('.mol', '.sdf')):
4581
+ # .mol/.sdfファイルを開くメソッドを呼び出す(メソッドが存在する場合)
4582
+ if hasattr(self, 'load_mol_file'):
4583
+ self.load_mol_file(file_path=file_path)
4584
+ event.acceptProposedAction()
4585
+ else:
4586
+ self.statusBar().showMessage(f"'{file_path.lower().split('.')[-1]}' file import is not implemented.")
4587
+ event.ignore()
4131
4588
  else:
4132
4589
  event.ignore()
4133
4590
  else:
4134
4591
  event.ignore()
4135
4592
 
4593
+ def _enter_3d_viewer_ui_mode(self):
4594
+ """3DビューアモードのUI状態に設定する"""
4595
+ self.is_2d_editable = False
4596
+ self.cleanup_button.setEnabled(False)
4597
+ self.convert_button.setEnabled(False)
4598
+ for action in self.tool_group.actions():
4599
+ action.setEnabled(False)
4600
+ if hasattr(self, 'other_atom_action'):
4601
+ self.other_atom_action.setEnabled(False)
4602
+
4603
+ self.minimize_2d_panel()
4604
+
4605
+ self.optimize_3d_button.setEnabled(True)
4606
+ self.export_button.setEnabled(True)
4607
+ self.edit_3d_action.setEnabled(True)
4608
+ self.analysis_action.setEnabled(True)
4609
+
4610
+ def restore_ui_for_editing(self):
4611
+ """Enables all 2D editing UI elements."""
4612
+ self.is_2d_editable = True
4613
+ self.restore_2d_panel()
4614
+ self.cleanup_button.setEnabled(True)
4615
+ self.convert_button.setEnabled(True)
4616
+
4617
+ for action in self.tool_group.actions():
4618
+ action.setEnabled(True)
4619
+
4620
+ if hasattr(self, 'other_atom_action'):
4621
+ self.other_atom_action.setEnabled(True)
4622
+
4623
+ def minimize_2d_panel(self):
4624
+ """2Dパネルを最小化(非表示に)する"""
4625
+ sizes = self.splitter.sizes()
4626
+ # すでに最小化されていなければ実行
4627
+ if sizes[0] > 0:
4628
+ total_width = sum(sizes)
4629
+ self.splitter.setSizes([0, total_width])
4630
+
4631
+ def restore_2d_panel(self):
4632
+ """最小化された2Dパネルを元のサイズに戻す"""
4633
+ sizes = self.splitter.sizes()
4634
+
4635
+ # sizesリストが空でないことを確認してからアクセスする
4636
+ if sizes and sizes[0] == 0:
4637
+ self.splitter.setSizes([600, 600])
4638
+
4639
+
4640
+ def apply_initial_settings(self):
4641
+ """UIの初期化が完了した後に、保存された設定を3Dビューに適用する"""
4642
+ if self.plotter and self.plotter.renderer:
4643
+ bg_color = self.settings.get('background_color', '#919191')
4644
+ self.plotter.set_background(bg_color)
4645
+ self.apply_3d_settings()
4646
+
4647
+
4648
+ def apply_3d_settings(self):
4649
+ """3Dビューの視覚設定を適用する"""
4650
+ if not hasattr(self, 'plotter'):
4651
+ return
4652
+
4653
+ # --- 3D軸ウィジェットの設定 ---
4654
+ show_axes = self.settings.get('show_3d_axes', True)
4655
+
4656
+ # ウィジェットがまだ作成されていない場合は作成する
4657
+ if self.axes_widget is None and hasattr(self.plotter, 'interactor'):
4658
+ axes = vtk.vtkAxesActor()
4659
+ self.axes_widget = vtk.vtkOrientationMarkerWidget()
4660
+ self.axes_widget.SetOrientationMarker(axes)
4661
+ self.axes_widget.SetInteractor(self.plotter.interactor)
4662
+ # 左下隅に設定 (幅・高さ20%)
4663
+ self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2)
4664
+
4665
+ # 設定に応じてウィジェットを有効化/無効化
4666
+ if self.axes_widget:
4667
+ if show_axes:
4668
+ self.axes_widget.On()
4669
+ self.axes_widget.SetInteractive(False)
4670
+ else:
4671
+ self.axes_widget.Off()
4672
+
4673
+ self.draw_molecule_3d(self.current_mol)
4674
+ self.plotter.reset_camera()
4675
+
4676
+
4677
+
4678
+ def open_settings_dialog(self):
4679
+ dialog = SettingsDialog(self.settings, self)
4680
+ if dialog.exec():
4681
+ self.settings = dialog.get_settings()
4682
+ self.save_settings()
4683
+ self.apply_3d_settings()
4684
+
4685
+ def load_settings(self):
4686
+ default_settings = {
4687
+ 'background_color': '#919191',
4688
+ 'lighting_enabled': True,
4689
+ 'specular': 0.2,
4690
+ 'specular_power': 20,
4691
+ 'light_intensity': 1.0,
4692
+ 'show_3d_axes': True,
4693
+ }
4694
+
4695
+ try:
4696
+ if os.path.exists(self.settings_file):
4697
+ with open(self.settings_file, 'r') as f:
4698
+ loaded_settings = json.load(f)
4699
+
4700
+ for key, value in default_settings.items():
4701
+ loaded_settings.setdefault(key, value)
4702
+ self.settings = loaded_settings
4703
+
4704
+ else:
4705
+ self.settings = default_settings
4706
+
4707
+ except Exception:
4708
+ self.settings = default_settings
4709
+
4710
+ def save_settings(self):
4711
+ try:
4712
+ if not os.path.exists(self.settings_dir):
4713
+ os.makedirs(self.settings_dir)
4714
+ with open(self.settings_file, 'w') as f:
4715
+ json.dump(self.settings, f, indent=4)
4716
+ except Exception as e:
4717
+ print(f"Error saving settings: {e}")
4718
+
4719
+
4136
4720
 
4137
4721
  # --- Application Execution ---
4138
4722
  if __name__ == '__main__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleditpy
3
- Version: 1.3.1
3
+ Version: 1.4.0
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