MoleditPy 1.16.3__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 (54) hide show
  1. moleditpy/__init__.py +4 -0
  2. moleditpy/__main__.py +29 -0
  3. moleditpy/main.py +37 -0
  4. moleditpy/modules/__init__.py +36 -0
  5. moleditpy/modules/about_dialog.py +92 -0
  6. moleditpy/modules/align_plane_dialog.py +281 -0
  7. moleditpy/modules/alignment_dialog.py +261 -0
  8. moleditpy/modules/analysis_window.py +197 -0
  9. moleditpy/modules/angle_dialog.py +428 -0
  10. moleditpy/modules/assets/icon.icns +0 -0
  11. moleditpy/modules/assets/icon.ico +0 -0
  12. moleditpy/modules/assets/icon.png +0 -0
  13. moleditpy/modules/atom_item.py +336 -0
  14. moleditpy/modules/bond_item.py +303 -0
  15. moleditpy/modules/bond_length_dialog.py +368 -0
  16. moleditpy/modules/calculation_worker.py +754 -0
  17. moleditpy/modules/color_settings_dialog.py +309 -0
  18. moleditpy/modules/constants.py +76 -0
  19. moleditpy/modules/constrained_optimization_dialog.py +667 -0
  20. moleditpy/modules/custom_interactor_style.py +737 -0
  21. moleditpy/modules/custom_qt_interactor.py +49 -0
  22. moleditpy/modules/dialog3_d_picking_mixin.py +96 -0
  23. moleditpy/modules/dihedral_dialog.py +431 -0
  24. moleditpy/modules/main_window.py +830 -0
  25. moleditpy/modules/main_window_app_state.py +747 -0
  26. moleditpy/modules/main_window_compute.py +1203 -0
  27. moleditpy/modules/main_window_dialog_manager.py +454 -0
  28. moleditpy/modules/main_window_edit_3d.py +531 -0
  29. moleditpy/modules/main_window_edit_actions.py +1449 -0
  30. moleditpy/modules/main_window_export.py +744 -0
  31. moleditpy/modules/main_window_main_init.py +1668 -0
  32. moleditpy/modules/main_window_molecular_parsers.py +1037 -0
  33. moleditpy/modules/main_window_project_io.py +429 -0
  34. moleditpy/modules/main_window_string_importers.py +270 -0
  35. moleditpy/modules/main_window_ui_manager.py +567 -0
  36. moleditpy/modules/main_window_view_3d.py +1211 -0
  37. moleditpy/modules/main_window_view_loaders.py +350 -0
  38. moleditpy/modules/mirror_dialog.py +110 -0
  39. moleditpy/modules/molecular_data.py +290 -0
  40. moleditpy/modules/molecule_scene.py +1964 -0
  41. moleditpy/modules/move_group_dialog.py +586 -0
  42. moleditpy/modules/periodic_table_dialog.py +72 -0
  43. moleditpy/modules/planarize_dialog.py +209 -0
  44. moleditpy/modules/settings_dialog.py +1071 -0
  45. moleditpy/modules/template_preview_item.py +148 -0
  46. moleditpy/modules/template_preview_view.py +62 -0
  47. moleditpy/modules/translation_dialog.py +353 -0
  48. moleditpy/modules/user_template_dialog.py +621 -0
  49. moleditpy/modules/zoomable_view.py +98 -0
  50. moleditpy-1.16.3.dist-info/METADATA +274 -0
  51. moleditpy-1.16.3.dist-info/RECORD +54 -0
  52. moleditpy-1.16.3.dist-info/WHEEL +5 -0
  53. moleditpy-1.16.3.dist-info/entry_points.txt +2 -0
  54. moleditpy-1.16.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,336 @@
1
+ from PyQt6.QtWidgets import QGraphicsItem
2
+
3
+ from PyQt6.QtGui import (
4
+ QPen, QBrush, QColor, QFont, QPainterPath, QFontMetricsF
5
+ )
6
+
7
+ from PyQt6.QtCore import (
8
+ Qt, QPointF, QRectF
9
+ )
10
+
11
+ try:
12
+ from .constants import (
13
+ ATOM_RADIUS, DESIRED_ATOM_PIXEL_RADIUS,
14
+ FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD,
15
+ CPK_COLORS,
16
+ )
17
+ except Exception:
18
+ from modules.constants import (
19
+ ATOM_RADIUS, DESIRED_ATOM_PIXEL_RADIUS,
20
+ FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD,
21
+ CPK_COLORS,
22
+ )
23
+
24
+ try:
25
+ from . import sip_isdeleted_safe
26
+ except Exception:
27
+ from modules import sip_isdeleted_safe
28
+
29
+ class AtomItem(QGraphicsItem):
30
+ def __init__(self, atom_id, symbol, pos, charge=0, radical=0):
31
+ super().__init__()
32
+ self.atom_id, self.symbol, self.charge, self.radical, self.bonds, self.chiral_label = atom_id, symbol, charge, radical, [], None
33
+ self.setPos(pos)
34
+ self.implicit_h_count = 0
35
+ self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
36
+ self.setZValue(1); self.font = QFont(FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD); self.update_style()
37
+ self.setAcceptHoverEvents(True)
38
+ self.hovered = False
39
+ self.has_problem = False
40
+
41
+ def boundingRect(self):
42
+ # --- paint()メソッドと完全に同じロジックでテキストの位置とサイズを計算 ---
43
+ font = QFont(FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD)
44
+ fm = QFontMetricsF(font)
45
+
46
+ hydrogen_part = ""
47
+ if self.implicit_h_count > 0:
48
+ is_skeletal_carbon = (self.symbol == 'C' and
49
+ self.charge == 0 and
50
+ self.radical == 0 and
51
+ len(self.bonds) > 0)
52
+ if not is_skeletal_carbon:
53
+ hydrogen_part = "H"
54
+ if self.implicit_h_count > 1:
55
+ subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
56
+ hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
57
+
58
+ flip_text = False
59
+ if hydrogen_part and self.bonds:
60
+ my_pos_x = self.pos().x()
61
+ total_dx = 0.0
62
+ # Defensive: some bonds may have missing atom references (None) or C++ wrappers
63
+ # that have been deleted. Iterate and accumulate only valid partner positions.
64
+ for b in self.bonds:
65
+ # partner is the atom at the other end of the bond
66
+ partner = b.atom2 if b.atom1 is self else b.atom1
67
+ try:
68
+ if partner is None:
69
+ continue
70
+ # If SIP reports the wrapper as deleted, skip it
71
+ if sip_isdeleted_safe(partner):
72
+ continue
73
+ partner_pos = partner.pos()
74
+ if partner_pos is None:
75
+ continue
76
+ total_dx += partner_pos.x() - my_pos_x
77
+ except Exception:
78
+ # Skip any bond that raises while inspecting; keep UI tolerant
79
+ continue
80
+
81
+ if total_dx > 0:
82
+ flip_text = True
83
+
84
+ if flip_text:
85
+ display_text = hydrogen_part + self.symbol
86
+ else:
87
+ display_text = self.symbol + hydrogen_part
88
+
89
+ text_rect = fm.boundingRect(display_text)
90
+ text_rect.adjust(-2, -2, 2, 2)
91
+ if hydrogen_part:
92
+ symbol_rect = fm.boundingRect(self.symbol)
93
+ if flip_text:
94
+ offset_x = symbol_rect.width() // 2
95
+ text_rect.moveTo(offset_x - text_rect.width(), -text_rect.height() / 2)
96
+ else:
97
+ offset_x = -symbol_rect.width() // 2
98
+ text_rect.moveTo(offset_x, -text_rect.height() / 2)
99
+ else:
100
+ text_rect.moveCenter(QPointF(0, 0))
101
+
102
+ # 1. paint()で描画される背景の矩形(bg_rect)を計算する
103
+ bg_rect = text_rect.adjusted(-5, -8, 5, 8)
104
+
105
+ # 2. このbg_rectを基準として全体の描画領域を構築する
106
+ full_visual_rect = QRectF(bg_rect)
107
+
108
+ # 電荷記号の領域を計算に含める
109
+ if self.charge != 0:
110
+ # Chemical convention: single charge as "+"/"-", multiple as "2+"/"2-"
111
+ if self.charge == 1:
112
+ charge_str = "+"
113
+ elif self.charge == -1:
114
+ charge_str = "-"
115
+ else:
116
+ sign = '+' if self.charge > 0 else '-'
117
+ charge_str = f"{abs(self.charge)}{sign}"
118
+ charge_font = QFont("Arial", 12, QFont.Weight.Bold)
119
+ charge_fm = QFontMetricsF(charge_font)
120
+ charge_rect = charge_fm.boundingRect(charge_str)
121
+
122
+ if flip_text:
123
+ charge_pos = QPointF(text_rect.left() - charge_rect.width() - 2, text_rect.top())
124
+ else:
125
+ charge_pos = QPointF(text_rect.right() + 2, text_rect.top())
126
+ charge_rect.moveTopLeft(charge_pos)
127
+ full_visual_rect = full_visual_rect.united(charge_rect)
128
+
129
+ # ラジカル記号の領域を計算に含める
130
+ if self.radical > 0:
131
+ radical_area = QRectF(text_rect.center().x() - 8, text_rect.top() - 8, 16, 8)
132
+ full_visual_rect = full_visual_rect.united(radical_area)
133
+
134
+ # 3. 選択ハイライト等のための最終的なマージンを追加する
135
+ return full_visual_rect.adjusted(-3, -3, 3, 3)
136
+
137
+ def shape(self):
138
+ scene = self.scene()
139
+ if not scene or not scene.views():
140
+ path = QPainterPath()
141
+ hit_r = max(4.0, ATOM_RADIUS - 6.0) * 2
142
+ path.addEllipse(QRectF(-hit_r, -hit_r, hit_r * 2.0, hit_r * 2.0))
143
+ return path
144
+
145
+ view = scene.views()[0]
146
+ scale = view.transform().m11()
147
+
148
+ scene_radius = DESIRED_ATOM_PIXEL_RADIUS / scale
149
+
150
+ path = QPainterPath()
151
+ path.addEllipse(QPointF(0, 0), scene_radius, scene_radius)
152
+ return path
153
+
154
+ def paint(self, painter, option, widget):
155
+ color = CPK_COLORS.get(self.symbol, CPK_COLORS['DEFAULT'])
156
+ if self.is_visible:
157
+ # 1. 描画の準備
158
+ painter.setFont(self.font)
159
+ fm = painter.fontMetrics()
160
+
161
+ # --- 水素部分のテキストを作成 ---
162
+ hydrogen_part = ""
163
+ if self.implicit_h_count > 0:
164
+ is_skeletal_carbon = (self.symbol == 'C' and
165
+ self.charge == 0 and
166
+ self.radical == 0 and
167
+ len(self.bonds) > 0)
168
+ if not is_skeletal_carbon:
169
+ hydrogen_part = "H"
170
+ if self.implicit_h_count > 1:
171
+ subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
172
+ hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
173
+
174
+ # --- テキストを反転させるか決定 ---
175
+ flip_text = False
176
+ # 水素ラベルがあり、結合が1本以上ある場合のみ反転を考慮
177
+ if hydrogen_part and self.bonds:
178
+
179
+ # 相対的なX座標で、結合が左右どちらに偏っているか判定
180
+ my_pos_x = self.pos().x()
181
+ total_dx = 0.0
182
+ # Defensive: some bonds may have missing atom references (None) or
183
+ # wrappers that were deleted by SIP. Only accumulate valid partner positions.
184
+ for bond in self.bonds:
185
+ try:
186
+ other_atom = bond.atom1 if bond.atom2 is self else bond.atom2
187
+ if other_atom is None:
188
+ continue
189
+ # If SIP reports the wrapper as deleted, skip it
190
+ try:
191
+ if sip_isdeleted_safe(other_atom):
192
+ continue
193
+ except Exception:
194
+ # If sip check fails, continue defensively
195
+ pass
196
+
197
+ other_pos = None
198
+ try:
199
+ other_pos = other_atom.pos()
200
+ except Exception:
201
+ # Accessing .pos() may raise if the C++ object was destroyed
202
+ other_pos = None
203
+
204
+ if other_pos is None:
205
+ continue
206
+
207
+ total_dx += (other_pos.x() - my_pos_x)
208
+ except Exception:
209
+ # Skip any problematic bond/partner rather than crashing the paint
210
+ continue
211
+
212
+ # 結合が主に右側にある場合はテキストを反転させる
213
+ if total_dx > 0:
214
+ flip_text = True
215
+
216
+ # --- 表示テキストとアライメントを最終決定 ---
217
+ if flip_text:
218
+ display_text = hydrogen_part + self.symbol
219
+ alignment_flag = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
220
+ else:
221
+ display_text = self.symbol + hydrogen_part
222
+ alignment_flag = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
223
+
224
+ text_rect = fm.boundingRect(display_text)
225
+ text_rect.adjust(-2, -2, 2, 2)
226
+ symbol_rect = fm.boundingRect(self.symbol) # 主元素のみの幅を計算
227
+
228
+ # --- テキストの描画位置を決定 ---
229
+ # 水素ラベルがない場合 (従来通り中央揃え)
230
+ if not hydrogen_part:
231
+ alignment_flag = Qt.AlignmentFlag.AlignCenter
232
+ text_rect.moveCenter(QPointF(0, 0).toPoint())
233
+ # 水素ラベルがあり、反転する場合 (右揃え)
234
+ elif flip_text:
235
+ # 主元素の中心が原子の中心に来るように、矩形の右端を調整
236
+ offset_x = symbol_rect.width() // 2
237
+ text_rect.moveTo(offset_x - text_rect.width(), -text_rect.height() // 2)
238
+ # 水素ラベルがあり、反転しない場合 (左揃え)
239
+ else:
240
+ # 主元素の中心が原子の中心に来るように、矩形の左端を調整
241
+ offset_x = -symbol_rect.width() // 2
242
+ text_rect.moveTo(offset_x, -text_rect.height() // 2)
243
+
244
+ # 2. 原子記号の背景を白で塗りつぶす
245
+ if self.scene():
246
+ bg_brush = self.scene().backgroundBrush()
247
+ bg_rect = text_rect.adjusted(-5, -8, 5, 8)
248
+ painter.setBrush(bg_brush)
249
+ painter.setPen(Qt.PenStyle.NoPen)
250
+ painter.drawEllipse(bg_rect)
251
+
252
+ # 3. 原子記号自体を描画
253
+ if self.symbol == 'H':
254
+ painter.setPen(QPen(Qt.GlobalColor.black))
255
+ else:
256
+ painter.setPen(QPen(color))
257
+ painter.drawText(text_rect, int(alignment_flag), display_text)
258
+
259
+ # --- 電荷とラジカルの描画 ---
260
+ if self.charge != 0:
261
+ # Chemical convention: single charge as "+"/"-", multiple as "2+"/"2-"
262
+ if self.charge == 1:
263
+ charge_str = "+"
264
+ elif self.charge == -1:
265
+ charge_str = "-"
266
+ else:
267
+ sign = '+' if self.charge > 0 else '-'
268
+ charge_str = f"{abs(self.charge)}{sign}"
269
+ charge_font = QFont("Arial", 12, QFont.Weight.Bold)
270
+ painter.setFont(charge_font)
271
+ charge_rect = painter.fontMetrics().boundingRect(charge_str)
272
+ # 電荷の位置も反転に対応
273
+ if flip_text:
274
+ charge_pos = QPointF(text_rect.left() - charge_rect.width() -2, text_rect.top() + charge_rect.height() - 2)
275
+ else:
276
+ charge_pos = QPointF(text_rect.right() + 2, text_rect.top() + charge_rect.height() - 2)
277
+ painter.setPen(Qt.GlobalColor.black)
278
+ painter.drawText(charge_pos, charge_str)
279
+
280
+ if self.radical > 0:
281
+ painter.setBrush(QBrush(Qt.GlobalColor.black))
282
+ painter.setPen(Qt.PenStyle.NoPen)
283
+ radical_pos_y = text_rect.top() - 5
284
+ if self.radical == 1:
285
+ painter.drawEllipse(QPointF(text_rect.center().x(), radical_pos_y), 3, 3)
286
+ elif self.radical == 2:
287
+ painter.drawEllipse(QPointF(text_rect.center().x() - 5, radical_pos_y), 3, 3)
288
+ painter.drawEllipse(QPointF(text_rect.center().x() + 5, radical_pos_y), 3, 3)
289
+
290
+
291
+ # --- 選択時のハイライトなど ---
292
+ if self.has_problem:
293
+ painter.setBrush(Qt.BrushStyle.NoBrush)
294
+ painter.setPen(QPen(QColor(255, 0, 0, 200), 4))
295
+ painter.drawRect(self.boundingRect())
296
+ elif self.isSelected():
297
+ painter.setBrush(Qt.BrushStyle.NoBrush)
298
+ painter.setPen(QPen(QColor(0, 100, 255), 3))
299
+ painter.drawRect(self.boundingRect())
300
+ if (not self.isSelected()) and getattr(self, 'hovered', False):
301
+ pen = QPen(QColor(144, 238, 144, 200), 3)
302
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
303
+ painter.setBrush(Qt.BrushStyle.NoBrush)
304
+ painter.setPen(pen)
305
+ painter.drawRect(self.boundingRect())
306
+
307
+ def update_style(self):
308
+ self.is_visible = not (self.symbol == 'C' and len(self.bonds) > 0 and self.charge == 0 and self.radical == 0)
309
+ self.update()
310
+
311
+
312
+ # 約203行目 AtomItem クラス内
313
+
314
+ def itemChange(self, change, value):
315
+ res = super().itemChange(change, value)
316
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
317
+ if self.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsMovable:
318
+ # Prevent cascading updates during batch operations
319
+ if not getattr(self, '_updating_position', False):
320
+ for bond in self.bonds:
321
+ if bond.scene(): # Only update if bond is still in scene
322
+ bond.update_position()
323
+
324
+ return res
325
+
326
+ def hoverEnterEvent(self, event):
327
+ # シーンのモードにかかわらず、ホバー時にハイライトを有効にする
328
+ self.hovered = True
329
+ self.update()
330
+ super().hoverEnterEvent(event)
331
+
332
+ def hoverLeaveEvent(self, event):
333
+ if self.hovered:
334
+ self.hovered = False
335
+ self.update()
336
+ super().hoverLeaveEvent(event)
@@ -0,0 +1,303 @@
1
+ from PyQt6.QtWidgets import QGraphicsItem, QGraphicsScene
2
+
3
+ from PyQt6.QtGui import (
4
+ QPen, QBrush, QColor, QFont, QPolygonF,
5
+ QPainterPath, QPainterPathStroker, QFontMetricsF
6
+ )
7
+
8
+ from PyQt6.QtCore import (
9
+ Qt, QPointF, QRectF, QLineF
10
+ )
11
+
12
+ try:
13
+ from .constants import (
14
+ EZ_LABEL_BOX_SIZE, EZ_LABEL_TEXT_OUTLINE, EZ_LABEL_MARGIN,
15
+ BOND_OFFSET, FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD,
16
+ HOVER_PEN_WIDTH, DESIRED_BOND_PIXEL_WIDTH,
17
+ )
18
+ except Exception:
19
+ from modules.constants import (
20
+ EZ_LABEL_BOX_SIZE, EZ_LABEL_TEXT_OUTLINE, EZ_LABEL_MARGIN,
21
+ BOND_OFFSET, FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD,
22
+ HOVER_PEN_WIDTH, DESIRED_BOND_PIXEL_WIDTH,
23
+ )
24
+
25
+ class BondItem(QGraphicsItem):
26
+
27
+ def get_ez_label_rect(self):
28
+ """E/Zラベルの描画範囲(シーン座標)を返す。ラベルが無い場合はNone。"""
29
+ if self.order != 2 or self.stereo not in [3, 4]:
30
+ return None
31
+ line = self.get_line_in_local_coords()
32
+ center = line.center()
33
+ label_width = EZ_LABEL_BOX_SIZE
34
+ label_height = EZ_LABEL_BOX_SIZE
35
+ label_rect = QRectF(center.x() - label_width/2, center.y() - label_height/2, label_width, label_height)
36
+ # シーン座標に変換
37
+ return self.mapToScene(label_rect).boundingRect()
38
+ def set_stereo(self, new_stereo):
39
+ try:
40
+ # ラベルを消す場合は、消す前のboundingRectをscene().invalidateで強制的に無効化
41
+ if new_stereo == 0 and self.stereo in [3, 4] and self.scene():
42
+ rect = self.mapToScene(self.boundingRect()).boundingRect()
43
+ self.scene().invalidate(rect, QGraphicsScene.SceneLayer.BackgroundLayer | QGraphicsScene.SceneLayer.ForegroundLayer)
44
+
45
+ self.prepareGeometryChange()
46
+ self.stereo = new_stereo
47
+ self.update()
48
+
49
+ if self.scene() and self.scene().views():
50
+ try:
51
+ self.scene().views()[0].viewport().update()
52
+ except (IndexError, RuntimeError):
53
+ # Handle case where views are being destroyed
54
+ pass
55
+
56
+ except Exception as e:
57
+ print(f"Error in BondItem.set_stereo: {e}")
58
+ # Continue without crashing
59
+ self.stereo = new_stereo
60
+
61
+ def set_order(self, new_order):
62
+ self.prepareGeometryChange()
63
+ self.order = new_order
64
+ self.update()
65
+ if self.scene() and self.scene().views():
66
+ self.scene().views()[0].viewport().update()
67
+ def __init__(self, atom1_item, atom2_item, order=1, stereo=0):
68
+ super().__init__()
69
+ # Validate input parameters
70
+ if atom1_item is None or atom2_item is None:
71
+ raise ValueError("BondItem requires non-None atom items")
72
+ self.atom1, self.atom2, self.order, self.stereo = atom1_item, atom2_item, order, stereo
73
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
74
+ self.pen = QPen(Qt.GlobalColor.black, 2)
75
+ self.setZValue(0)
76
+ self.update_position()
77
+ self.setAcceptHoverEvents(True)
78
+ self.hovered = False
79
+
80
+
81
+ def get_line_in_local_coords(self):
82
+ if self.atom1 is None or self.atom2 is None:
83
+ return QLineF(0, 0, 0, 0)
84
+ try:
85
+ p2 = self.mapFromItem(self.atom2, 0, 0)
86
+ return QLineF(QPointF(0, 0), p2)
87
+ except (RuntimeError, TypeError):
88
+ # Handle case where atoms are deleted from scene
89
+ return QLineF(0, 0, 0, 0)
90
+
91
+ def boundingRect(self):
92
+ try:
93
+ line = self.get_line_in_local_coords()
94
+ except Exception:
95
+ line = QLineF(0, 0, 0, 0)
96
+ bond_offset = globals().get('BOND_OFFSET', 2)
97
+ extra = (getattr(self, 'order', 1) - 1) * bond_offset + 20
98
+ rect = QRectF(line.p1(), line.p2()).normalized().adjusted(-extra, -extra, extra, extra)
99
+
100
+ # E/Zラベルの描画範囲も考慮して拡張(QFontMetricsFで正確に)
101
+ if self.order == 2 and self.stereo in [3, 4]:
102
+ font = QFont(FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD)
103
+ font.setItalic(True)
104
+ text = "Z" if self.stereo == 3 else "E"
105
+ fm = QFontMetricsF(font)
106
+ text_rect = fm.boundingRect(text)
107
+ outline = EZ_LABEL_TEXT_OUTLINE # 輪郭の太さ分
108
+ margin = EZ_LABEL_MARGIN # 追加余白
109
+ center = line.center()
110
+ label_rect = QRectF(center.x() - text_rect.width()/2 - outline - margin,
111
+ center.y() - text_rect.height()/2 - outline - margin,
112
+ text_rect.width() + 2*outline + 2*margin,
113
+ text_rect.height() + 2*outline + 2*margin)
114
+ rect = rect.united(label_rect)
115
+ return rect
116
+
117
+ def shape(self):
118
+ path = QPainterPath()
119
+ try:
120
+ line = self.get_line_in_local_coords()
121
+ except Exception:
122
+ return path
123
+ if line.length() == 0:
124
+ return path
125
+
126
+ scene = self.scene()
127
+ if not scene or not scene.views():
128
+ return super().shape()
129
+
130
+ view = scene.views()[0]
131
+ scale = view.transform().m11()
132
+
133
+ scene_width = DESIRED_BOND_PIXEL_WIDTH / scale
134
+
135
+ stroker = QPainterPathStroker()
136
+ stroker.setWidth(scene_width)
137
+ stroker.setCapStyle(Qt.PenCapStyle.RoundCap)
138
+ stroker.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
139
+
140
+ center_line_path = QPainterPath(line.p1())
141
+ center_line_path.lineTo(line.p2())
142
+
143
+ return stroker.createStroke(center_line_path)
144
+
145
+ def paint(self, painter, option, widget):
146
+ if self.atom1 is None or self.atom2 is None:
147
+ return
148
+ line = self.get_line_in_local_coords()
149
+ if line.length() == 0: return
150
+
151
+ # --- 1. 選択状態に応じてペンとブラシを準備 ---
152
+ if self.isSelected():
153
+ selection_color = QColor("blue")
154
+ painter.setPen(QPen(selection_color, 3))
155
+ painter.setBrush(QBrush(selection_color))
156
+ else:
157
+ # Allow bond color override from app settings (2D color)
158
+ try:
159
+ sc = self.scene()
160
+ if sc is not None and hasattr(sc, 'window') and sc.window is not None:
161
+ bond_hex = sc.window.settings.get('bond_color', '#222222')
162
+ bond_color = QColor(bond_hex)
163
+ painter.setPen(QPen(bond_color, 2))
164
+ else:
165
+ painter.setPen(self.pen)
166
+ except Exception:
167
+ painter.setPen(self.pen)
168
+ painter.setBrush(QBrush(Qt.GlobalColor.black))
169
+
170
+ # --- 立体化学 (Wedge/Dash) の描画 ---
171
+ if self.order == 1 and self.stereo in [1, 2]:
172
+ vec = line.unitVector()
173
+ normal = vec.normalVector()
174
+ p1 = line.p1() + vec.p2() * 5
175
+ p2 = line.p2() - vec.p2() * 5
176
+
177
+ if self.stereo == 1: # Wedge (くさび形)
178
+ offset = QPointF(normal.dx(), normal.dy()) * 6.0
179
+ poly = QPolygonF([p1, p2 + offset, p2 - offset])
180
+ painter.drawPolygon(poly)
181
+
182
+ elif self.stereo == 2: # Dash (破線)
183
+ painter.save()
184
+ if not self.isSelected():
185
+ pen = painter.pen()
186
+ pen.setWidthF(2.5)
187
+ painter.setPen(pen)
188
+
189
+ num_dashes = 8
190
+ for i in range(num_dashes + 1):
191
+ t = i / num_dashes
192
+ start_pt = p1 * (1 - t) + p2 * t
193
+ width = 12.0 * t
194
+ offset = QPointF(normal.dx(), normal.dy()) * width / 2.0
195
+ painter.drawLine(start_pt - offset, start_pt + offset)
196
+ painter.restore()
197
+
198
+ # --- 通常の結合 (単/二重/三重) の描画 ---
199
+ else:
200
+ if self.order == 1:
201
+ painter.drawLine(line)
202
+ else:
203
+ v = line.unitVector().normalVector()
204
+ offset = QPointF(v.dx(), v.dy()) * BOND_OFFSET
205
+
206
+ if self.order == 2:
207
+ # -------------------- ここから差し替え --------------------)
208
+ line1 = line.translated(offset)
209
+ line2 = line.translated(-offset)
210
+ painter.drawLine(line1)
211
+ painter.drawLine(line2)
212
+
213
+ # E/Z ラベルの描画処理
214
+ if self.stereo in [3, 4]:
215
+ painter.save() # 現在の描画設定を保存
216
+
217
+ # --- ラベルの設定 ---
218
+ font = QFont(FONT_FAMILY, FONT_SIZE_LARGE, FONT_WEIGHT_BOLD)
219
+ font.setItalic(True)
220
+ text_color = QColor("gray")
221
+ # 輪郭の色を背景色と同じにする(scene()がNoneのときは安全なフォールバックを使う)
222
+ outline_color = None
223
+ try:
224
+ sc = self.scene()
225
+ if sc is not None:
226
+ outline_color = sc.backgroundBrush().color()
227
+ except Exception:
228
+ outline_color = None
229
+ if outline_color is None:
230
+ # デフォルトでは白背景を想定して黒系の輪郭が見やすい
231
+ outline_color = QColor(255, 255, 255)
232
+
233
+ # --- 描画パスの作成 ---
234
+ text = "Z" if self.stereo == 3 else "E"
235
+ path = QPainterPath()
236
+
237
+ # テキストが正確に中央に来るように位置を計算
238
+ fm = QFontMetricsF(font)
239
+ text_rect = fm.boundingRect(text)
240
+ text_rect.moveCenter(line.center())
241
+ path.addText(text_rect.topLeft(), font, text)
242
+
243
+ # --- 輪郭の描画 ---
244
+ stroker = QPainterPathStroker()
245
+ stroker.setWidth(EZ_LABEL_TEXT_OUTLINE) # 輪郭の太さ
246
+ outline_path = stroker.createStroke(path)
247
+
248
+ painter.setBrush(outline_color)
249
+ painter.setPen(Qt.PenStyle.NoPen)
250
+ painter.drawPath(outline_path)
251
+
252
+ # --- 文字本体の描画 ---
253
+ painter.setBrush(text_color)
254
+ painter.setPen(text_color)
255
+ painter.drawPath(path)
256
+
257
+ painter.restore() # 描画設定を元に戻す
258
+
259
+ elif self.order == 3:
260
+ painter.drawLine(line)
261
+ painter.drawLine(line.translated(offset))
262
+ painter.drawLine(line.translated(-offset))
263
+
264
+ # --- 2. ホバー時のエフェクトを上から重ねて描画 ---
265
+ if (not self.isSelected()) and getattr(self, 'hovered', False):
266
+ try:
267
+ # ホバー時のハイライトを太めの半透明な線で描画
268
+ hover_pen = QPen(QColor(144, 238, 144, 180), HOVER_PEN_WIDTH) # LightGreen, 半透明
269
+ hover_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
270
+ painter.setPen(hover_pen)
271
+ painter.drawLine(line)
272
+ except Exception:
273
+ pass
274
+
275
+
276
+
277
+ def update_position(self):
278
+ try:
279
+ self.prepareGeometryChange()
280
+ if self.atom1:
281
+ self.setPos(self.atom1.pos())
282
+ self.update()
283
+ except Exception as e:
284
+ print(f"Error updating bond position: {e}")
285
+ # Continue without crashing
286
+
287
+
288
+ def hoverEnterEvent(self, event):
289
+ scene = self.scene()
290
+ mode = getattr(scene, 'mode', '')
291
+ self.hovered = True
292
+ self.update()
293
+ if self.scene():
294
+ self.scene().set_hovered_item(self)
295
+ super().hoverEnterEvent(event)
296
+
297
+ def hoverLeaveEvent(self, event):
298
+ if self.hovered:
299
+ self.hovered = False
300
+ self.update()
301
+ if self.scene():
302
+ self.scene().set_hovered_item(None)
303
+ super().hoverLeaveEvent(event)