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.
Files changed (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. 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
+