MoleditPy 2.3.3__tar.gz → 2.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {moleditpy-2.3.3 → moleditpy-2.4.1}/PKG-INFO +1 -1
  2. {moleditpy-2.3.3 → moleditpy-2.4.1}/pyproject.toml +1 -1
  3. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/MoleditPy.egg-info/PKG-INFO +1 -1
  4. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/atom_item.py +61 -14
  5. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/bond_item.py +78 -20
  6. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/constants.py +1 -1
  7. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window.py +8 -0
  8. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_edit_actions.py +105 -2
  9. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_export.py +119 -8
  10. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_main_init.py +38 -3
  11. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/settings_dialog.py +361 -7
  12. {moleditpy-2.3.3 → moleditpy-2.4.1}/LICENSE +0 -0
  13. {moleditpy-2.3.3 → moleditpy-2.4.1}/README.md +0 -0
  14. {moleditpy-2.3.3 → moleditpy-2.4.1}/setup.cfg +0 -0
  15. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
  16. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  17. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  18. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/MoleditPy.egg-info/requires.txt +0 -0
  19. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/MoleditPy.egg-info/top_level.txt +0 -0
  20. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/__init__.py +0 -0
  21. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/__main__.py +0 -0
  22. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/main.py +0 -0
  23. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/__init__.py +0 -0
  24. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/about_dialog.py +0 -0
  25. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/align_plane_dialog.py +0 -0
  26. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/alignment_dialog.py +0 -0
  27. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/analysis_window.py +0 -0
  28. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/angle_dialog.py +0 -0
  29. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/assets/file_icon.ico +0 -0
  30. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/assets/icon.icns +0 -0
  31. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/assets/icon.ico +0 -0
  32. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/assets/icon.png +0 -0
  33. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/bond_length_dialog.py +0 -0
  34. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/calculation_worker.py +0 -0
  35. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/color_settings_dialog.py +0 -0
  36. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/constrained_optimization_dialog.py +0 -0
  37. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/custom_interactor_style.py +0 -0
  38. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/custom_qt_interactor.py +0 -0
  39. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/dialog3_d_picking_mixin.py +0 -0
  40. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/dihedral_dialog.py +0 -0
  41. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_app_state.py +0 -0
  42. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_compute.py +0 -0
  43. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_dialog_manager.py +0 -0
  44. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_edit_3d.py +0 -0
  45. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_molecular_parsers.py +0 -0
  46. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_project_io.py +0 -0
  47. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_string_importers.py +0 -0
  48. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_ui_manager.py +0 -0
  49. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_view_3d.py +0 -0
  50. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/main_window_view_loaders.py +0 -0
  51. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/mirror_dialog.py +0 -0
  52. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/molecular_data.py +0 -0
  53. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/molecule_scene.py +0 -0
  54. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/move_group_dialog.py +0 -0
  55. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/periodic_table_dialog.py +0 -0
  56. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/planarize_dialog.py +0 -0
  57. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/plugin_interface.py +0 -0
  58. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/plugin_manager.py +0 -0
  59. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/plugin_manager_window.py +0 -0
  60. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/template_preview_item.py +0 -0
  61. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/template_preview_view.py +0 -0
  62. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/translation_dialog.py +0 -0
  63. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/user_template_dialog.py +0 -0
  64. {moleditpy-2.3.3 → moleditpy-2.4.1}/src/moleditpy/modules/zoomable_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 2.3.3
3
+ Version: 2.4.1
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy"
7
7
 
8
- version = "2.3.3"
8
+ version = "2.4.1"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 2.3.3
3
+ Version: 2.4.1
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -13,7 +13,7 @@ DOI: 10.5281/zenodo.17268532
13
13
  from PyQt6.QtWidgets import QGraphicsItem
14
14
 
15
15
  from PyQt6.QtGui import (
16
- QPen, QBrush, QColor, QFont, QPainterPath, QFontMetricsF
16
+ QPen, QBrush, QColor, QFont, QPainterPath, QFontMetricsF, QPainter
17
17
  )
18
18
 
19
19
  from PyQt6.QtCore import (
@@ -45,14 +45,41 @@ class AtomItem(QGraphicsItem):
45
45
  self.setPos(pos)
46
46
  self.implicit_h_count = 0
47
47
  self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
48
- self.setZValue(1); self.font = QFont(FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD); self.update_style()
48
+ self.setZValue(1)
49
+ self.update_style()
49
50
  self.setAcceptHoverEvents(True)
50
51
  self.hovered = False
51
52
  self.has_problem = False
52
53
 
54
+
55
+ def update_style(self):
56
+ # Allow updating font preference dynamically
57
+ font_size = 20
58
+ try:
59
+ if self.scene() and self.scene().views():
60
+ win = self.scene().views()[0].window()
61
+ if win and hasattr(win, 'settings'):
62
+ font_size = win.settings.get('atom_font_size_2d', 20)
63
+ except Exception:
64
+ pass
65
+ self.font = QFont(FONT_FAMILY, font_size, FONT_WEIGHT_BOLD)
66
+ self.prepareGeometryChange()
67
+
68
+ self.is_visible = not (self.symbol == 'C' and len(self.bonds) > 0 and self.charge == 0 and self.radical == 0)
69
+ self.update()
70
+
53
71
  def boundingRect(self):
54
72
  # --- paint()メソッドと完全に同じロジックでテキストの位置とサイズを計算 ---
55
- font = QFont(FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD)
73
+ # Get dynamic font size
74
+ font_size = 20
75
+ try:
76
+ if self.scene() and self.scene().views():
77
+ win = self.scene().views()[0].window()
78
+ if win and hasattr(win, 'settings'):
79
+ font_size = win.settings.get('atom_font_size_2d', 20)
80
+ except Exception:
81
+ pass
82
+ font = QFont(FONT_FAMILY, font_size, FONT_WEIGHT_BOLD)
56
83
  fm = QFontMetricsF(font)
57
84
 
58
85
  hydrogen_part = ""
@@ -164,9 +191,21 @@ class AtomItem(QGraphicsItem):
164
191
  return path
165
192
 
166
193
  def paint(self, painter, option, widget):
194
+ # Color logic: check if we should use bond color (uniform) or CPK (element-specific)
167
195
  color = CPK_COLORS.get(self.symbol, CPK_COLORS['DEFAULT'])
196
+ try:
197
+ if self.scene() and self.scene().views():
198
+ win = self.scene().views()[0].window()
199
+ if win and hasattr(win, 'settings'):
200
+ if win.settings.get('atom_use_bond_color_2d', False):
201
+ bond_col = win.settings.get('bond_color_2d', '#222222')
202
+ color = QColor(bond_col)
203
+ except Exception:
204
+ pass
205
+
168
206
  if self.is_visible:
169
207
  # 1. 描画の準備
208
+ # Ensure correct font is used (self.font should be updated by update_style)
170
209
  painter.setFont(self.font)
171
210
  fm = painter.fontMetrics()
172
211
 
@@ -253,19 +292,29 @@ class AtomItem(QGraphicsItem):
253
292
  offset_x = -symbol_rect.width() // 2
254
293
  text_rect.moveTo(offset_x, -text_rect.height() // 2)
255
294
 
256
- # 2. 原子記号の背景を白で塗りつぶす
295
+ # 2. 原子記号の背景を処理(白で塗りつぶす か 透明なら切り抜く)
257
296
  if self.scene():
258
297
  bg_brush = self.scene().backgroundBrush()
259
298
  bg_rect = text_rect.adjusted(-5, -8, 5, 8)
260
- painter.setBrush(bg_brush)
261
- painter.setPen(Qt.PenStyle.NoPen)
262
- painter.drawEllipse(bg_rect)
299
+
300
+ if bg_brush.style() == Qt.BrushStyle.NoBrush:
301
+ # 背景が透明の場合は、CompositionMode_Clearを使って
302
+ # 重なっている結合の線を「消しゴム」のように消す
303
+ painter.save()
304
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
305
+ painter.setBrush(QColor(0, 0, 0, 255)) # 色は何でも良い(アルファが重要)
306
+ painter.setPen(Qt.PenStyle.NoPen)
307
+ painter.drawEllipse(bg_rect)
308
+ painter.restore()
309
+ else:
310
+ # 背景がある場合は、その背景色で塗りつぶす(従来通り)
311
+ painter.setBrush(bg_brush)
312
+ painter.setPen(Qt.PenStyle.NoPen)
313
+ painter.drawEllipse(bg_rect)
263
314
 
264
315
  # 3. 原子記号自体を描画
265
- if self.symbol == 'H':
266
- painter.setPen(QPen(Qt.GlobalColor.black))
267
- else:
268
- painter.setPen(QPen(color))
316
+ # Color is already determined above
317
+ painter.setPen(QPen(color))
269
318
  painter.drawText(text_rect, int(alignment_flag), display_text)
270
319
 
271
320
  # --- 電荷とラジカルの描画 ---
@@ -316,9 +365,7 @@ class AtomItem(QGraphicsItem):
316
365
  painter.setPen(pen)
317
366
  painter.drawRect(self.boundingRect())
318
367
 
319
- def update_style(self):
320
- self.is_visible = not (self.symbol == 'C' and len(self.bonds) > 0 and self.charge == 0 and self.radical == 0)
321
- self.update()
368
+
322
369
 
323
370
 
324
371
  # 約203行目 AtomItem クラス内
@@ -105,7 +105,21 @@ class BondItem(QGraphicsItem):
105
105
  line = self.get_line_in_local_coords()
106
106
  except Exception:
107
107
  line = QLineF(0, 0, 0, 0)
108
- bond_offset = globals().get('BOND_OFFSET', 2)
108
+
109
+ # Get dynamic bond offset (spacing)
110
+ bond_offset = 3.5
111
+ try:
112
+ if self.scene() and hasattr(self.scene(), 'views') and self.scene().views():
113
+ win = self.scene().views()[0].window()
114
+ if win and hasattr(win, 'settings'):
115
+ # Use specific spacing based on bond order
116
+ if getattr(self, 'order', 1) == 3:
117
+ bond_offset = win.settings.get('bond_spacing_triple_2d', 3.5)
118
+ else:
119
+ bond_offset = win.settings.get('bond_spacing_double_2d', 3.5)
120
+ except Exception:
121
+ bond_offset = globals().get('BOND_OFFSET', 3.5)
122
+
109
123
  extra = (getattr(self, 'order', 1) - 1) * bond_offset + 20
110
124
  rect = QRectF(line.p1(), line.p2()).normalized().adjusted(-extra, -extra, extra, extra)
111
125
 
@@ -142,7 +156,18 @@ class BondItem(QGraphicsItem):
142
156
  view = scene.views()[0]
143
157
  scale = view.transform().m11()
144
158
 
145
- scene_width = DESIRED_BOND_PIXEL_WIDTH / scale
159
+ # Dynamic bond width
160
+ width_2d = 2.0
161
+ try:
162
+ if view.window() and hasattr(view.window(), 'settings'):
163
+ width_2d = view.window().settings.get('bond_width_2d', 2.0)
164
+ except Exception:
165
+ pass
166
+
167
+ # Hit area should be roughly closely matched or slightly larger than visual
168
+ # Ensure minimum hit width for usability
169
+ scene_width = max(DESIRED_BOND_PIXEL_WIDTH, width_2d * 10) / scale
170
+
146
171
 
147
172
  stroker = QPainterPathStroker()
148
173
  stroker.setWidth(scene_width)
@@ -160,24 +185,44 @@ class BondItem(QGraphicsItem):
160
185
  line = self.get_line_in_local_coords()
161
186
  if line.length() == 0: return
162
187
 
163
- # --- 1. 選択状態に応じてペンとブラシを準備 ---
164
- if self.isSelected():
165
- selection_color = QColor("blue")
166
- painter.setPen(QPen(selection_color, 3))
167
- painter.setBrush(QBrush(selection_color))
168
- else:
169
- # Allow bond color override from app settings (2D color)
170
- try:
171
- sc = self.scene()
172
- if sc is not None and hasattr(sc, 'window') and sc.window is not None:
173
- bond_hex = sc.window.settings.get('bond_color', '#222222')
174
- bond_color = QColor(bond_hex)
175
- painter.setPen(QPen(bond_color, 2))
188
+ # Allow bond color override from app settings (2D color)
189
+ width_2d = 2.0
190
+
191
+ try:
192
+ sc = self.scene()
193
+ if sc is not None and hasattr(sc, 'window') and sc.window is not None:
194
+ # Get settings
195
+ settings = sc.window.settings
196
+
197
+ # Width
198
+ width_2d = settings.get('bond_width_2d', 2.0)
199
+
200
+ # Cap Style logic
201
+ cap_style_str = settings.get('bond_cap_style_2d', 'Round')
202
+ cap_style = Qt.PenCapStyle.RoundCap # Default
203
+
204
+ if cap_style_str == 'Flat':
205
+ cap_style = Qt.PenCapStyle.FlatCap
206
+ elif cap_style_str == 'Square':
207
+ cap_style = Qt.PenCapStyle.SquareCap
208
+
209
+ # Color
210
+ if self.isSelected():
211
+ bond_color = QColor("blue") # Selection color
176
212
  else:
177
- painter.setPen(self.pen)
178
- except Exception:
213
+ bond_hex = settings.get('bond_color_2d', '#222222')
214
+ bond_color = QColor(bond_hex)
215
+
216
+ pen = QPen(bond_color, width_2d)
217
+ pen.setCapStyle(cap_style)
218
+ painter.setPen(pen)
219
+
220
+ else:
179
221
  painter.setPen(self.pen)
180
- painter.setBrush(QBrush(Qt.GlobalColor.black))
222
+ except Exception:
223
+ painter.setPen(self.pen)
224
+
225
+ painter.setBrush(QBrush(Qt.GlobalColor.black))
181
226
 
182
227
  # --- 立体化学 (Wedge/Dash) の描画 ---
183
228
  if self.order == 1 and self.stereo in [1, 2]:
@@ -213,7 +258,19 @@ class BondItem(QGraphicsItem):
213
258
  painter.drawLine(line)
214
259
  else:
215
260
  v = line.unitVector().normalVector()
216
- offset = QPointF(v.dx(), v.dy()) * BOND_OFFSET
261
+ # Use dynamic offset
262
+ bond_offset = 3.5
263
+ try:
264
+ sc = self.scene()
265
+ if sc and sc.views() and hasattr(sc.views()[0].window(), 'settings'):
266
+ if self.order == 3:
267
+ bond_offset = sc.views()[0].window().settings.get('bond_spacing_triple_2d', 3.5)
268
+ else:
269
+ bond_offset = sc.views()[0].window().settings.get('bond_spacing_double_2d', 3.5)
270
+ except Exception:
271
+ bond_offset = globals().get('BOND_OFFSET', 3.5)
272
+
273
+ offset = QPointF(v.dx(), v.dy()) * bond_offset
217
274
 
218
275
  if self.order == 2:
219
276
  # 環構造かどうかを判定し、描画方法を変更
@@ -274,7 +331,8 @@ class BondItem(QGraphicsItem):
274
331
  is_in_ring = False
275
332
 
276
333
  v = line.unitVector().normalVector()
277
- offset = QPointF(v.dx(), v.dy()) * BOND_OFFSET
334
+ # Re-calculate offset in case loop variable scope issue, though strictly not needed if offset defined above works
335
+ offset = QPointF(v.dx(), v.dy()) * bond_offset
278
336
 
279
337
  if is_in_ring and ring_center:
280
338
  # 環構造: 1本の中心線(単結合位置) + 1本の短い内側線
@@ -16,7 +16,7 @@ from PyQt6.QtGui import QFont, QColor
16
16
  from rdkit import Chem
17
17
 
18
18
  #Version
19
- VERSION = '2.3.3'
19
+ VERSION = '2.4.1'
20
20
 
21
21
  ATOM_RADIUS = 18
22
22
  BOND_OFFSET = 3.5
@@ -500,6 +500,10 @@ class MainWindow(QMainWindow):
500
500
  # --- MOVED TO main_window_export.py ---
501
501
  return self.main_window_export.export_2d_png()
502
502
 
503
+ def export_2d_svg(self):
504
+ # --- MOVED TO main_window_export.py ---
505
+ return self.main_window_export.export_2d_svg()
506
+
503
507
  def export_3d_png(self):
504
508
  # --- MOVED TO main_window_export.py ---
505
509
  return self.main_window_export.export_3d_png()
@@ -524,6 +528,10 @@ class MainWindow(QMainWindow):
524
528
  # --- MOVED TO main_window_edit_actions.py ---
525
529
  return self.main_window_edit_actions.adjust_molecule_positions_to_avoid_collisions(mol, frags)
526
530
 
531
+ def open_rotate_2d_dialog(self):
532
+ # --- MOVED TO main_window_edit_actions.py ---
533
+ return self.main_window_edit_actions.open_rotate_2d_dialog()
534
+
527
535
  def draw_molecule_3d(self, mol):
528
536
  # --- MOVED TO main_window_view_3d.py ---
529
537
  return self.main_window_view_3d.draw_molecule_3d(mol)
@@ -34,7 +34,7 @@ from rdkit.Chem import AllChem
34
34
 
35
35
  # PyQt6 Modules
36
36
  from PyQt6.QtWidgets import (
37
- QApplication
37
+ QApplication, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QSlider, QPushButton
38
38
  )
39
39
 
40
40
  from PyQt6.QtGui import (
@@ -43,9 +43,51 @@ from PyQt6.QtGui import (
43
43
 
44
44
 
45
45
  from PyQt6.QtCore import (
46
- QPointF, QLineF, QMimeData, QByteArray, QTimer
46
+ QPointF, QLineF, QMimeData, QByteArray, QTimer, Qt
47
47
  )
48
48
 
49
+ class Rotate2DDialog(QDialog):
50
+ def __init__(self, parent=None):
51
+ super().__init__(parent)
52
+ self.setWindowTitle("Rotate 2D")
53
+ self.setFixedWidth(300)
54
+
55
+ layout = QVBoxLayout(self)
56
+
57
+ # Angle input
58
+ input_layout = QHBoxLayout()
59
+ input_layout.addWidget(QLabel("Angle (degrees):"))
60
+ self.angle_spin = QSpinBox()
61
+ self.angle_spin.setRange(-360, 360)
62
+ self.angle_spin.setValue(45)
63
+ input_layout.addWidget(self.angle_spin)
64
+ layout.addLayout(input_layout)
65
+
66
+ # Slider
67
+ self.slider = QSlider(Qt.Orientation.Horizontal)
68
+ self.slider.setRange(-180, 180)
69
+ self.slider.setValue(45)
70
+ self.slider.setTickPosition(QSlider.TickPosition.TicksBelow)
71
+ self.slider.setTickInterval(15)
72
+ layout.addWidget(self.slider)
73
+
74
+ # Sync slider and spinbox
75
+ self.angle_spin.valueChanged.connect(self.slider.setValue)
76
+ self.slider.valueChanged.connect(self.angle_spin.setValue)
77
+
78
+ # Buttons
79
+ btn_layout = QHBoxLayout()
80
+ ok_btn = QPushButton("Rotate")
81
+ ok_btn.clicked.connect(self.accept)
82
+ cancel_btn = QPushButton("Cancel")
83
+ cancel_btn.clicked.connect(self.reject)
84
+ btn_layout.addWidget(ok_btn)
85
+ btn_layout.addWidget(cancel_btn)
86
+ layout.addLayout(btn_layout)
87
+
88
+ def get_angle(self):
89
+ return self.angle_spin.value()
90
+
49
91
 
50
92
  # Use centralized Open Babel availability from package-level __init__
51
93
  # Use per-package modules availability (local __init__).
@@ -568,6 +610,67 @@ class MainWindowEditActions(object):
568
610
 
569
611
 
570
612
 
613
+ def open_rotate_2d_dialog(self):
614
+ """2D回転ダイアログを開く"""
615
+ dialog = Rotate2DDialog(self)
616
+ if dialog.exec() == QDialog.DialogCode.Accepted:
617
+ angle = dialog.get_angle()
618
+ self.rotate_molecule_2d(angle)
619
+
620
+ def rotate_molecule_2d(self, angle_degrees):
621
+ """2D分子を指定角度回転させる(選択範囲があればそれのみ、なければ全体)"""
622
+ try:
623
+ # Determine target atoms
624
+ selected_items = self.scene.selectedItems()
625
+ target_atoms = [item for item in selected_items if isinstance(item, AtomItem)]
626
+
627
+ # If no selection, rotate everything
628
+ if not target_atoms:
629
+ target_atoms = [data['item'] for data in self.data.atoms.values() if data.get('item') and not sip_isdeleted_safe(data['item'])]
630
+
631
+ if not target_atoms:
632
+ self.statusBar().showMessage("No atoms to rotate.")
633
+ return
634
+
635
+ # Calculate Center
636
+ xs = [atom.pos().x() for atom in target_atoms]
637
+ ys = [atom.pos().y() for atom in target_atoms]
638
+ if not xs: return
639
+
640
+ center_x = sum(xs) / len(xs)
641
+ center_y = sum(ys) / len(ys)
642
+ center = QPointF(center_x, center_y)
643
+
644
+ rad = math.radians(angle_degrees)
645
+ cos_a = math.cos(rad)
646
+ sin_a = math.sin(rad)
647
+
648
+ for atom in target_atoms:
649
+ # Relative pos
650
+ dx = atom.pos().x() - center_x
651
+ dy = atom.pos().y() - center_y
652
+
653
+ # Rotate
654
+ new_dx = dx * cos_a - dy * sin_a
655
+ new_dy = dx * sin_a + dy * cos_a
656
+
657
+ new_pos = QPointF(center_x + new_dx, center_y + new_dy)
658
+ atom.setPos(new_pos)
659
+
660
+ # Update bonds
661
+ self.scene.update_connected_bonds(target_atoms)
662
+
663
+ self.push_undo_state()
664
+ self.statusBar().showMessage(f"Rotated {len(target_atoms)} atoms by {angle_degrees} degrees.")
665
+ self.scene.update()
666
+
667
+ except Exception as e:
668
+ print(f"Error rotating molecule: {e}")
669
+ traceback.print_exc()
670
+ self.statusBar().showMessage(f"Error rotating: {e}")
671
+
672
+
673
+
571
674
 
572
675
  def select_all(self):
573
676
  for item in self.scene.items():
@@ -37,9 +37,11 @@ from PyQt6.QtGui import (
37
37
  QBrush, QColor, QPainter, QImage
38
38
  )
39
39
 
40
+ from PyQt6.QtSvg import QSvgGenerator
41
+
40
42
 
41
43
  from PyQt6.QtCore import (
42
- Qt, QRectF
44
+ Qt, QRectF, QSize
43
45
  )
44
46
 
45
47
  import pyvista as pv
@@ -672,7 +674,7 @@ class MainWindowExport(object):
672
674
  filePath += ".png"
673
675
 
674
676
  reply = QMessageBox.question(self, 'Choose Background',
675
- 'Do you want a transparent background?\n(Choose "No" for a white background)',
677
+ 'Do you want a transparent background?\n(Choose "No" to use the current background color)',
676
678
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
677
679
  QMessageBox.StandardButton.Yes)
678
680
 
@@ -706,8 +708,7 @@ class MainWindowExport(object):
706
708
 
707
709
  if is_transparent:
708
710
  self.scene.setBackgroundBrush(QBrush(Qt.BrushStyle.NoBrush))
709
- else:
710
- self.scene.setBackgroundBrush(QBrush(QColor("#FFFFFF")))
711
+ # Else: keep original_background (current 2D background)
711
712
 
712
713
  rect_to_render = molecule_bounds.adjusted(-20, -20, 20, 20)
713
714
 
@@ -719,10 +720,8 @@ class MainWindowExport(object):
719
720
  return
720
721
 
721
722
  image = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
722
- if is_transparent:
723
- image.fill(Qt.GlobalColor.transparent)
724
- else:
725
- image.fill(Qt.GlobalColor.white)
723
+ # Always fill with transparent; render will paint opaque background if present
724
+ image.fill(Qt.GlobalColor.transparent)
726
725
 
727
726
  painter = QPainter()
728
727
  ok = painter.begin(image)
@@ -756,6 +755,118 @@ class MainWindowExport(object):
756
755
 
757
756
 
758
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
+
759
870
  def export_3d_png(self):
760
871
  if not self.current_mol:
761
872
  self.statusBar().showMessage("No 3D molecule to export.", 2000)