MoleditPy-linux 2.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
MoleditPy — A Python-based molecular editing software
|
|
6
|
+
|
|
7
|
+
Author: Hiromichi Yokoyama
|
|
8
|
+
License: GPL-3.0 license
|
|
9
|
+
Repo: https://github.com/HiroYokoyama/python_molecular_editor
|
|
10
|
+
DOI: 10.5281/zenodo.17268532
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
main_window_export.py
|
|
15
|
+
MainWindow (main_window.py) から分離されたモジュール
|
|
16
|
+
機能クラス: MainWindowExport
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# RDKit imports (explicit to satisfy flake8 and used features)
|
|
26
|
+
try:
|
|
27
|
+
pass
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
# PyQt6 Modules
|
|
32
|
+
from PyQt6.QtWidgets import (
|
|
33
|
+
QApplication, QFileDialog, QMessageBox
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from PyQt6.QtGui import (
|
|
37
|
+
QBrush, QColor, QPainter, QImage
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from PyQt6.QtSvg import QSvgGenerator
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
from PyQt6.QtCore import (
|
|
44
|
+
Qt, QRectF, QSize
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
import pyvista as pv
|
|
48
|
+
|
|
49
|
+
# Use centralized Open Babel availability from package-level __init__
|
|
50
|
+
# Use per-package modules availability (local __init__).
|
|
51
|
+
try:
|
|
52
|
+
from . import OBABEL_AVAILABLE
|
|
53
|
+
except Exception:
|
|
54
|
+
from modules import OBABEL_AVAILABLE
|
|
55
|
+
# Only import pybel on demand — `moleditpy` itself doesn't expose `pybel`.
|
|
56
|
+
if OBABEL_AVAILABLE:
|
|
57
|
+
try:
|
|
58
|
+
from openbabel import pybel
|
|
59
|
+
except Exception:
|
|
60
|
+
# If import fails here, disable OBABEL locally; avoid raising
|
|
61
|
+
pybel = None
|
|
62
|
+
OBABEL_AVAILABLE = False
|
|
63
|
+
print("Warning: openbabel.pybel not available. Open Babel fallback and OBabel-based options will be disabled.")
|
|
64
|
+
else:
|
|
65
|
+
pybel = None
|
|
66
|
+
|
|
67
|
+
# Optional SIP helper: on some PyQt6 builds sip.isdeleted is available and
|
|
68
|
+
# allows safely detecting C++ wrapper objects that have been deleted. Import
|
|
69
|
+
# it once at module import time and expose a small, robust wrapper so callers
|
|
70
|
+
# can avoid re-importing sip repeatedly and so we centralize exception
|
|
71
|
+
# handling (this reduces crash risk during teardown and deletion operations).
|
|
72
|
+
try:
|
|
73
|
+
import sip as _sip # type: ignore
|
|
74
|
+
_sip_isdeleted = getattr(_sip, 'isdeleted', None)
|
|
75
|
+
except Exception:
|
|
76
|
+
_sip = None
|
|
77
|
+
_sip_isdeleted = None
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
# package relative imports (preferred when running as `python -m moleditpy`)
|
|
81
|
+
from .atom_item import AtomItem
|
|
82
|
+
from .bond_item import BondItem
|
|
83
|
+
except Exception:
|
|
84
|
+
# Fallback to absolute imports for script-style execution
|
|
85
|
+
from modules.atom_item import AtomItem
|
|
86
|
+
from modules.bond_item import BondItem
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- クラス定義 ---
|
|
90
|
+
class MainWindowExport(object):
|
|
91
|
+
""" main_window.py から分離された機能クラス """
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def export_stl(self):
|
|
95
|
+
"""STLファイルとしてエクスポート(色なし)"""
|
|
96
|
+
if not self.current_mol:
|
|
97
|
+
self.statusBar().showMessage("Error: Please generate a 3D structure first.")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# prefer same directory as current file when available
|
|
101
|
+
default_dir = ""
|
|
102
|
+
try:
|
|
103
|
+
if self.current_file_path:
|
|
104
|
+
default_dir = os.path.dirname(self.current_file_path)
|
|
105
|
+
except Exception:
|
|
106
|
+
default_dir = ""
|
|
107
|
+
|
|
108
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
109
|
+
self, "Export as STL", default_dir, "STL Files (*.stl);;All Files (*)"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not file_path:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
|
|
117
|
+
# 3Dビューから直接データを取得(色情報なし)
|
|
118
|
+
combined_mesh = self.export_from_3d_view_no_color()
|
|
119
|
+
|
|
120
|
+
if combined_mesh is None or combined_mesh.n_points == 0:
|
|
121
|
+
self.statusBar().showMessage("No 3D geometry to export.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if not file_path.lower().endswith('.stl'):
|
|
125
|
+
file_path += '.stl'
|
|
126
|
+
|
|
127
|
+
combined_mesh.save(file_path, binary=True)
|
|
128
|
+
self.statusBar().showMessage(f"STL exported to {file_path}")
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
self.statusBar().showMessage(f"Error exporting STL: {e}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def export_obj_mtl(self):
|
|
136
|
+
"""OBJ/MTLファイルとしてエクスポート(表示中のモデルベース、色付き)"""
|
|
137
|
+
if not self.current_mol:
|
|
138
|
+
self.statusBar().showMessage("Error: Please generate a 3D structure first.")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# prefer same directory as current file when available
|
|
142
|
+
default_dir = ""
|
|
143
|
+
try:
|
|
144
|
+
if self.current_file_path:
|
|
145
|
+
default_dir = os.path.dirname(self.current_file_path)
|
|
146
|
+
except Exception:
|
|
147
|
+
default_dir = ""
|
|
148
|
+
|
|
149
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
150
|
+
self, "Export as OBJ/MTL (with colors)", default_dir, "OBJ Files (*.obj);;All Files (*)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if not file_path:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
|
|
158
|
+
# 3Dビューから表示中のメッシュデータを色情報とともに取得
|
|
159
|
+
meshes_with_colors = self.export_from_3d_view_with_colors()
|
|
160
|
+
|
|
161
|
+
if not meshes_with_colors:
|
|
162
|
+
self.statusBar().showMessage("No 3D geometry to export.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# ファイル拡張子を確認・追加
|
|
166
|
+
if not file_path.lower().endswith('.obj'):
|
|
167
|
+
file_path += '.obj'
|
|
168
|
+
|
|
169
|
+
# OBJ+MTL形式で保存(オブジェクトごとに色分け)
|
|
170
|
+
mtl_path = file_path.replace('.obj', '.mtl')
|
|
171
|
+
|
|
172
|
+
self.create_multi_material_obj(meshes_with_colors, file_path, mtl_path)
|
|
173
|
+
|
|
174
|
+
self.statusBar().showMessage(f"OBJ+MTL files with individual colors exported to {file_path} and {mtl_path}")
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
self.statusBar().showMessage(f"Error exporting OBJ/MTL: {e}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_multi_material_obj(self, meshes_with_colors, obj_path, mtl_path):
|
|
182
|
+
"""複数のマテリアルを持つOBJファイルとMTLファイルを作成(改良版)"""
|
|
183
|
+
try:
|
|
184
|
+
|
|
185
|
+
# MTLファイルを作成
|
|
186
|
+
with open(mtl_path, 'w') as mtl_file:
|
|
187
|
+
mtl_file.write(f"# Material file for {os.path.basename(obj_path)}\n")
|
|
188
|
+
mtl_file.write("# Generated with individual object colors\n\n")
|
|
189
|
+
|
|
190
|
+
for i, mesh_data in enumerate(meshes_with_colors):
|
|
191
|
+
color = mesh_data['color']
|
|
192
|
+
material_name = f"material_{i}_{mesh_data['name'].replace(' ', '_')}"
|
|
193
|
+
|
|
194
|
+
mtl_file.write(f"newmtl {material_name}\n")
|
|
195
|
+
mtl_file.write(f"Ka 0.2 0.2 0.2\n") # Ambient
|
|
196
|
+
mtl_file.write(f"Kd {color[0]/255.0:.3f} {color[1]/255.0:.3f} {color[2]/255.0:.3f}\n") # Diffuse
|
|
197
|
+
mtl_file.write(f"Ks 0.5 0.5 0.5\n") # Specular
|
|
198
|
+
mtl_file.write(f"Ns 32.0\n") # Specular exponent
|
|
199
|
+
mtl_file.write(f"illum 2\n") # Illumination model
|
|
200
|
+
mtl_file.write(f"\n")
|
|
201
|
+
|
|
202
|
+
# OBJファイルを作成
|
|
203
|
+
with open(obj_path, 'w') as obj_file:
|
|
204
|
+
obj_file.write(f"# OBJ file with multiple materials\n")
|
|
205
|
+
obj_file.write(f"# Generated with individual object colors\n")
|
|
206
|
+
obj_file.write(f"mtllib {os.path.basename(mtl_path)}\n\n")
|
|
207
|
+
|
|
208
|
+
vertex_offset = 1 # OBJファイルの頂点インデックスは1から始まる
|
|
209
|
+
|
|
210
|
+
for i, mesh_data in enumerate(meshes_with_colors):
|
|
211
|
+
mesh = mesh_data['mesh']
|
|
212
|
+
material_name = f"material_{i}_{mesh_data['name'].replace(' ', '_')}"
|
|
213
|
+
|
|
214
|
+
obj_file.write(f"# Object {i}: {mesh_data['name']}\n")
|
|
215
|
+
obj_file.write(f"# Color: RGB({mesh_data['color'][0]}, {mesh_data['color'][1]}, {mesh_data['color'][2]})\n")
|
|
216
|
+
obj_file.write(f"o object_{i}\n")
|
|
217
|
+
obj_file.write(f"usemtl {material_name}\n")
|
|
218
|
+
|
|
219
|
+
# 頂点を書き込み
|
|
220
|
+
points = mesh.points
|
|
221
|
+
for point in points:
|
|
222
|
+
obj_file.write(f"v {point[0]:.6f} {point[1]:.6f} {point[2]:.6f}\n")
|
|
223
|
+
|
|
224
|
+
# 面を書き込み
|
|
225
|
+
faces_written = 0
|
|
226
|
+
for j in range(mesh.n_cells):
|
|
227
|
+
cell = mesh.get_cell(j)
|
|
228
|
+
if cell.type == 5: # VTK_TRIANGLE
|
|
229
|
+
points_in_cell = cell.point_ids
|
|
230
|
+
v1 = points_in_cell[0] + vertex_offset
|
|
231
|
+
v2 = points_in_cell[1] + vertex_offset
|
|
232
|
+
v3 = points_in_cell[2] + vertex_offset
|
|
233
|
+
obj_file.write(f"f {v1} {v2} {v3}\n")
|
|
234
|
+
faces_written += 1
|
|
235
|
+
elif cell.type == 6: # VTK_TRIANGLE_STRIP
|
|
236
|
+
# Triangle strips share vertices between adjacent triangles
|
|
237
|
+
# For n points, we get (n-2) triangles
|
|
238
|
+
points_in_cell = cell.point_ids
|
|
239
|
+
n_points = len(points_in_cell)
|
|
240
|
+
for k in range(n_points - 2):
|
|
241
|
+
if k % 2 == 0:
|
|
242
|
+
# Even triangles: use points k, k+1, k+2
|
|
243
|
+
v1 = points_in_cell[k] + vertex_offset
|
|
244
|
+
v2 = points_in_cell[k+1] + vertex_offset
|
|
245
|
+
v3 = points_in_cell[k+2] + vertex_offset
|
|
246
|
+
else:
|
|
247
|
+
# Odd triangles: reverse winding to maintain consistent orientation
|
|
248
|
+
v1 = points_in_cell[k+1] + vertex_offset
|
|
249
|
+
v2 = points_in_cell[k] + vertex_offset
|
|
250
|
+
v3 = points_in_cell[k+2] + vertex_offset
|
|
251
|
+
obj_file.write(f"f {v1} {v2} {v3}\n")
|
|
252
|
+
faces_written += 1
|
|
253
|
+
elif cell.type == 9: # VTK_QUAD
|
|
254
|
+
points_in_cell = cell.point_ids
|
|
255
|
+
v1 = points_in_cell[0] + vertex_offset
|
|
256
|
+
v2 = points_in_cell[1] + vertex_offset
|
|
257
|
+
v3 = points_in_cell[2] + vertex_offset
|
|
258
|
+
v4 = points_in_cell[3] + vertex_offset
|
|
259
|
+
obj_file.write(f"f {v1} {v2} {v3} {v4}\n")
|
|
260
|
+
faces_written += 1
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
vertex_offset += mesh.n_points
|
|
264
|
+
obj_file.write(f"\n")
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
raise Exception(f"Failed to create multi-material OBJ: {e}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def export_color_stl(self):
|
|
272
|
+
"""カラーSTLファイルとしてエクスポート"""
|
|
273
|
+
if not self.current_mol:
|
|
274
|
+
self.statusBar().showMessage("Error: Please generate a 3D structure first.")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# prefer same directory as current file when available
|
|
278
|
+
default_dir = ""
|
|
279
|
+
try:
|
|
280
|
+
if self.current_file_path:
|
|
281
|
+
default_dir = os.path.dirname(self.current_file_path)
|
|
282
|
+
except Exception:
|
|
283
|
+
default_dir = ""
|
|
284
|
+
|
|
285
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
286
|
+
self, "Export as Color STL", default_dir, "STL Files (*.stl);;All Files (*)"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if not file_path:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
|
|
294
|
+
# 3Dビューから直接データを取得
|
|
295
|
+
combined_mesh = self.export_from_3d_view()
|
|
296
|
+
|
|
297
|
+
if combined_mesh is None or combined_mesh.n_points == 0:
|
|
298
|
+
self.statusBar().showMessage("No 3D geometry to export.")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# STL形式で保存
|
|
302
|
+
if not file_path.lower().endswith('.stl'):
|
|
303
|
+
file_path += '.stl'
|
|
304
|
+
combined_mesh.save(file_path, binary=True)
|
|
305
|
+
self.statusBar().showMessage(f"STL exported to {file_path}")
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
self.statusBar().showMessage(f"Error exporting STL: {e}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def export_from_3d_view(self):
|
|
313
|
+
"""現在の3Dビューから直接メッシュデータを取得"""
|
|
314
|
+
try:
|
|
315
|
+
|
|
316
|
+
# PyVistaプロッターから全てのアクターを取得
|
|
317
|
+
combined_mesh = pv.PolyData()
|
|
318
|
+
|
|
319
|
+
# プロッターのレンダラーからアクターを取得
|
|
320
|
+
renderer = self.plotter.renderer
|
|
321
|
+
actors = renderer.actors
|
|
322
|
+
|
|
323
|
+
for actor_name, actor in actors.items():
|
|
324
|
+
try:
|
|
325
|
+
# VTKアクターからポリデータを取得する複数の方法を試行
|
|
326
|
+
mesh = None
|
|
327
|
+
|
|
328
|
+
# 方法1: mapperのinputから取得 (Improved)
|
|
329
|
+
mapper = None
|
|
330
|
+
if hasattr(actor, 'mapper') and actor.mapper is not None:
|
|
331
|
+
mapper = actor.mapper
|
|
332
|
+
elif hasattr(actor, 'GetMapper'):
|
|
333
|
+
mapper = actor.GetMapper()
|
|
334
|
+
|
|
335
|
+
if mapper is not None:
|
|
336
|
+
if hasattr(mapper, 'input') and mapper.input is not None:
|
|
337
|
+
mesh = mapper.input
|
|
338
|
+
elif hasattr(mapper, 'GetInput') and mapper.GetInput() is not None:
|
|
339
|
+
mesh = mapper.GetInput()
|
|
340
|
+
elif hasattr(mapper, 'GetInputAsDataSet'):
|
|
341
|
+
mesh = mapper.GetInputAsDataSet()
|
|
342
|
+
|
|
343
|
+
# 方法2: PyVistaプロッターの内部データから取得
|
|
344
|
+
if mesh is None and actor_name in self.plotter.mesh:
|
|
345
|
+
mesh = self.plotter.mesh[actor_name]
|
|
346
|
+
|
|
347
|
+
if mesh is not None and hasattr(mesh, 'n_points') and mesh.n_points > 0:
|
|
348
|
+
# PyVistaメッシュに変換(必要な場合)
|
|
349
|
+
if not isinstance(mesh, pv.PolyData):
|
|
350
|
+
if hasattr(mesh, 'extract_surface'):
|
|
351
|
+
mesh = mesh.extract_surface()
|
|
352
|
+
else:
|
|
353
|
+
mesh = pv.wrap(mesh)
|
|
354
|
+
|
|
355
|
+
# 元のメッシュを変更しないようにコピーを作成
|
|
356
|
+
mesh_copy = mesh.copy()
|
|
357
|
+
|
|
358
|
+
# コピーしたメッシュにカラー情報を追加
|
|
359
|
+
if hasattr(actor, 'prop') and hasattr(actor.prop, 'color'):
|
|
360
|
+
color = actor.prop.color
|
|
361
|
+
# RGB値を0-255の範囲に変換
|
|
362
|
+
rgb = np.array([int(c * 255) for c in color], dtype=np.uint8)
|
|
363
|
+
|
|
364
|
+
# Blender対応のPLY形式用カラー属性を設定
|
|
365
|
+
mesh_copy.point_data['diffuse_red'] = np.full(mesh_copy.n_points, rgb[0], dtype=np.uint8)
|
|
366
|
+
mesh_copy.point_data['diffuse_green'] = np.full(mesh_copy.n_points, rgb[1], dtype=np.uint8)
|
|
367
|
+
mesh_copy.point_data['diffuse_blue'] = np.full(mesh_copy.n_points, rgb[2], dtype=np.uint8)
|
|
368
|
+
|
|
369
|
+
# 標準的なPLY形式もサポート
|
|
370
|
+
mesh_copy.point_data['red'] = np.full(mesh_copy.n_points, rgb[0], dtype=np.uint8)
|
|
371
|
+
mesh_copy.point_data['green'] = np.full(mesh_copy.n_points, rgb[1], dtype=np.uint8)
|
|
372
|
+
mesh_copy.point_data['blue'] = np.full(mesh_copy.n_points, rgb[2], dtype=np.uint8)
|
|
373
|
+
|
|
374
|
+
# 従来の colors 配列も保持(STL用)
|
|
375
|
+
mesh_colors = np.tile(rgb, (mesh_copy.n_points, 1))
|
|
376
|
+
mesh_copy.point_data['colors'] = mesh_colors
|
|
377
|
+
|
|
378
|
+
# メッシュを結合
|
|
379
|
+
if combined_mesh.n_points == 0:
|
|
380
|
+
combined_mesh = mesh_copy.copy()
|
|
381
|
+
else:
|
|
382
|
+
combined_mesh = combined_mesh.merge(mesh_copy)
|
|
383
|
+
|
|
384
|
+
except Exception:
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
return combined_mesh
|
|
388
|
+
|
|
389
|
+
except Exception:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def export_from_3d_view_no_color(self):
|
|
395
|
+
"""現在の3Dビューから直接メッシュデータを取得(色情報なし)"""
|
|
396
|
+
try:
|
|
397
|
+
|
|
398
|
+
# PyVistaプロッターから全てのアクターを取得
|
|
399
|
+
combined_mesh = pv.PolyData()
|
|
400
|
+
|
|
401
|
+
# プロッターのレンダラーからアクターを取得
|
|
402
|
+
renderer = self.plotter.renderer
|
|
403
|
+
actors = renderer.actors
|
|
404
|
+
|
|
405
|
+
for actor_name, actor in actors.items():
|
|
406
|
+
try:
|
|
407
|
+
# VTKアクターからポリデータを取得する複数の方法を試行
|
|
408
|
+
mesh = None
|
|
409
|
+
|
|
410
|
+
# 方法1: mapperのinputから取得 (Improved)
|
|
411
|
+
mapper = None
|
|
412
|
+
if hasattr(actor, 'mapper') and actor.mapper is not None:
|
|
413
|
+
mapper = actor.mapper
|
|
414
|
+
elif hasattr(actor, 'GetMapper'):
|
|
415
|
+
mapper = actor.GetMapper()
|
|
416
|
+
|
|
417
|
+
if mapper is not None:
|
|
418
|
+
if hasattr(mapper, 'input') and mapper.input is not None:
|
|
419
|
+
mesh = mapper.input
|
|
420
|
+
elif hasattr(mapper, 'GetInput') and mapper.GetInput() is not None:
|
|
421
|
+
mesh = mapper.GetInput()
|
|
422
|
+
elif hasattr(mapper, 'GetInputAsDataSet'):
|
|
423
|
+
mesh = mapper.GetInputAsDataSet()
|
|
424
|
+
|
|
425
|
+
# 方法2: PyVistaプロッターの内部データから取得
|
|
426
|
+
if mesh is None and actor_name in self.plotter.mesh:
|
|
427
|
+
mesh = self.plotter.mesh[actor_name]
|
|
428
|
+
|
|
429
|
+
# 方法3: Removed unsafe fallback
|
|
430
|
+
|
|
431
|
+
if mesh is not None and hasattr(mesh, 'n_points') and mesh.n_points > 0:
|
|
432
|
+
# PyVistaメッシュに変換(必要な場合)
|
|
433
|
+
if not isinstance(mesh, pv.PolyData):
|
|
434
|
+
if hasattr(mesh, 'extract_surface'):
|
|
435
|
+
mesh = mesh.extract_surface()
|
|
436
|
+
else:
|
|
437
|
+
mesh = pv.wrap(mesh)
|
|
438
|
+
|
|
439
|
+
# 元のメッシュを変更しないようにコピーを作成(色情報は追加しない)
|
|
440
|
+
mesh_copy = mesh.copy()
|
|
441
|
+
|
|
442
|
+
# メッシュを結合
|
|
443
|
+
if combined_mesh.n_points == 0:
|
|
444
|
+
combined_mesh = mesh_copy.copy()
|
|
445
|
+
else:
|
|
446
|
+
combined_mesh = combined_mesh.merge(mesh_copy)
|
|
447
|
+
|
|
448
|
+
except Exception:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
return combined_mesh
|
|
452
|
+
|
|
453
|
+
except Exception:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def export_from_3d_view_with_colors(self):
|
|
459
|
+
"""現在の3Dビューから直接メッシュデータを色情報とともに取得"""
|
|
460
|
+
try:
|
|
461
|
+
|
|
462
|
+
meshes_with_colors = []
|
|
463
|
+
|
|
464
|
+
# PyVistaプロッターから全てのアクターを取得
|
|
465
|
+
renderer = self.plotter.renderer
|
|
466
|
+
actors = renderer.actors
|
|
467
|
+
|
|
468
|
+
actor_count = 0
|
|
469
|
+
|
|
470
|
+
for actor_name, actor in actors.items():
|
|
471
|
+
try:
|
|
472
|
+
# VTKアクターからポリデータを取得
|
|
473
|
+
mesh = None
|
|
474
|
+
|
|
475
|
+
# 方法1: mapperのinputから取得 (Improved)
|
|
476
|
+
mapper = None
|
|
477
|
+
if hasattr(actor, 'mapper') and actor.mapper is not None:
|
|
478
|
+
mapper = actor.mapper
|
|
479
|
+
elif hasattr(actor, 'GetMapper'):
|
|
480
|
+
mapper = actor.GetMapper()
|
|
481
|
+
|
|
482
|
+
if mapper is not None:
|
|
483
|
+
if hasattr(mapper, 'input') and mapper.input is not None:
|
|
484
|
+
mesh = mapper.input
|
|
485
|
+
elif hasattr(mapper, 'GetInput') and mapper.GetInput() is not None:
|
|
486
|
+
mesh = mapper.GetInput()
|
|
487
|
+
elif hasattr(mapper, 'GetInputAsDataSet'):
|
|
488
|
+
mesh = mapper.GetInputAsDataSet()
|
|
489
|
+
|
|
490
|
+
# 方法2: PyVistaプロッターの内部データから取得
|
|
491
|
+
if mesh is None and actor_name in self.plotter.mesh:
|
|
492
|
+
mesh = self.plotter.mesh[actor_name]
|
|
493
|
+
|
|
494
|
+
if mesh is not None and hasattr(mesh, 'n_points') and mesh.n_points > 0:
|
|
495
|
+
# PyVistaメッシュに変換(必要な場合)
|
|
496
|
+
if not isinstance(mesh, pv.PolyData):
|
|
497
|
+
if hasattr(mesh, 'extract_surface'):
|
|
498
|
+
mesh = mesh.extract_surface()
|
|
499
|
+
else:
|
|
500
|
+
mesh = pv.wrap(mesh)
|
|
501
|
+
|
|
502
|
+
# アクターから色情報を取得
|
|
503
|
+
color = [128, 128, 128] # デフォルト色(グレー)
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
# VTKアクターのプロパティから色を取得
|
|
507
|
+
if hasattr(actor, 'prop') and actor.prop is not None:
|
|
508
|
+
vtk_color = actor.prop.GetColor()
|
|
509
|
+
color = [int(c * 255) for c in vtk_color]
|
|
510
|
+
elif hasattr(actor, 'GetProperty'):
|
|
511
|
+
prop = actor.GetProperty()
|
|
512
|
+
if prop is not None:
|
|
513
|
+
vtk_color = prop.GetColor()
|
|
514
|
+
color = [int(c * 255) for c in vtk_color]
|
|
515
|
+
except Exception:
|
|
516
|
+
# 色取得に失敗した場合はデフォルト色をそのまま使用
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
# メッシュのコピーを作成
|
|
520
|
+
mesh_copy = mesh.copy()
|
|
521
|
+
|
|
522
|
+
# もしメッシュに頂点ごとの色情報が含まれている場合、
|
|
523
|
+
# それぞれの色ごとにサブメッシュに分割して個別マテリアルを作る。
|
|
524
|
+
# これにより、glyphs(すべての原子が一つのメッシュにまとめられる場合)でも
|
|
525
|
+
# 各原子の色を保持してOBJ/MTLへ出力できる。
|
|
526
|
+
try:
|
|
527
|
+
colors = None
|
|
528
|
+
pd = mesh_copy.point_data
|
|
529
|
+
# 優先的にred/green/blue配列を使用
|
|
530
|
+
if 'red' in pd and 'green' in pd and 'blue' in pd:
|
|
531
|
+
r = np.asarray(pd['red']).reshape(-1)
|
|
532
|
+
g = np.asarray(pd['green']).reshape(-1)
|
|
533
|
+
b = np.asarray(pd['blue']).reshape(-1)
|
|
534
|
+
colors = np.vstack([r, g, b]).T
|
|
535
|
+
# diffuse_* のキーもサポート
|
|
536
|
+
elif 'diffuse_red' in pd and 'diffuse_green' in pd and 'diffuse_blue' in pd:
|
|
537
|
+
r = np.asarray(pd['diffuse_red']).reshape(-1)
|
|
538
|
+
g = np.asarray(pd['diffuse_green']).reshape(-1)
|
|
539
|
+
b = np.asarray(pd['diffuse_blue']).reshape(-1)
|
|
540
|
+
colors = np.vstack([r, g, b]).T
|
|
541
|
+
# 単一の colors 配列があればそれを使う
|
|
542
|
+
elif 'colors' in pd:
|
|
543
|
+
colors = np.asarray(pd['colors'])
|
|
544
|
+
|
|
545
|
+
# cell_dataのcolorsも確認(Tubeフィルタなどはcell_dataに色を持つ場合がある)
|
|
546
|
+
if colors is None and 'colors' in mesh_copy.cell_data:
|
|
547
|
+
try:
|
|
548
|
+
# cell_dataをpoint_dataに変換
|
|
549
|
+
temp_mesh = mesh_copy.cell_data_to_point_data()
|
|
550
|
+
if 'colors' in temp_mesh.point_data:
|
|
551
|
+
colors = np.asarray(temp_mesh.point_data['colors'])
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
if colors is not None and colors.size > 0:
|
|
556
|
+
# 整数に変換。colors が 0-1 の float の場合は 255 倍して正規化する。
|
|
557
|
+
colors_arr = np.asarray(colors)
|
|
558
|
+
# 期待形状に整形
|
|
559
|
+
if colors_arr.ndim == 1:
|
|
560
|
+
# 1次元の場合は単一チャンネルとして扱う
|
|
561
|
+
colors_arr = colors_arr.reshape(-1, 1)
|
|
562
|
+
|
|
563
|
+
# float かどうか判定して正規化
|
|
564
|
+
if np.issubdtype(colors_arr.dtype, np.floating):
|
|
565
|
+
# 値の最大が1付近なら0-1レンジとみなして255倍
|
|
566
|
+
if colors_arr.max() <= 1.01:
|
|
567
|
+
colors_int = np.clip((colors_arr * 255.0).round(), 0, 255).astype(np.int32)
|
|
568
|
+
else:
|
|
569
|
+
# 既に0-255レンジのfloatならそのまま丸める
|
|
570
|
+
colors_int = np.clip(colors_arr.round(), 0, 255).astype(np.int32)
|
|
571
|
+
else:
|
|
572
|
+
colors_int = np.clip(colors_arr, 0, 255).astype(np.int32)
|
|
573
|
+
# Ensure shape is (n_points, 3)
|
|
574
|
+
if colors_int.ndim == 1:
|
|
575
|
+
# 単一値が入っている場合は同一RGBとして扱う
|
|
576
|
+
colors_int = np.vstack([colors_int, colors_int, colors_int]).T
|
|
577
|
+
|
|
578
|
+
# 一意な色ごとにサブメッシュを抽出して追加
|
|
579
|
+
unique_colors, inverse = np.unique(colors_int, axis=0, return_inverse=True)
|
|
580
|
+
|
|
581
|
+
split_success = False
|
|
582
|
+
if unique_colors.shape[0] > 1:
|
|
583
|
+
for uc_idx, uc in enumerate(unique_colors):
|
|
584
|
+
point_inds = np.where(inverse == uc_idx)[0]
|
|
585
|
+
if point_inds.size == 0:
|
|
586
|
+
continue
|
|
587
|
+
try:
|
|
588
|
+
# Use temp_mesh if available (has point data), else mesh_copy
|
|
589
|
+
target_mesh = temp_mesh if 'temp_mesh' in locals() else mesh_copy
|
|
590
|
+
|
|
591
|
+
# extract_points with adjacent_cells=False to avoid pulling in neighbors
|
|
592
|
+
submesh = target_mesh.extract_points(point_inds, adjacent_cells=False)
|
|
593
|
+
|
|
594
|
+
except Exception:
|
|
595
|
+
# extract_points が利用できない場合はスキップ
|
|
596
|
+
continue
|
|
597
|
+
if submesh is None or getattr(submesh, 'n_points', 0) == 0:
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
color_rgb = [int(uc[0]), int(uc[1]), int(uc[2])]
|
|
601
|
+
meshes_with_colors.append({
|
|
602
|
+
'mesh': submesh,
|
|
603
|
+
'color': color_rgb,
|
|
604
|
+
'name': f'{actor_name}_color_{uc_idx}',
|
|
605
|
+
'type': 'display_actor',
|
|
606
|
+
'actor_name': actor_name
|
|
607
|
+
})
|
|
608
|
+
split_success = True
|
|
609
|
+
|
|
610
|
+
if split_success:
|
|
611
|
+
actor_count += 1
|
|
612
|
+
# 分割に成功したので以下の通常追加は行わない
|
|
613
|
+
continue
|
|
614
|
+
# If splitting failed (no submeshes added), fall through to default
|
|
615
|
+
else:
|
|
616
|
+
# 色が1色のみの場合は、その色を使用してメッシュ全体を出力
|
|
617
|
+
uc = unique_colors[0]
|
|
618
|
+
color = [int(uc[0]), int(uc[1]), int(uc[2])]
|
|
619
|
+
# ここでは continue せず、下のデフォルト追加処理に任せる(colorを更新したため)
|
|
620
|
+
except Exception:
|
|
621
|
+
# 分割処理に失敗した場合はフォールバックで単体メッシュを追加
|
|
622
|
+
pass
|
|
623
|
+
|
|
624
|
+
meshes_with_colors.append({
|
|
625
|
+
'mesh': mesh_copy,
|
|
626
|
+
'color': color,
|
|
627
|
+
'name': f'actor_{actor_count}_{actor_name}',
|
|
628
|
+
'type': 'display_actor',
|
|
629
|
+
'actor_name': actor_name
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
actor_count += 1
|
|
633
|
+
|
|
634
|
+
except Exception as e:
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
return meshes_with_colors
|
|
639
|
+
|
|
640
|
+
except Exception as e:
|
|
641
|
+
print(f"Error in export_from_3d_view_with_colors: {e}")
|
|
642
|
+
return []
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def export_2d_png(self):
|
|
647
|
+
if not self.data.atoms:
|
|
648
|
+
self.statusBar().showMessage("Nothing to export.")
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
# default filename: based on current file, append -2d for 2D exports
|
|
652
|
+
default_name = "untitled-2d"
|
|
653
|
+
try:
|
|
654
|
+
if self.current_file_path:
|
|
655
|
+
base = os.path.basename(self.current_file_path)
|
|
656
|
+
name = os.path.splitext(base)[0]
|
|
657
|
+
default_name = f"{name}-2d"
|
|
658
|
+
except Exception:
|
|
659
|
+
default_name = "untitled-2d"
|
|
660
|
+
|
|
661
|
+
# prefer same directory as current file when available
|
|
662
|
+
default_path = default_name
|
|
663
|
+
try:
|
|
664
|
+
if self.current_file_path:
|
|
665
|
+
default_path = os.path.join(os.path.dirname(self.current_file_path), default_name)
|
|
666
|
+
except Exception:
|
|
667
|
+
default_path = default_name
|
|
668
|
+
|
|
669
|
+
filePath, _ = QFileDialog.getSaveFileName(self, "Export 2D as PNG", default_path, "PNG Files (*.png)")
|
|
670
|
+
if not filePath:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
if not (filePath.lower().endswith(".png")):
|
|
674
|
+
filePath += ".png"
|
|
675
|
+
|
|
676
|
+
reply = QMessageBox.question(self, 'Choose Background',
|
|
677
|
+
'Do you want a transparent background?\n(Choose "No" to use the current background color)',
|
|
678
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
|
|
679
|
+
QMessageBox.StandardButton.Yes)
|
|
680
|
+
|
|
681
|
+
if reply == QMessageBox.StandardButton.Cancel:
|
|
682
|
+
self.statusBar().showMessage("Export cancelled.", 2000)
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
is_transparent = (reply == QMessageBox.StandardButton.Yes)
|
|
686
|
+
|
|
687
|
+
QApplication.processEvents()
|
|
688
|
+
|
|
689
|
+
items_to_restore = {}
|
|
690
|
+
original_background = self.scene.backgroundBrush()
|
|
691
|
+
|
|
692
|
+
try:
|
|
693
|
+
all_items = list(self.scene.items())
|
|
694
|
+
for item in all_items:
|
|
695
|
+
is_mol_part = isinstance(item, (AtomItem, BondItem))
|
|
696
|
+
if not (is_mol_part and item.isVisible()):
|
|
697
|
+
items_to_restore[item] = item.isVisible()
|
|
698
|
+
item.hide()
|
|
699
|
+
|
|
700
|
+
molecule_bounds = QRectF()
|
|
701
|
+
for item in self.scene.items():
|
|
702
|
+
if isinstance(item, (AtomItem, BondItem)) and item.isVisible():
|
|
703
|
+
molecule_bounds = molecule_bounds.united(item.sceneBoundingRect())
|
|
704
|
+
|
|
705
|
+
if molecule_bounds.isEmpty() or not molecule_bounds.isValid():
|
|
706
|
+
self.statusBar().showMessage("Error: Could not determine molecule bounds for export.")
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
if is_transparent:
|
|
710
|
+
self.scene.setBackgroundBrush(QBrush(Qt.BrushStyle.NoBrush))
|
|
711
|
+
# Else: keep original_background (current 2D background)
|
|
712
|
+
|
|
713
|
+
rect_to_render = molecule_bounds.adjusted(-20, -20, 20, 20)
|
|
714
|
+
|
|
715
|
+
w = max(1, int(math.ceil(rect_to_render.width())))
|
|
716
|
+
h = max(1, int(math.ceil(rect_to_render.height())))
|
|
717
|
+
|
|
718
|
+
if w <= 0 or h <= 0:
|
|
719
|
+
self.statusBar().showMessage("Error: Invalid image size calculated.")
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
image = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
|
|
723
|
+
# Always fill with transparent; render will paint opaque background if present
|
|
724
|
+
image.fill(Qt.GlobalColor.transparent)
|
|
725
|
+
|
|
726
|
+
painter = QPainter()
|
|
727
|
+
ok = painter.begin(image)
|
|
728
|
+
if not ok or not painter.isActive():
|
|
729
|
+
self.statusBar().showMessage("Failed to start QPainter for image rendering.")
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
734
|
+
target_rect = QRectF(0, 0, w, h)
|
|
735
|
+
source_rect = rect_to_render
|
|
736
|
+
self.scene.render(painter, target_rect, source_rect)
|
|
737
|
+
finally:
|
|
738
|
+
painter.end()
|
|
739
|
+
|
|
740
|
+
saved = image.save(filePath, "PNG")
|
|
741
|
+
if saved:
|
|
742
|
+
self.statusBar().showMessage(f"2D view exported to {filePath}")
|
|
743
|
+
else:
|
|
744
|
+
self.statusBar().showMessage("Failed to save image. Check file path or permissions.")
|
|
745
|
+
|
|
746
|
+
except Exception as e:
|
|
747
|
+
self.statusBar().showMessage(f"An unexpected error occurred during 2D export: {e}")
|
|
748
|
+
|
|
749
|
+
finally:
|
|
750
|
+
for item, was_visible in items_to_restore.items():
|
|
751
|
+
item.setVisible(was_visible)
|
|
752
|
+
self.scene.setBackgroundBrush(original_background)
|
|
753
|
+
if self.view_2d:
|
|
754
|
+
self.view_2d.viewport().update()
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def export_2d_svg(self):
|
|
759
|
+
"""2D drawingをSVGとしてエクスポート"""
|
|
760
|
+
if not self.data.atoms:
|
|
761
|
+
self.statusBar().showMessage("Nothing to export.")
|
|
762
|
+
return
|
|
763
|
+
|
|
764
|
+
# default filename
|
|
765
|
+
default_name = "untitled-2d"
|
|
766
|
+
try:
|
|
767
|
+
if self.current_file_path:
|
|
768
|
+
base = os.path.basename(self.current_file_path)
|
|
769
|
+
name = os.path.splitext(base)[0]
|
|
770
|
+
default_name = f"{name}-2d"
|
|
771
|
+
except Exception:
|
|
772
|
+
default_name = "untitled-2d"
|
|
773
|
+
|
|
774
|
+
# prefer same directory
|
|
775
|
+
default_path = default_name
|
|
776
|
+
try:
|
|
777
|
+
if self.current_file_path:
|
|
778
|
+
default_path = os.path.join(os.path.dirname(self.current_file_path), default_name)
|
|
779
|
+
except Exception:
|
|
780
|
+
default_path = default_name
|
|
781
|
+
|
|
782
|
+
filePath, _ = QFileDialog.getSaveFileName(self, "Export 2D as SVG", default_path, "SVG Files (*.svg)")
|
|
783
|
+
if not filePath:
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
if not (filePath.lower().endswith(".svg")):
|
|
787
|
+
filePath += ".svg"
|
|
788
|
+
|
|
789
|
+
# Ask about transparency
|
|
790
|
+
reply = QMessageBox.question(self, 'Choose Background',
|
|
791
|
+
'Do you want a transparent background?\n(Choose "No" to use the current background color)',
|
|
792
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
|
|
793
|
+
QMessageBox.StandardButton.Yes)
|
|
794
|
+
|
|
795
|
+
if reply == QMessageBox.StandardButton.Cancel:
|
|
796
|
+
self.statusBar().showMessage("Export cancelled.", 2000)
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
is_transparent = (reply == QMessageBox.StandardButton.Yes)
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
# 1. Hide non-molecular items if needed (optional, keeping consistent with PNG export)
|
|
803
|
+
items_to_restore = {}
|
|
804
|
+
original_background = self.scene.backgroundBrush()
|
|
805
|
+
|
|
806
|
+
all_items = list(self.scene.items())
|
|
807
|
+
for item in all_items:
|
|
808
|
+
is_mol_part = isinstance(item, (AtomItem, BondItem))
|
|
809
|
+
if not (is_mol_part and item.isVisible()):
|
|
810
|
+
# Keep measurement items visible if they are part of the scene?
|
|
811
|
+
# For now, let's stick to hiding everything that isn't atom/bond,
|
|
812
|
+
# similar to png export logic, or we can decide to export everything visible.
|
|
813
|
+
# The PNG export hides non-atom/bond items. Let's follow that for consistency.
|
|
814
|
+
items_to_restore[item] = item.isVisible()
|
|
815
|
+
item.hide()
|
|
816
|
+
|
|
817
|
+
# 2. Calculate bounds
|
|
818
|
+
molecule_bounds = QRectF()
|
|
819
|
+
for item in self.scene.items():
|
|
820
|
+
if isinstance(item, (AtomItem, BondItem)) and item.isVisible():
|
|
821
|
+
molecule_bounds = molecule_bounds.united(item.sceneBoundingRect())
|
|
822
|
+
|
|
823
|
+
if molecule_bounds.isEmpty() or not molecule_bounds.isValid():
|
|
824
|
+
self.statusBar().showMessage("Error: Could not determine molecule bounds for export.")
|
|
825
|
+
# Restore
|
|
826
|
+
for item, was_visible in items_to_restore.items():
|
|
827
|
+
item.setVisible(was_visible)
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
if is_transparent:
|
|
831
|
+
self.scene.setBackgroundBrush(QBrush(Qt.BrushStyle.NoBrush))
|
|
832
|
+
|
|
833
|
+
# Margin
|
|
834
|
+
rect_to_render = molecule_bounds.adjusted(-20, -20, 20, 20)
|
|
835
|
+
|
|
836
|
+
width = int(rect_to_render.width())
|
|
837
|
+
height = int(rect_to_render.height())
|
|
838
|
+
|
|
839
|
+
# 3. Setup QSvgGenerator
|
|
840
|
+
generator = QSvgGenerator()
|
|
841
|
+
generator.setFileName(filePath)
|
|
842
|
+
generator.setSize(QSize(width, height))
|
|
843
|
+
generator.setViewBox(rect_to_render)
|
|
844
|
+
generator.setTitle("MoleditPy Molecule")
|
|
845
|
+
|
|
846
|
+
# 4. Render
|
|
847
|
+
painter = QPainter()
|
|
848
|
+
painter.begin(generator)
|
|
849
|
+
try:
|
|
850
|
+
self.scene.render(painter, rect_to_render, rect_to_render)
|
|
851
|
+
finally:
|
|
852
|
+
painter.end()
|
|
853
|
+
|
|
854
|
+
self.statusBar().showMessage(f"2D view exported to {filePath}")
|
|
855
|
+
|
|
856
|
+
except Exception as e:
|
|
857
|
+
self.statusBar().showMessage(f"An unexpected error occurred during SVG export: {e}")
|
|
858
|
+
|
|
859
|
+
finally:
|
|
860
|
+
# Restore
|
|
861
|
+
for item, was_visible in items_to_restore.items():
|
|
862
|
+
item.setVisible(was_visible)
|
|
863
|
+
if 'original_background' in locals():
|
|
864
|
+
self.scene.setBackgroundBrush(original_background)
|
|
865
|
+
if self.view_2d:
|
|
866
|
+
self.view_2d.viewport().update()
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def export_3d_png(self):
|
|
871
|
+
if not self.current_mol:
|
|
872
|
+
self.statusBar().showMessage("No 3D molecule to export.", 2000)
|
|
873
|
+
return
|
|
874
|
+
|
|
875
|
+
# default filename: match XYZ/MOL naming (use base name without suffix)
|
|
876
|
+
default_name = "untitled"
|
|
877
|
+
try:
|
|
878
|
+
if self.current_file_path:
|
|
879
|
+
base = os.path.basename(self.current_file_path)
|
|
880
|
+
name = os.path.splitext(base)[0]
|
|
881
|
+
default_name = f"{name}"
|
|
882
|
+
except Exception:
|
|
883
|
+
default_name = "untitled"
|
|
884
|
+
|
|
885
|
+
# prefer same directory as current file when available
|
|
886
|
+
default_path = default_name
|
|
887
|
+
try:
|
|
888
|
+
if self.current_file_path:
|
|
889
|
+
default_path = os.path.join(os.path.dirname(self.current_file_path), default_name)
|
|
890
|
+
except Exception:
|
|
891
|
+
default_path = default_name
|
|
892
|
+
|
|
893
|
+
filePath, _ = QFileDialog.getSaveFileName(self, "Export 3D as PNG", default_path, "PNG Files (*.png)")
|
|
894
|
+
if not filePath:
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
if not (filePath.lower().endswith(".png")):
|
|
898
|
+
filePath += ".png"
|
|
899
|
+
|
|
900
|
+
reply = QMessageBox.question(self, 'Choose Background',
|
|
901
|
+
'Do you want a transparent background?\n(Choose "No" for current background)',
|
|
902
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
|
|
903
|
+
QMessageBox.StandardButton.Yes)
|
|
904
|
+
|
|
905
|
+
if reply == QMessageBox.StandardButton.Cancel:
|
|
906
|
+
self.statusBar().showMessage("Export cancelled.", 2000)
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
is_transparent = (reply == QMessageBox.StandardButton.Yes)
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
self.plotter.screenshot(filePath, transparent_background=is_transparent)
|
|
913
|
+
self.statusBar().showMessage(f"3D view exported to {filePath}", 3000)
|
|
914
|
+
except Exception as e:
|
|
915
|
+
self.statusBar().showMessage(f"Error exporting 3D PNG: {e}")
|
|
916
|
+
|
|
917
|
+
|