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,692 @@
|
|
|
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
|
+
from PyQt6.QtWidgets import (
|
|
14
|
+
QDialog, QVBoxLayout, QLabel, QWidget, QGridLayout, QScrollArea,
|
|
15
|
+
QHBoxLayout, QPushButton, QGraphicsScene, QInputDialog, QMessageBox
|
|
16
|
+
)
|
|
17
|
+
from PyQt6.QtGui import QPainter, QFont, QColor, QPen, QBrush
|
|
18
|
+
from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, QDateTime, QLineF
|
|
19
|
+
from .template_preview_view import TemplatePreviewView
|
|
20
|
+
try:
|
|
21
|
+
from .constants import VERSION, CPK_COLORS
|
|
22
|
+
except Exception:
|
|
23
|
+
from modules.constants import VERSION, CPK_COLORS
|
|
24
|
+
import os
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
|
|
28
|
+
class UserTemplateDialog(QDialog):
|
|
29
|
+
"""ユーザーテンプレート管理ダイアログ"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, main_window, parent=None):
|
|
32
|
+
super().__init__(parent)
|
|
33
|
+
self.main_window = main_window
|
|
34
|
+
self.user_templates = []
|
|
35
|
+
self.selected_template = None
|
|
36
|
+
self.init_ui()
|
|
37
|
+
self.load_user_templates()
|
|
38
|
+
|
|
39
|
+
def init_ui(self):
|
|
40
|
+
self.setWindowTitle("User Templates")
|
|
41
|
+
self.setModal(False) # モードレスに変更
|
|
42
|
+
self.resize(800, 600)
|
|
43
|
+
|
|
44
|
+
# ウィンドウを右上に配置
|
|
45
|
+
if self.parent():
|
|
46
|
+
parent_geometry = self.parent().geometry()
|
|
47
|
+
x = parent_geometry.right() - self.width() - 20
|
|
48
|
+
y = parent_geometry.top() + 50
|
|
49
|
+
self.move(x, y)
|
|
50
|
+
|
|
51
|
+
layout = QVBoxLayout(self)
|
|
52
|
+
|
|
53
|
+
# Instructions
|
|
54
|
+
instruction_label = QLabel("Create and manage your custom molecular templates. Click a template to use it in the editor.")
|
|
55
|
+
instruction_label.setWordWrap(True)
|
|
56
|
+
layout.addWidget(instruction_label)
|
|
57
|
+
|
|
58
|
+
# Template grid
|
|
59
|
+
self.template_widget = QWidget()
|
|
60
|
+
self.template_layout = QGridLayout(self.template_widget)
|
|
61
|
+
self.template_layout.setSpacing(10)
|
|
62
|
+
|
|
63
|
+
scroll_area = QScrollArea()
|
|
64
|
+
scroll_area.setWidget(self.template_widget)
|
|
65
|
+
scroll_area.setWidgetResizable(True)
|
|
66
|
+
scroll_area.setMinimumHeight(400)
|
|
67
|
+
layout.addWidget(scroll_area)
|
|
68
|
+
|
|
69
|
+
# Buttons
|
|
70
|
+
button_layout = QHBoxLayout()
|
|
71
|
+
|
|
72
|
+
self.save_current_button = QPushButton("Save Current 2D as Template")
|
|
73
|
+
self.save_current_button.clicked.connect(self.save_current_as_template)
|
|
74
|
+
button_layout.addWidget(self.save_current_button)
|
|
75
|
+
|
|
76
|
+
button_layout.addStretch()
|
|
77
|
+
|
|
78
|
+
self.delete_button = QPushButton("Delete Selected")
|
|
79
|
+
self.delete_button.clicked.connect(self.delete_selected_template)
|
|
80
|
+
self.delete_button.setEnabled(False)
|
|
81
|
+
button_layout.addWidget(self.delete_button)
|
|
82
|
+
|
|
83
|
+
close_button = QPushButton("Close")
|
|
84
|
+
close_button.clicked.connect(self.close)
|
|
85
|
+
button_layout.addWidget(close_button)
|
|
86
|
+
|
|
87
|
+
layout.addLayout(button_layout)
|
|
88
|
+
|
|
89
|
+
def closeEvent(self, event):
|
|
90
|
+
"""ダイアログクローズ時にモードをリセット"""
|
|
91
|
+
self.cleanup_template_mode()
|
|
92
|
+
super().closeEvent(event)
|
|
93
|
+
|
|
94
|
+
def cleanup_template_mode(self):
|
|
95
|
+
"""テンプレートモードを終了し、atom_C(炭素描画)モードに戻す (Defensive implementation)"""
|
|
96
|
+
# 1. Reset Dialog State
|
|
97
|
+
self.selected_template = None
|
|
98
|
+
if hasattr(self, 'delete_button'):
|
|
99
|
+
self.delete_button.setEnabled(False)
|
|
100
|
+
|
|
101
|
+
# 2. Reset Main Window Mode (UI/Toolbar)
|
|
102
|
+
target_mode = 'atom_C'
|
|
103
|
+
try:
|
|
104
|
+
if hasattr(self.main_window, 'set_mode_and_update_toolbar'):
|
|
105
|
+
self.main_window.set_mode_and_update_toolbar(target_mode)
|
|
106
|
+
elif hasattr(self.main_window, 'set_mode'):
|
|
107
|
+
self.main_window.set_mode(target_mode)
|
|
108
|
+
|
|
109
|
+
# Fallback: set attribute directly if methods fail/don't exist
|
|
110
|
+
if hasattr(self.main_window, 'mode'):
|
|
111
|
+
self.main_window.mode = target_mode
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logging.error(f"Error resetting main window mode: {e}")
|
|
114
|
+
|
|
115
|
+
# 3. Reset Scene State (The Source of Truth)
|
|
116
|
+
try:
|
|
117
|
+
if hasattr(self.main_window, 'scene') and self.main_window.scene:
|
|
118
|
+
scene = self.main_window.scene
|
|
119
|
+
|
|
120
|
+
# A. FORCE MODE
|
|
121
|
+
scene.mode = target_mode
|
|
122
|
+
scene.current_atom_symbol = 'C'
|
|
123
|
+
|
|
124
|
+
# B. Clear Data
|
|
125
|
+
if hasattr(scene, 'user_template_data'):
|
|
126
|
+
scene.user_template_data = None
|
|
127
|
+
if hasattr(scene, 'template_context'):
|
|
128
|
+
scene.template_context = {}
|
|
129
|
+
|
|
130
|
+
# C. Clear/Hide Preview Item
|
|
131
|
+
if hasattr(scene, 'clear_template_preview'):
|
|
132
|
+
scene.clear_template_preview()
|
|
133
|
+
|
|
134
|
+
if hasattr(scene, 'template_preview') and scene.template_preview:
|
|
135
|
+
scene.template_preview.hide()
|
|
136
|
+
|
|
137
|
+
# D. Reset Cursor & View
|
|
138
|
+
if scene.views():
|
|
139
|
+
view = scene.views()[0]
|
|
140
|
+
view.setCursor(Qt.CursorShape.CrossCursor)
|
|
141
|
+
view.viewport().update()
|
|
142
|
+
|
|
143
|
+
scene.update()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logging.error(f"Error cleaning up scene state: {e}")
|
|
146
|
+
|
|
147
|
+
def resizeEvent(self, event):
|
|
148
|
+
"""ダイアログリサイズ時にテンプレートプレビューを再フィット"""
|
|
149
|
+
super().resizeEvent(event)
|
|
150
|
+
# Delay the refit to ensure proper widget sizing
|
|
151
|
+
QTimer.singleShot(100, self.refit_all_previews)
|
|
152
|
+
|
|
153
|
+
def refit_all_previews(self):
|
|
154
|
+
"""すべてのテンプレートプレビューを再フィット"""
|
|
155
|
+
try:
|
|
156
|
+
for i in range(self.template_layout.count()):
|
|
157
|
+
item = self.template_layout.itemAt(i)
|
|
158
|
+
if item and item.widget():
|
|
159
|
+
widget = item.widget()
|
|
160
|
+
# Find the TemplatePreviewView within this widget
|
|
161
|
+
for child in widget.findChildren(TemplatePreviewView):
|
|
162
|
+
if hasattr(child, 'redraw_with_current_size'):
|
|
163
|
+
# Use redraw for better scaling adaptation
|
|
164
|
+
child.redraw_with_current_size()
|
|
165
|
+
elif hasattr(child, 'refit_view'):
|
|
166
|
+
child.refit_view()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logging.warning(f"Warning: Failed to refit template previews: {e}")
|
|
169
|
+
|
|
170
|
+
def showEvent(self, event):
|
|
171
|
+
"""ダイアログ表示時にプレビューを適切にフィット"""
|
|
172
|
+
super().showEvent(event)
|
|
173
|
+
# Ensure all previews are properly fitted when dialog becomes visible
|
|
174
|
+
QTimer.singleShot(300, self.refit_all_previews)
|
|
175
|
+
|
|
176
|
+
def get_template_directory(self):
|
|
177
|
+
"""テンプレートディレクトリのパスを取得"""
|
|
178
|
+
template_dir = os.path.join(self.main_window.settings_dir, 'user-templates')
|
|
179
|
+
if not os.path.exists(template_dir):
|
|
180
|
+
os.makedirs(template_dir)
|
|
181
|
+
return template_dir
|
|
182
|
+
|
|
183
|
+
def load_user_templates(self):
|
|
184
|
+
"""ユーザーテンプレートを読み込み"""
|
|
185
|
+
template_dir = self.get_template_directory()
|
|
186
|
+
self.user_templates.clear()
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
for filename in os.listdir(template_dir):
|
|
190
|
+
if filename.endswith('.pmetmplt'):
|
|
191
|
+
filepath = os.path.join(template_dir, filename)
|
|
192
|
+
template_data = self.load_template_file(filepath)
|
|
193
|
+
if template_data:
|
|
194
|
+
template_data['filename'] = filename
|
|
195
|
+
template_data['filepath'] = filepath
|
|
196
|
+
self.user_templates.append(template_data)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logging.error(f"Error loading user templates: {e}")
|
|
199
|
+
|
|
200
|
+
self.update_template_grid()
|
|
201
|
+
|
|
202
|
+
def load_template_file(self, filepath):
|
|
203
|
+
"""テンプレートファイルを読み込み"""
|
|
204
|
+
try:
|
|
205
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
206
|
+
return json.load(f)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logging.error(f"Error loading template file {filepath}: {e}")
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def save_template_file(self, filepath, template_data):
|
|
212
|
+
"""テンプレートファイルを保存"""
|
|
213
|
+
try:
|
|
214
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
215
|
+
json.dump(template_data, f, indent=2, ensure_ascii=False)
|
|
216
|
+
return True
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logging.error(f"Error saving template file {filepath}: {e}")
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def update_template_grid(self):
|
|
222
|
+
"""テンプレートグリッドを更新"""
|
|
223
|
+
# Clear existing widgets
|
|
224
|
+
for i in reversed(range(self.template_layout.count())):
|
|
225
|
+
self.template_layout.itemAt(i).widget().setParent(None)
|
|
226
|
+
|
|
227
|
+
# Add template previews (left-to-right, top-to-bottom ordering)
|
|
228
|
+
cols = 4
|
|
229
|
+
for i, template in enumerate(self.user_templates):
|
|
230
|
+
row = i // cols
|
|
231
|
+
col = i % cols # Left-to-right ordering
|
|
232
|
+
|
|
233
|
+
preview_widget = self.create_template_preview(template)
|
|
234
|
+
self.template_layout.addWidget(preview_widget, row, col)
|
|
235
|
+
|
|
236
|
+
# Ensure all previews are properly fitted after grid update
|
|
237
|
+
QTimer.singleShot(200, self.refit_all_previews)
|
|
238
|
+
|
|
239
|
+
def create_template_preview(self, template_data):
|
|
240
|
+
"""テンプレートプレビューウィジェットを作成"""
|
|
241
|
+
widget = QWidget()
|
|
242
|
+
widget.setFixedSize(180, 200)
|
|
243
|
+
widget.setStyleSheet("""
|
|
244
|
+
QWidget {
|
|
245
|
+
border: 2px solid #ccc;
|
|
246
|
+
border-radius: 8px;
|
|
247
|
+
background-color: white;
|
|
248
|
+
}
|
|
249
|
+
QWidget:hover {
|
|
250
|
+
border-color: #007acc;
|
|
251
|
+
background-color: #f0f8ff;
|
|
252
|
+
}
|
|
253
|
+
""")
|
|
254
|
+
|
|
255
|
+
layout = QVBoxLayout(widget)
|
|
256
|
+
|
|
257
|
+
# Preview graphics - use custom view class for better resize handling
|
|
258
|
+
preview_scene = QGraphicsScene()
|
|
259
|
+
preview_view = TemplatePreviewView(preview_scene)
|
|
260
|
+
preview_view.setFixedSize(160, 140)
|
|
261
|
+
preview_view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
262
|
+
preview_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
263
|
+
preview_view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
264
|
+
|
|
265
|
+
# Set template data for dynamic redrawing
|
|
266
|
+
preview_view.set_template_data(template_data, self)
|
|
267
|
+
|
|
268
|
+
# Draw template structure with view size for proper scaling
|
|
269
|
+
view_size = (preview_view.width(), preview_view.height())
|
|
270
|
+
self.draw_template_preview(preview_scene, template_data, view_size)
|
|
271
|
+
|
|
272
|
+
# Improved fitting approach with better error handling
|
|
273
|
+
bounding_rect = preview_scene.itemsBoundingRect()
|
|
274
|
+
if not bounding_rect.isEmpty() and bounding_rect.width() > 0 and bounding_rect.height() > 0:
|
|
275
|
+
# Calculate appropriate padding based on content size
|
|
276
|
+
content_size = max(bounding_rect.width(), bounding_rect.height())
|
|
277
|
+
padding = max(20, content_size * 0.2) # At least 20 units or 20% of content
|
|
278
|
+
|
|
279
|
+
padded_rect = bounding_rect.adjusted(-padding, -padding, padding, padding)
|
|
280
|
+
preview_scene.setSceneRect(padded_rect)
|
|
281
|
+
|
|
282
|
+
# Store original scene rect for proper fitting on resize
|
|
283
|
+
preview_view.original_scene_rect = padded_rect
|
|
284
|
+
|
|
285
|
+
# Use QTimer to ensure fitInView happens after widget is fully initialized
|
|
286
|
+
QTimer.singleShot(0, lambda: self.fit_preview_view_safely(preview_view, padded_rect))
|
|
287
|
+
else:
|
|
288
|
+
# Default view for empty or invalid content
|
|
289
|
+
default_rect = QRectF(-50, -50, 100, 100)
|
|
290
|
+
preview_scene.setSceneRect(default_rect)
|
|
291
|
+
preview_view.original_scene_rect = default_rect
|
|
292
|
+
QTimer.singleShot(0, lambda: self.fit_preview_view_safely(preview_view, default_rect))
|
|
293
|
+
|
|
294
|
+
layout.addWidget(preview_view)
|
|
295
|
+
|
|
296
|
+
# Template name
|
|
297
|
+
name = template_data.get('name', 'Unnamed Template')
|
|
298
|
+
name_label = QLabel(name)
|
|
299
|
+
name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
300
|
+
name_label.setWordWrap(True)
|
|
301
|
+
layout.addWidget(name_label)
|
|
302
|
+
|
|
303
|
+
# Mouse events
|
|
304
|
+
widget.mousePressEvent = lambda event: self.select_template(template_data, widget)
|
|
305
|
+
widget.mouseDoubleClickEvent = lambda event: self.use_template(template_data)
|
|
306
|
+
|
|
307
|
+
return widget
|
|
308
|
+
|
|
309
|
+
def fit_preview_view_safely(self, view, rect):
|
|
310
|
+
"""プレビュービューを安全にフィット"""
|
|
311
|
+
try:
|
|
312
|
+
if view and not rect.isEmpty():
|
|
313
|
+
view.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
logging.warning(f"Warning: Failed to fit preview view: {e}")
|
|
316
|
+
|
|
317
|
+
def draw_template_preview(self, scene, template_data, view_size=None):
|
|
318
|
+
"""テンプレートプレビューを描画 - fitInView縮小率に基づく動的スケーリング"""
|
|
319
|
+
atoms = template_data.get('atoms', [])
|
|
320
|
+
bonds = template_data.get('bonds', [])
|
|
321
|
+
|
|
322
|
+
if not atoms:
|
|
323
|
+
# Add placeholder text when no atoms
|
|
324
|
+
text = scene.addText("No structure", QFont('Arial', 12))
|
|
325
|
+
text.setDefaultTextColor(QColor('gray'))
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# Calculate molecular dimensions
|
|
329
|
+
positions = [QPointF(atom['x'], atom['y']) for atom in atoms]
|
|
330
|
+
min_x = min(pos.x() for pos in positions)
|
|
331
|
+
max_x = max(pos.x() for pos in positions)
|
|
332
|
+
min_y = min(pos.y() for pos in positions)
|
|
333
|
+
max_y = max(pos.y() for pos in positions)
|
|
334
|
+
|
|
335
|
+
mol_width = max_x - min_x
|
|
336
|
+
mol_height = max_y - min_y
|
|
337
|
+
mol_size = max(mol_width, mol_height)
|
|
338
|
+
|
|
339
|
+
# Calculate fit scale factor (how much fitInView will shrink the content)
|
|
340
|
+
if view_size is None:
|
|
341
|
+
view_size = (160, 140) # Default preview view size
|
|
342
|
+
|
|
343
|
+
view_width, view_height = view_size
|
|
344
|
+
|
|
345
|
+
if mol_size > 0 and mol_width > 0 and mol_height > 0:
|
|
346
|
+
# Calculate the padding that will be added
|
|
347
|
+
padding = max(20, mol_size * 0.2)
|
|
348
|
+
padded_width = mol_width + 2 * padding
|
|
349
|
+
padded_height = mol_height + 2 * padding
|
|
350
|
+
|
|
351
|
+
# Calculate how much fitInView will scale down the content
|
|
352
|
+
# fitInView fits the padded rectangle into the view while maintaining aspect ratio
|
|
353
|
+
fit_scale_x = view_width / padded_width
|
|
354
|
+
fit_scale_y = view_height / padded_height
|
|
355
|
+
fit_scale = min(fit_scale_x, fit_scale_y) # fitInView uses the smaller scale
|
|
356
|
+
|
|
357
|
+
# Compensate for the fit scaling to maintain visual thickness
|
|
358
|
+
# When fit_scale is small (content heavily shrunk), we need thicker lines/fonts
|
|
359
|
+
if fit_scale > 0:
|
|
360
|
+
scale_factor = max(0.4, min(4.0, 1.0 / fit_scale))
|
|
361
|
+
else:
|
|
362
|
+
scale_factor = 4.0
|
|
363
|
+
|
|
364
|
+
# Debug info (can be removed in production)
|
|
365
|
+
# logging.debug(f"Mol size: {mol_size:.1f}, Fit scale: {fit_scale:.3f}, Scale factor: {scale_factor:.2f}")
|
|
366
|
+
else:
|
|
367
|
+
scale_factor = 1.0
|
|
368
|
+
|
|
369
|
+
# Base sizes that look good at 1:1 scale
|
|
370
|
+
base_bond_width = 1.8
|
|
371
|
+
base_font_size = 11
|
|
372
|
+
base_ellipse_width = 18
|
|
373
|
+
base_ellipse_height = 14
|
|
374
|
+
base_double_bond_offset = 3.5
|
|
375
|
+
base_triple_bond_offset = 2.5
|
|
376
|
+
|
|
377
|
+
# Apply inverse fit scaling to maintain visual consistency
|
|
378
|
+
bond_width = max(1.0, min(8.0, base_bond_width * scale_factor))
|
|
379
|
+
font_size = max(8, min(24, int(base_font_size * scale_factor)))
|
|
380
|
+
ellipse_width = max(10, min(40, base_ellipse_width * scale_factor))
|
|
381
|
+
ellipse_height = max(8, min(30, base_ellipse_height * scale_factor))
|
|
382
|
+
double_bond_offset = max(2.0, min(10.0, base_double_bond_offset * scale_factor))
|
|
383
|
+
triple_bond_offset = max(1.5, min(8.0, base_triple_bond_offset * scale_factor))
|
|
384
|
+
|
|
385
|
+
# Create atom ID to index mapping for bond drawing
|
|
386
|
+
atom_id_to_index = {}
|
|
387
|
+
for i, atom in enumerate(atoms):
|
|
388
|
+
atom_id = atom.get('id', i) # Use id if available, otherwise use index
|
|
389
|
+
atom_id_to_index[atom_id] = i
|
|
390
|
+
|
|
391
|
+
# Draw bonds first using original coordinates with dynamic sizing
|
|
392
|
+
for bond in bonds:
|
|
393
|
+
atom1_id, atom2_id = bond['atom1'], bond['atom2']
|
|
394
|
+
|
|
395
|
+
# Get atom indices from IDs
|
|
396
|
+
atom1_idx = atom_id_to_index.get(atom1_id)
|
|
397
|
+
atom2_idx = atom_id_to_index.get(atom2_id)
|
|
398
|
+
|
|
399
|
+
if atom1_idx is not None and atom2_idx is not None and atom1_idx < len(atoms) and atom2_idx < len(atoms):
|
|
400
|
+
pos1 = QPointF(atoms[atom1_idx]['x'], atoms[atom1_idx]['y'])
|
|
401
|
+
pos2 = QPointF(atoms[atom2_idx]['x'], atoms[atom2_idx]['y'])
|
|
402
|
+
|
|
403
|
+
# Draw bonds with proper order - dynamic thickness
|
|
404
|
+
bond_order = bond.get('order', 1)
|
|
405
|
+
pen = QPen(QColor('black'), bond_width)
|
|
406
|
+
|
|
407
|
+
if bond_order == 2:
|
|
408
|
+
# Double bond - draw two parallel lines
|
|
409
|
+
line = QLineF(pos1, pos2)
|
|
410
|
+
if line.length() > 0:
|
|
411
|
+
normal = line.normalVector()
|
|
412
|
+
normal.setLength(double_bond_offset)
|
|
413
|
+
|
|
414
|
+
line1 = QLineF(pos1 + normal.p2() - normal.p1(), pos2 + normal.p2() - normal.p1())
|
|
415
|
+
line2 = QLineF(pos1 - normal.p2() + normal.p1(), pos2 - normal.p2() + normal.p1())
|
|
416
|
+
|
|
417
|
+
scene.addLine(line1, pen)
|
|
418
|
+
scene.addLine(line2, pen)
|
|
419
|
+
else:
|
|
420
|
+
scene.addLine(line, pen)
|
|
421
|
+
elif bond_order == 3:
|
|
422
|
+
# Triple bond - draw three parallel lines
|
|
423
|
+
line = QLineF(pos1, pos2)
|
|
424
|
+
if line.length() > 0:
|
|
425
|
+
normal = line.normalVector()
|
|
426
|
+
normal.setLength(triple_bond_offset)
|
|
427
|
+
|
|
428
|
+
# Center line
|
|
429
|
+
scene.addLine(line, pen)
|
|
430
|
+
# Side lines
|
|
431
|
+
line1 = QLineF(pos1 + normal.p2() - normal.p1(), pos2 + normal.p2() - normal.p1())
|
|
432
|
+
line2 = QLineF(pos1 - normal.p2() + normal.p1(), pos2 - normal.p2() + normal.p1())
|
|
433
|
+
|
|
434
|
+
scene.addLine(line1, pen)
|
|
435
|
+
scene.addLine(line2, pen)
|
|
436
|
+
else:
|
|
437
|
+
scene.addLine(line, pen)
|
|
438
|
+
else:
|
|
439
|
+
# Single bond
|
|
440
|
+
scene.addLine(QLineF(pos1, pos2), pen)
|
|
441
|
+
|
|
442
|
+
# Draw only non-carbon atom labels with dynamic sizing
|
|
443
|
+
for i, atom in enumerate(atoms):
|
|
444
|
+
try:
|
|
445
|
+
pos = QPointF(atom['x'], atom['y'])
|
|
446
|
+
symbol = atom.get('symbol', 'C')
|
|
447
|
+
|
|
448
|
+
# Draw atoms - white ellipse background to hide bonds, then CPK colored text
|
|
449
|
+
if symbol != 'C':
|
|
450
|
+
# All non-carbon atoms including hydrogen: white background ellipse + CPK colored text
|
|
451
|
+
color = CPK_COLORS.get(symbol, CPK_COLORS.get('DEFAULT', QColor('#FF1493')))
|
|
452
|
+
|
|
453
|
+
# Add white background ellipse to hide bonds - dynamic size
|
|
454
|
+
pen = QPen(Qt.GlobalColor.white, 0) # No border
|
|
455
|
+
brush = QBrush(Qt.GlobalColor.white)
|
|
456
|
+
ellipse_x = pos.x() - ellipse_width/2
|
|
457
|
+
ellipse_y = pos.y() - ellipse_height/2
|
|
458
|
+
ellipse = scene.addEllipse(ellipse_x, ellipse_y, ellipse_width, ellipse_height, pen, brush)
|
|
459
|
+
|
|
460
|
+
# Add CPK colored text label on top - dynamic font size
|
|
461
|
+
font = QFont("Arial", font_size, QFont.Weight.Bold)
|
|
462
|
+
text = scene.addText(symbol, font)
|
|
463
|
+
text.setDefaultTextColor(color) # CPK colored text
|
|
464
|
+
text_rect = text.boundingRect()
|
|
465
|
+
text.setPos(pos.x() - text_rect.width()/2, pos.y() - text_rect.height()/2)
|
|
466
|
+
|
|
467
|
+
except Exception:
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
def select_template(self, template_data, widget):
|
|
471
|
+
"""テンプレートを選択してテンプレートモードに切り替え"""
|
|
472
|
+
# Clear previous selection styling
|
|
473
|
+
for i in range(self.template_layout.count()):
|
|
474
|
+
item = self.template_layout.itemAt(i)
|
|
475
|
+
if item and item.widget():
|
|
476
|
+
item.widget().setStyleSheet("""
|
|
477
|
+
QWidget {
|
|
478
|
+
border: 2px solid #ccc;
|
|
479
|
+
border-radius: 8px;
|
|
480
|
+
background-color: white;
|
|
481
|
+
}
|
|
482
|
+
QWidget:hover {
|
|
483
|
+
border-color: #007acc;
|
|
484
|
+
background-color: #f0f8ff;
|
|
485
|
+
}
|
|
486
|
+
""")
|
|
487
|
+
|
|
488
|
+
# Highlight selected widget - only border, no background change
|
|
489
|
+
widget.setStyleSheet("""
|
|
490
|
+
QWidget {
|
|
491
|
+
border: 3px solid #007acc;
|
|
492
|
+
border-radius: 8px;
|
|
493
|
+
background-color: white;
|
|
494
|
+
}
|
|
495
|
+
""")
|
|
496
|
+
|
|
497
|
+
self.selected_template = template_data
|
|
498
|
+
self.delete_button.setEnabled(True)
|
|
499
|
+
|
|
500
|
+
# Automatically switch to template mode when template is selected
|
|
501
|
+
template_name = template_data.get('name', 'user_template')
|
|
502
|
+
mode_name = f"template_user_{template_name}"
|
|
503
|
+
|
|
504
|
+
# Store template data for the scene to use
|
|
505
|
+
try:
|
|
506
|
+
self.main_window.scene.user_template_data = template_data
|
|
507
|
+
except Exception:
|
|
508
|
+
# Best-effort: ignore if scene or attribute missing
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
# Force the main window into the template mode.
|
|
512
|
+
# Clear or uncheck any existing mode actions if present to avoid staying in another mode.
|
|
513
|
+
try:
|
|
514
|
+
# Uncheck all mode actions first (if a dict of QAction exists)
|
|
515
|
+
if hasattr(self.main_window, 'mode_actions') and isinstance(self.main_window.mode_actions, dict):
|
|
516
|
+
for act in self.main_window.mode_actions.values():
|
|
517
|
+
try:
|
|
518
|
+
act.setChecked(False)
|
|
519
|
+
except Exception:
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
# If main_window has a set_mode method, call it. Otherwise, try to set a mode attribute.
|
|
523
|
+
if hasattr(self.main_window, 'set_mode') and callable(self.main_window.set_mode):
|
|
524
|
+
self.main_window.set_mode(mode_name)
|
|
525
|
+
else:
|
|
526
|
+
# Fallback: set an attribute and try to update UI
|
|
527
|
+
setattr(self.main_window, 'mode', mode_name)
|
|
528
|
+
|
|
529
|
+
# Update UI
|
|
530
|
+
try:
|
|
531
|
+
self.main_window.statusBar().showMessage(f"Template mode: {template_name}")
|
|
532
|
+
except Exception:
|
|
533
|
+
# ignore status bar failures
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
# If there is a matching QAction in mode_actions, check it
|
|
537
|
+
try:
|
|
538
|
+
if hasattr(self.main_window, 'mode_actions') and f"template_user_{template_name}" in self.main_window.mode_actions:
|
|
539
|
+
self.main_window.mode_actions[f"template_user_{template_name}"].setChecked(True)
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logging.warning(f"Warning: Failed to switch main window to template mode: {e}")
|
|
544
|
+
|
|
545
|
+
def use_template(self, template_data):
|
|
546
|
+
"""テンプレートを使用(エディタに適用)"""
|
|
547
|
+
try:
|
|
548
|
+
# Switch to template mode
|
|
549
|
+
template_name = template_data.get('name', 'user_template')
|
|
550
|
+
mode_name = f"template_user_{template_name}"
|
|
551
|
+
|
|
552
|
+
# Store template data for the scene to use
|
|
553
|
+
try:
|
|
554
|
+
self.main_window.scene.user_template_data = template_data
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
# Force the main window into the template mode (same approach as select_template)
|
|
559
|
+
try:
|
|
560
|
+
if hasattr(self.main_window, 'mode_actions') and isinstance(self.main_window.mode_actions, dict):
|
|
561
|
+
for act in self.main_window.mode_actions.values():
|
|
562
|
+
try:
|
|
563
|
+
act.setChecked(False)
|
|
564
|
+
except Exception:
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
if hasattr(self.main_window, 'set_mode') and callable(self.main_window.set_mode):
|
|
568
|
+
self.main_window.set_mode(mode_name)
|
|
569
|
+
else:
|
|
570
|
+
setattr(self.main_window, 'mode', mode_name)
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
self.main_window.statusBar().showMessage(f"Template mode: {template_name}")
|
|
574
|
+
except Exception:
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
# Mark selected and keep dialog open
|
|
578
|
+
self.selected_template = template_data
|
|
579
|
+
except Exception as e:
|
|
580
|
+
logging.warning(f"Warning: Failed to switch main window to template mode: {e}")
|
|
581
|
+
|
|
582
|
+
# Don't close dialog - keep it open for easy template switching
|
|
583
|
+
# self.accept()
|
|
584
|
+
|
|
585
|
+
except Exception as e:
|
|
586
|
+
QMessageBox.critical(self, "Error", f"Failed to apply template: {str(e)}")
|
|
587
|
+
|
|
588
|
+
def save_current_as_template(self):
|
|
589
|
+
"""現在の2D構造をテンプレートとして保存"""
|
|
590
|
+
if not self.main_window.data.atoms:
|
|
591
|
+
QMessageBox.warning(self, "Warning", "No structure to save as template.")
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
# Get template name
|
|
595
|
+
name, ok = QInputDialog.getText(self, "Save Template", "Enter template name:")
|
|
596
|
+
if not ok or not name.strip():
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
name = name.strip()
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
# Convert current structure to template format
|
|
603
|
+
template_data = self.convert_structure_to_template(name)
|
|
604
|
+
|
|
605
|
+
# Save to file
|
|
606
|
+
filename = f"{name.replace(' ', '_')}.pmetmplt"
|
|
607
|
+
filepath = os.path.join(self.get_template_directory(), filename)
|
|
608
|
+
|
|
609
|
+
if os.path.exists(filepath):
|
|
610
|
+
reply = QMessageBox.question(
|
|
611
|
+
self, "Overwrite Template",
|
|
612
|
+
f"Template '{name}' already exists. Overwrite?",
|
|
613
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
614
|
+
)
|
|
615
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if self.save_template_file(filepath, template_data):
|
|
619
|
+
# Mark main window as saved
|
|
620
|
+
self.main_window.has_unsaved_changes = False
|
|
621
|
+
self.main_window.update_window_title()
|
|
622
|
+
|
|
623
|
+
QMessageBox.information(self, "Success", f"Template '{name}' saved successfully.")
|
|
624
|
+
self.load_user_templates() # Refresh the display
|
|
625
|
+
else:
|
|
626
|
+
QMessageBox.critical(self, "Error", "Failed to save template.")
|
|
627
|
+
|
|
628
|
+
except Exception as e:
|
|
629
|
+
QMessageBox.critical(self, "Error", f"Failed to save template: {str(e)}")
|
|
630
|
+
|
|
631
|
+
def convert_structure_to_template(self, name):
|
|
632
|
+
"""現在の構造をテンプレート形式に変換"""
|
|
633
|
+
atoms_data = []
|
|
634
|
+
bonds_data = []
|
|
635
|
+
|
|
636
|
+
# Convert atoms
|
|
637
|
+
for atom_id, atom_info in self.main_window.data.atoms.items():
|
|
638
|
+
pos = atom_info['pos']
|
|
639
|
+
atoms_data.append({
|
|
640
|
+
'id': atom_id,
|
|
641
|
+
'symbol': atom_info['symbol'],
|
|
642
|
+
'x': pos.x(),
|
|
643
|
+
'y': pos.y(),
|
|
644
|
+
'charge': atom_info.get('charge', 0),
|
|
645
|
+
'radical': atom_info.get('radical', 0)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
# Convert bonds
|
|
649
|
+
for (atom1_id, atom2_id), bond_info in self.main_window.data.bonds.items():
|
|
650
|
+
bonds_data.append({
|
|
651
|
+
'atom1': atom1_id,
|
|
652
|
+
'atom2': atom2_id,
|
|
653
|
+
'order': bond_info['order'],
|
|
654
|
+
'stereo': bond_info.get('stereo', 0)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
# Create template data
|
|
658
|
+
template_data = {
|
|
659
|
+
'format': "PME Template",
|
|
660
|
+
'version': "1.0",
|
|
661
|
+
'application': "MoleditPy",
|
|
662
|
+
'application_version': VERSION,
|
|
663
|
+
'name': name,
|
|
664
|
+
'created': str(QDateTime.currentDateTime().toString()),
|
|
665
|
+
'atoms': atoms_data,
|
|
666
|
+
'bonds': bonds_data
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return template_data
|
|
670
|
+
|
|
671
|
+
def delete_selected_template(self):
|
|
672
|
+
"""選択されたテンプレートを削除"""
|
|
673
|
+
if not self.selected_template:
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
name = self.selected_template.get('name', 'Unknown')
|
|
677
|
+
reply = QMessageBox.question(
|
|
678
|
+
self, "Delete Template",
|
|
679
|
+
f"Are you sure you want to delete template '{name}'?",
|
|
680
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
684
|
+
try:
|
|
685
|
+
filepath = self.selected_template['filepath']
|
|
686
|
+
os.remove(filepath)
|
|
687
|
+
QMessageBox.information(self, "Success", f"Template '{name}' deleted successfully.")
|
|
688
|
+
self.load_user_templates() # Refresh the display
|
|
689
|
+
self.selected_template = None
|
|
690
|
+
self.delete_button.setEnabled(False)
|
|
691
|
+
except Exception as e:
|
|
692
|
+
QMessageBox.critical(self, "Error", f"Failed to delete template: {str(e)}")
|