moleditpy 1.3.2__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.2
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.2"
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.2'
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)):
@@ -1777,8 +1793,6 @@ class MoleculeScene(QGraphicsScene):
1777
1793
  # --- どの操作にも当てはまらない場合 ---
1778
1794
  super().keyPressEvent(event)
1779
1795
 
1780
-
1781
- # In MoleculeScene class
1782
1796
  def find_atom_near(self, pos, tol=14.0):
1783
1797
  # Create a small search rectangle around the position
1784
1798
  search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
@@ -1809,7 +1823,7 @@ class ZoomableView(QGraphicsView):
1809
1823
  self.setDragMode(QGraphicsView.DragMode.NoDrag)
1810
1824
 
1811
1825
  self.main_window = parent
1812
- self.setAcceptDrops(True)
1826
+ self.setAcceptDrops(False)
1813
1827
 
1814
1828
  self._is_panning = False
1815
1829
  self._pan_start_pos = QPointF()
@@ -1887,44 +1901,7 @@ class ZoomableView(QGraphicsView):
1887
1901
  else:
1888
1902
  super().mouseReleaseEvent(event)
1889
1903
 
1890
- # ZoomableView クラス内 (約1691行目あたりから)
1891
-
1892
- def dragEnterEvent(self, event):
1893
- """ファイルがこのウィジェット上にドラッグされたときに呼び出される"""
1894
- # ドラッグされたデータにファイルパスが含まれている場合のみ受け入れる
1895
- if event.mimeData().hasUrls():
1896
- event.acceptProposedAction()
1897
- else:
1898
- event.ignore()
1899
-
1900
- def dragMoveEvent(self, event):
1901
- """
1902
- ウィジェット上でドラッグが移動したときに呼び出される。
1903
- """
1904
- if event.mimeData().hasUrls():
1905
- event.acceptProposedAction()
1906
- else:
1907
- super().dragMoveEvent(event)
1908
1904
 
1909
- def dropEvent(self, event):
1910
- """ファイルがこのウィジェット上でドロップされたときに呼び出される"""
1911
- urls = event.mimeData().urls()
1912
- if urls and urls[0].isLocalFile():
1913
- file_path = urls[0].toLocalFile()
1914
-
1915
- # MainWindowのファイル読み込みメソッドを呼び出す
1916
- if self.main_window:
1917
- # 拡張子によって適切な読み込み処理を呼び出す
1918
- if file_path.lower().endswith('.pmeraw'):
1919
- self.main_window.load_raw_data(file_path=file_path)
1920
- elif file_path.lower().endswith(('.mol', '.sdf')):
1921
- self.main_window.load_mol_file(file_path=file_path)
1922
-
1923
- event.acceptProposedAction()
1924
- else:
1925
- event.ignore()
1926
- else:
1927
- event.ignore()
1928
1905
 
1929
1906
  class CalculationWorker(QObject):
1930
1907
  status_update = pyqtSignal(str)
@@ -2169,6 +2146,143 @@ class AnalysisWindow(QDialog):
2169
2146
  if self.parent() and hasattr(self.parent(), 'statusBar'):
2170
2147
  self.parent().statusBar().showMessage(f"Copied '{text}' to clipboard.", 2000)
2171
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
+
2172
2286
  # --- 3Dインタラクションを管理する専用クラス ---
2173
2287
  class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2174
2288
  def __init__(self, main_window):
@@ -2192,7 +2306,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2192
2306
  is_temp_mode = bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
2193
2307
  is_edit_active = mw.is_3d_edit_mode or is_temp_mode
2194
2308
 
2195
- if is_edit_active:
2309
+ # 3D分子(mw.current_mol)が存在する場合のみ、原子の選択処理を実行
2310
+ if is_edit_active and mw.current_mol:
2196
2311
  click_pos = self.GetInteractor().GetEventPosition()
2197
2312
  picker = mw.plotter.picker
2198
2313
  picker.Pick(click_pos[0], click_pos[1], 0, mw.plotter.renderer)
@@ -2202,17 +2317,20 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2202
2317
  distances = np.linalg.norm(mw.atom_positions_3d - picked_position, axis=1)
2203
2318
  closest_atom_idx = np.argmin(distances)
2204
2319
 
2205
- atomic_num = mw.current_mol.GetAtomWithIdx(int(closest_atom_idx)).GetAtomicNum()
2206
- vdw_radius = pt.GetRvdw(atomic_num)
2207
- click_threshold = vdw_radius * 1.5
2208
-
2209
- if distances[closest_atom_idx] < click_threshold:
2210
- # 原子を掴むことに成功した場合
2211
- self._is_dragging_atom = True
2212
- self.is_dragging = False
2213
- mw.dragged_atom_info = {'id': int(closest_atom_idx)}
2214
- mw.plotter.setCursor(Qt.CursorShape.ClosedHandCursor)
2215
- 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 # 親クラスのカメラ回転を呼ばない
2216
2334
 
2217
2335
  self._is_dragging_atom = False
2218
2336
  super().OnLeftButtonDown()
@@ -2286,12 +2404,14 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
2286
2404
 
2287
2405
  # 状態をリセット
2288
2406
  self._is_dragging_atom = False
2407
+ self.is_dragging = False # is_draggingもリセット
2289
2408
 
2290
- # ボタンを離した後のカーソル表示を最新の状態に更新するため、
2291
- # on_mouse_moveを一度呼び出します
2409
+ # ボタンを離した後のカーソル表示を最新の状態に更新
2292
2410
  self.on_mouse_move(obj, event)
2293
2411
 
2294
- mw.view_2d.setFocus()
2412
+ # 2Dビューにフォーカスを戻し、ショートカットキーなどが使えるようにする
2413
+ if mw and mw.view_2d:
2414
+ mw.view_2d.setFocus()
2295
2415
 
2296
2416
  class MainWindow(QMainWindow):
2297
2417
 
@@ -2299,6 +2419,11 @@ class MainWindow(QMainWindow):
2299
2419
  def __init__(self, initial_file=None):
2300
2420
  super().__init__()
2301
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()
2302
2427
  self.setWindowTitle("MoleditPy -- Python Molecular Editor Ver. " + VERSION); self.setGeometry(100, 100, 1400, 800)
2303
2428
  self.data = MolecularData(); self.current_mol = None
2304
2429
  self.current_3d_style = 'ball_and_stick'
@@ -2306,6 +2431,9 @@ class MainWindow(QMainWindow):
2306
2431
  self.is_3d_edit_mode = False
2307
2432
  self.dragged_atom_info = None
2308
2433
  self.atom_actor = None
2434
+ self.is_2d_editable = True
2435
+ self.axes_actor = None
2436
+ self.axes_widget = None
2309
2437
  self.undo_stack = []
2310
2438
  self.redo_stack = []
2311
2439
  self.mode_actions = {}
@@ -2333,6 +2461,8 @@ class MainWindow(QMainWindow):
2333
2461
 
2334
2462
  if initial_file:
2335
2463
  self.load_raw_data(file_path=initial_file)
2464
+
2465
+ QTimer.singleShot(0, self.apply_initial_settings)
2336
2466
 
2337
2467
  def init_ui(self):
2338
2468
  # 1. 現在のスクリプトがあるディレクトリのパスを取得
@@ -2352,8 +2482,8 @@ class MainWindow(QMainWindow):
2352
2482
 
2353
2483
  self.init_menu_bar()
2354
2484
 
2355
- splitter=QSplitter(Qt.Orientation.Horizontal)
2356
- self.setCentralWidget(splitter)
2485
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
2486
+ self.setCentralWidget(self.splitter)
2357
2487
 
2358
2488
  left_pane=QWidget()
2359
2489
  left_pane.setAcceptDrops(True)
@@ -2383,13 +2513,13 @@ class MainWindow(QMainWindow):
2383
2513
  left_buttons_layout.addWidget(self.convert_button)
2384
2514
 
2385
2515
  left_layout.addLayout(left_buttons_layout)
2386
- splitter.addWidget(left_pane)
2516
+ self.splitter.addWidget(left_pane)
2387
2517
 
2388
2518
  # --- 右パネルとボタンレイアウト ---
2389
2519
  right_pane = QWidget()
2390
2520
  # 1. 右パネル全体は「垂直」レイアウトにする
2391
2521
  right_layout = QVBoxLayout(right_pane)
2392
- self.plotter = QtInteractor(right_pane)
2522
+ self.plotter = CustomQtInteractor(right_pane, main_window=self, lighting='none')
2393
2523
  self.plotter.setAcceptDrops(False)
2394
2524
  self.plotter.setSizePolicy(
2395
2525
  QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
@@ -2429,9 +2559,9 @@ class MainWindow(QMainWindow):
2429
2559
 
2430
2560
  # 4. 水平のボタンレイアウトを、全体の垂直レイアウトに追加
2431
2561
  right_layout.addLayout(right_buttons_layout)
2432
- splitter.addWidget(right_pane)
2562
+ self.splitter.addWidget(right_pane)
2433
2563
 
2434
- splitter.setSizes([600, 600])
2564
+ self.splitter.setSizes([600, 600])
2435
2565
 
2436
2566
  # ステータスバーを左右に分離するための設定
2437
2567
  self.status_bar = self.statusBar()
@@ -2463,6 +2593,7 @@ class MainWindow(QMainWindow):
2463
2593
 
2464
2594
  if mode == 'atom_other':
2465
2595
  action.triggered.connect(self.open_periodic_table_dialog)
2596
+ self.other_atom_action = action
2466
2597
  else:
2467
2598
  action.triggered.connect(lambda c, m=mode: self.set_mode(m))
2468
2599
  self.mode_actions[mode] = action
@@ -2685,6 +2816,12 @@ class MainWindow(QMainWindow):
2685
2816
  import_inchi_action = QAction("Import InChI...", self)
2686
2817
  import_inchi_action.triggered.connect(self.import_inchi_dialog)
2687
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
+
2688
2825
  file_menu.addSeparator()
2689
2826
  save_mol_action = QAction("Save 2D as MOL...", self); save_mol_action.triggered.connect(self.save_as_mol)
2690
2827
  file_menu.addAction(save_mol_action)
@@ -2702,6 +2839,16 @@ class MainWindow(QMainWindow):
2702
2839
  load_raw_action.setShortcut(QKeySequence.StandardKey.Open)
2703
2840
  file_menu.addAction(load_raw_action)
2704
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
+
2705
2852
  file_menu.addSeparator()
2706
2853
  quit_action = QAction("Quit", self)
2707
2854
  quit_action.triggered.connect(self.close)
@@ -2781,6 +2928,13 @@ class MainWindow(QMainWindow):
2781
2928
 
2782
2929
  view_menu.addSeparator()
2783
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
+
2784
2938
  self.toggle_chiral_action = QAction("Show Chiral Labels", self, checkable=True)
2785
2939
  self.toggle_chiral_action.setChecked(self.show_chiral_labels)
2786
2940
  self.toggle_chiral_action.triggered.connect(self.toggle_chiral_labels_display)
@@ -2792,6 +2946,11 @@ class MainWindow(QMainWindow):
2792
2946
  self.analysis_action.setEnabled(False)
2793
2947
  analysis_menu.addAction(self.analysis_action)
2794
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
+
2795
2954
  help_menu = menu_bar.addMenu("&Help")
2796
2955
  about_action = QAction("About", self)
2797
2956
  about_action.triggered.connect(lambda: QMessageBox.about(
@@ -2820,7 +2979,6 @@ class MainWindow(QMainWindow):
2820
2979
 
2821
2980
  def set_mode(self, mode_str):
2822
2981
  self.scene.mode = mode_str
2823
-
2824
2982
  self.view_2d.setMouseTracking(True)
2825
2983
  if not mode_str.startswith('template'):
2826
2984
  self.scene.template_preview.hide()
@@ -3066,14 +3224,23 @@ class MainWindow(QMainWindow):
3066
3224
  self.edit_3d_action.setEnabled(False)
3067
3225
  self.statusBar().showMessage("Calculating 3D structure...")
3068
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
+
3069
3236
  text_actor = self.plotter.add_text(
3070
3237
  "Calculating...",
3071
- position='lower_left',
3238
+ position='lower_right',
3072
3239
  font_size=15,
3073
- color='gray',
3240
+ color=text_color,
3074
3241
  name='calculating_text'
3075
3242
  )
3076
- text_actor.GetTextProperty().SetOpacity(0.5)
3243
+ text_actor.GetTextProperty().SetOpacity(1)
3077
3244
  self.plotter.render()
3078
3245
  self.start_calculation.emit(mol_block)
3079
3246
 
@@ -3158,6 +3325,8 @@ class MainWindow(QMainWindow):
3158
3325
  state['version'] = VERSION
3159
3326
 
3160
3327
  if self.current_mol: state['mol_3d'] = self.current_mol.ToBinary()
3328
+
3329
+ state['is_3d_viewer_mode'] = not self.is_2d_editable
3161
3330
 
3162
3331
  return state
3163
3332
 
@@ -3234,6 +3403,12 @@ class MainWindow(QMainWindow):
3234
3403
 
3235
3404
  self.update_implicit_hydrogens()
3236
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()
3237
3412
 
3238
3413
 
3239
3414
  def push_undo_state(self):
@@ -3318,10 +3493,13 @@ class MainWindow(QMainWindow):
3318
3493
  item.setSelected(True)
3319
3494
 
3320
3495
  def clear_all(self):
3496
+
3497
+ self.restore_ui_for_editing()
3498
+
3321
3499
  # データが存在しない場合は何もしない
3322
3500
  if not self.data.atoms and self.current_mol is None:
3323
3501
  return
3324
-
3502
+
3325
3503
  self.dragged_atom_info = None
3326
3504
 
3327
3505
  # 2Dエディタをクリアする(Undoスタックにはプッシュしない)
@@ -3430,7 +3608,8 @@ class MainWindow(QMainWindow):
3430
3608
  AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
3431
3609
  conf = mol.GetConformer()
3432
3610
  AllChem.WedgeMolBonds(mol, conf)
3433
-
3611
+
3612
+ self.restore_ui_for_editing()
3434
3613
  self.clear_2d_editor(push_to_undo=False)
3435
3614
  self.current_mol = None
3436
3615
  self.plotter.clear()
@@ -3499,7 +3678,8 @@ class MainWindow(QMainWindow):
3499
3678
  AllChem.AssignStereochemistry(mol, cleanIt=True, force=True)
3500
3679
  conf = mol.GetConformer()
3501
3680
  AllChem.WedgeMolBonds(mol, conf)
3502
-
3681
+
3682
+ self.restore_ui_for_editing()
3503
3683
  self.clear_2d_editor(push_to_undo=False)
3504
3684
  self.current_mol = None
3505
3685
  self.plotter.clear()
@@ -3565,7 +3745,8 @@ class MainWindow(QMainWindow):
3565
3745
  if mol is None: raise ValueError("Failed to read molecule from file.")
3566
3746
 
3567
3747
  Chem.Kekulize(mol)
3568
-
3748
+
3749
+ self.restore_ui_for_editing()
3569
3750
  self.clear_2d_editor(push_to_undo=False)
3570
3751
  self.current_mol = None; self.plotter.clear(); self.analysis_action.setEnabled(False)
3571
3752
 
@@ -3622,9 +3803,44 @@ class MainWindow(QMainWindow):
3622
3803
  self.reset_undo_stack()
3623
3804
  QTimer.singleShot(0, self.fit_to_view)
3624
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
+
3625
3839
 
3626
3840
  def save_raw_data(self):
3627
- 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
3628
3844
  save_data = self.get_current_state()
3629
3845
  options = QFileDialog.Option.DontUseNativeDialog
3630
3846
  file_path, _ = QFileDialog.getSaveFileName(self, "Save Project File", "", "Project Files (*.pmeraw);;All Files (*)", options=options)
@@ -3635,6 +3851,7 @@ class MainWindow(QMainWindow):
3635
3851
  self.statusBar().showMessage(f"Project saved to {file_path}")
3636
3852
  except Exception as e: self.statusBar().showMessage(f"Error saving project file: {e}")
3637
3853
 
3854
+
3638
3855
  def load_raw_data(self, file_path=None):
3639
3856
  if not file_path:
3640
3857
  options = QFileDialog.Option.DontUseNativeDialog
@@ -3643,6 +3860,7 @@ class MainWindow(QMainWindow):
3643
3860
 
3644
3861
  try:
3645
3862
  with open(file_path, 'rb') as f: loaded_data = pickle.load(f)
3863
+ self.restore_ui_for_editing()
3646
3864
  self.set_state_from_data(loaded_data)
3647
3865
  self.statusBar().showMessage(f"Project loaded from {file_path}")
3648
3866
  self.reset_undo_stack()
@@ -3675,7 +3893,13 @@ class MainWindow(QMainWindow):
3675
3893
  if not file_path.lower().endswith('.mol'):
3676
3894
  file_path += '.mol'
3677
3895
  try:
3678
- 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)
3679
3903
  lines = mol_block.split('\n')
3680
3904
  if len(lines) > 1 and 'RDKit' in lines[1]:
3681
3905
  lines[1] = ' MoleditPy Ver. ' + VERSION + ' 3D'
@@ -3702,6 +3926,133 @@ class MainWindow(QMainWindow):
3702
3926
  self.statusBar().showMessage(f"Successfully saved to {file_path}")
3703
3927
  except Exception as e: self.statusBar().showMessage(f"Error saving file: {e}")
3704
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
+
3705
4056
  def open_periodic_table_dialog(self):
3706
4057
  dialog=PeriodicTableDialog(self); dialog.element_selected.connect(self.set_atom_from_periodic_table)
3707
4058
  checked_action=self.tool_group.checkedAction()
@@ -3923,32 +4274,80 @@ class MainWindow(QMainWindow):
3923
4274
  self.statusBar().showMessage("Resolved overlapping groups.", 2000)
3924
4275
 
3925
4276
 
4277
+
3926
4278
  def draw_molecule_3d(self, mol):
4279
+ """3D 分子を描画し、軸アクターの参照をクリアする(軸の再制御は apply_3d_settings に任せる)"""
4280
+
4281
+ # 1. カメラ状態とクリア
3927
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
+
3928
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. 分子描画ロジック
3929
4316
  conf = mol.GetConformer()
3930
-
3931
- # 3D原子座標をNumPy配列としてクラス変数に保持し、効率的な更新を可能にする
4317
+
3932
4318
  self.atom_positions_3d = np.array([list(conf.GetAtomPosition(i)) for i in range(mol.GetNumAtoms())])
3933
-
4319
+
3934
4320
  sym = [a.GetSymbol() for a in mol.GetAtoms()]
3935
4321
  col = np.array([CPK_COLORS_PV.get(s, [0.5, 0.5, 0.5]) for s in sym])
3936
-
4322
+
3937
4323
  if self.current_3d_style == 'cpk':
3938
4324
  rad = np.array([pt.GetRvdw(pt.GetAtomicNumber(s)) * 1.0 for s in sym])
3939
- else: # ball_and_stick (default)
4325
+ else:
3940
4326
  rad = np.array([VDW_RADII.get(s, 0.4) for s in sym])
3941
4327
 
3942
- # 原子球(グリフ)の元となるPolyDataをクラス変数として保持
3943
4328
  self.glyph_source = pv.PolyData(self.atom_positions_3d)
3944
4329
  self.glyph_source['colors'] = col
3945
4330
  self.glyph_source['radii'] = rad
3946
-
3947
- glyphs = self.glyph_source.glyph(scale='radii', geom=pv.Sphere(radius=1.0), orient=False)
3948
-
3949
- edge_color = '#505050'
3950
- # ピック(選択)対象となる原子のアクターをクラス変数として保持
3951
- 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
+
3952
4351
 
3953
4352
  if self.current_3d_style == 'ball_and_stick':
3954
4353
  bond_meshes = []
@@ -3961,8 +4360,9 @@ class MainWindow(QMainWindow):
3961
4360
  h = np.linalg.norm(d)
3962
4361
  if h == 0: continue
3963
4362
 
4363
+ cyl_radius = 0.1
3964
4364
  if bt == Chem.rdchem.BondType.SINGLE or bt == Chem.rdchem.BondType.AROMATIC:
3965
- 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)
3966
4366
  bond_meshes.append(cyl)
3967
4367
  else:
3968
4368
  v1 = d / h
@@ -3970,20 +4370,19 @@ class MainWindow(QMainWindow):
3970
4370
  if np.allclose(np.abs(np.dot(v1, v_arb)), 1.0): v_arb = np.array([0, 1, 0])
3971
4371
  off_dir = np.cross(v1, v_arb)
3972
4372
  off_dir /= np.linalg.norm(off_dir)
4373
+ r, s = cyl_radius * 0.8, cyl_radius * 2.0
3973
4374
  if bt == Chem.rdchem.BondType.DOUBLE:
3974
- r, s = 0.08, 0.20
3975
4375
  c1, c2 = c + off_dir * (s / 2), c - off_dir * (s / 2)
3976
- bond_meshes.append(pv.Cylinder(center=c1, direction=d, radius=r, height=h))
3977
- 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))
3978
4378
  elif bt == Chem.rdchem.BondType.TRIPLE:
3979
- r, s = 0.08, 0.20
3980
- bond_meshes.append(pv.Cylinder(center=c, direction=d, radius=r, height=h))
3981
- bond_meshes.append(pv.Cylinder(center=c + off_dir * s, direction=d, radius=r, height=h))
3982
- 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))
3983
4382
 
3984
4383
  if bond_meshes:
3985
4384
  combined_bonds = pv.merge(bond_meshes)
3986
- 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)
3987
4386
 
3988
4387
  if getattr(self, 'show_chiral_labels', False):
3989
4388
  try:
@@ -3998,7 +4397,7 @@ class MainWindow(QMainWindow):
3998
4397
  except Exception: pass
3999
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)
4000
4399
  except Exception as e: self.statusBar().showMessage(f"3D chiral label drawing error: {e}")
4001
-
4400
+
4002
4401
  self.plotter.camera = camera_state
4003
4402
 
4004
4403
 
@@ -4084,6 +4483,8 @@ class MainWindow(QMainWindow):
4084
4483
  self.statusBar().showMessage("Please generate a 3D structure first to show analysis.")
4085
4484
 
4086
4485
  def closeEvent(self, event):
4486
+ if self.settings != self.initial_settings:
4487
+ self.save_settings()
4087
4488
  reply = QMessageBox.question(self, 'Confirm Exit',
4088
4489
  "Are you sure you want to exit?",
4089
4490
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4152,36 +4553,170 @@ class MainWindow(QMainWindow):
4152
4553
  style = CustomInteractorStyle(self)
4153
4554
 
4154
4555
  # 調査の結果、'style' プロパティへの代入が正しい設定方法と判明
4155
- self.plotter.iren.style = style
4556
+ self.plotter.interactor.SetInteractorStyle(style)
4557
+ self.plotter.interactor.Initialize()
4156
4558
 
4157
- # 新しいスタイルを確実に読み込ませるためにインタラクタを初期化
4158
- self.plotter.iren.initialize()
4159
-
4160
4559
  def dragEnterEvent(self, event):
4161
- """ファイルがウィンドウ上にドラッグされたときに呼び出される"""
4162
- # ドラッグされたデータにファイルパスが含まれているかチェック
4560
+ """ウィンドウ全体で .pmeraw ファイルのドラッグのみを受け入れる"""
4163
4561
  if event.mimeData().hasUrls():
4164
- event.acceptProposedAction() # 受け入れ可能であることをカーソルで示す
4165
- else:
4166
- 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()
4167
4569
 
4168
4570
  def dropEvent(self, event):
4169
4571
  """ファイルがウィンドウ上でドロップされたときに呼び出される"""
4170
4572
  urls = event.mimeData().urls()
4171
- # ドロップされたのがローカルファイルの場合
4172
4573
  if urls and urls[0].isLocalFile():
4173
4574
  file_path = urls[0].toLocalFile()
4174
4575
 
4175
- # ファイルの拡張子が .pmeraw であることを確認
4576
+ # 拡張子に応じて適切な読み込みメソッドを呼び出す
4176
4577
  if file_path.lower().endswith('.pmeraw'):
4177
- # 既存のファイル読み込み関数を呼び出す
4178
4578
  self.load_raw_data(file_path=file_path)
4179
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()
4180
4588
  else:
4181
4589
  event.ignore()
4182
4590
  else:
4183
4591
  event.ignore()
4184
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
+
4185
4720
 
4186
4721
  # --- Application Execution ---
4187
4722
  if __name__ == '__main__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleditpy
3
- Version: 1.3.2
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