moleditpy 0.1.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.
moleditpy/main.py ADDED
@@ -0,0 +1,2289 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ moledit.py — simple molecule editor
5
+ Short: Atom/bond edit, template preview, keyboard shortcuts (space, 1/2/3, Del etc.).
6
+ Author: HiroYokoyama
7
+ License: Apache-2.0 license
8
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
9
+ """
10
+
11
+ import sys
12
+ import numpy as np
13
+ import pickle
14
+ import copy
15
+ import math
16
+ import io
17
+
18
+ # PyQt6 Modules
19
+ from PyQt6.QtWidgets import (
20
+ QApplication, QMainWindow, QWidget, QVBoxLayout,
21
+ QPushButton, QSplitter, QGraphicsView, QGraphicsScene, QGraphicsItem,
22
+ QToolBar, QStatusBar, QGraphicsTextItem, QGraphicsLineItem, QDialog, QGridLayout,
23
+ QFileDialog, QSizePolicy, QLabel, QLineEdit, QToolButton, QMenu
24
+ )
25
+ from PyQt6.QtGui import (
26
+ QPen, QBrush, QColor, QPainter, QAction, QActionGroup, QFont, QPolygonF,
27
+ QPainterPath, QFontMetrics, QKeySequence, QTransform, QCursor, QPixmap, QIcon
28
+ )
29
+ from PyQt6.QtCore import Qt, QPointF, QRectF, QLineF, QObject, QThread, pyqtSignal, QEvent, QMimeData, QByteArray
30
+
31
+
32
+ # RDKit
33
+ from rdkit import Chem
34
+ from rdkit.Chem import AllChem
35
+ from rdkit.Chem import Descriptors
36
+ from rdkit.Chem import rdMolDescriptors
37
+
38
+ # PyVista
39
+ import pyvista as pv
40
+ from pyvistaqt import QtInteractor
41
+
42
+ # --- Constants ---
43
+ ATOM_RADIUS = 18
44
+ BOND_OFFSET = 3.5
45
+ DEFAULT_BOND_LENGTH = 75.0 # テンプレートで使用する標準結合長
46
+ CLIPBOARD_MIME_TYPE = "application/x-moleditpy-fragment"
47
+
48
+ CPK_COLORS = {
49
+ 'H': QColor('#FFFFFF'), 'C': QColor('#222222'), 'N': QColor('#3377FF'), 'O': QColor('#FF3333'), 'F': QColor('#33FF33'),
50
+ 'Cl': QColor('#33FF33'), 'Br': QColor('#A52A2A'), 'I': QColor('#9400D3'), 'S': QColor('#FFC000'), 'P': QColor('#FFA500'),
51
+ 'Si': QColor('#DAA520'), 'B': QColor('#FA8072'), 'He': QColor('#D9FFFF'), 'Ne': QColor('#B3E3F5'), 'Ar': QColor('#80D1E3'),
52
+ 'Kr': QColor('#5CACC8'), 'Xe': QColor('#429EB0'), 'Rn': QColor('#298FA2'), 'Li': QColor('#CC80FF'), 'Na': QColor('#AB5CF2'),
53
+ 'K': QColor('#8F44D7'), 'Rb': QColor('#702EBC'), 'Cs': QColor('#561B9E'), 'Fr': QColor('#421384'), 'Be': QColor('#C2FF00'),
54
+ 'Mg': QColor('#8AFF00'), 'Ca': QColor('#3DFF00'), 'Sr': QColor('#00FF00'), 'Ba': QColor('#00E600'), 'Ra': QColor('#00B800'),
55
+ 'Sc': QColor('#E6E6E6'), 'Ti': QColor('#BFC2C7'), 'V': QColor('#A6A6AB'), 'Cr': QColor('#8A99C7'), 'Mn': QColor('#9C7AC7'),
56
+ 'Fe': QColor('#E06633'), 'Co': QColor('#F090A0'), 'Ni': QColor('#50D050'), 'Cu': QColor('#C88033'), 'Zn': QColor('#7D80B0'),
57
+ 'Ga': QColor('#C28F8F'), 'Ge': QColor('#668F8F'), 'As': QColor('#BD80E3'), 'Se': QColor('#FFA100'), 'Tc': QColor('#3B9E9E'),
58
+ 'Ru': QColor('#248F8F'), 'Rh': QColor('#0A7D8F'), 'Pd': QColor('#006985'), 'Ag': QColor('#C0C0C0'), 'Cd': QColor('#FFD700'),
59
+ 'In': QColor('#A67573'), 'Sn': QColor('#668080'), 'Sb': QColor('#9E63B5'), 'Te': QColor('#D47A00'), 'La': QColor('#70D4FF'),
60
+ 'Ce': QColor('#FFFFC7'), 'Pr': QColor('#D9FFC7'), 'Nd': QColor('#C7FFC7'), 'Pm': QColor('#A3FFC7'), 'Sm': QColor('#8FFFC7'),
61
+ 'Eu': QColor('#61FFC7'), 'Gd': QColor('#45FFC7'), 'Tb': QColor('#30FFC7'), 'Dy': QColor('#1FFFC7'), 'Ho': QColor('#00FF9C'),
62
+ 'Er': QColor('#00E675'), 'Tm': QColor('#00D452'), 'Yb': QColor('#00BF38'), 'Lu': QColor('#00AB24'), 'Hf': QColor('#4DC2FF'),
63
+ 'Ta': QColor('#4DA6FF'), 'W': QColor('#2194D6'), 'Re': QColor('#267DAB'), 'Os': QColor('#266696'), 'Ir': QColor('#175487'),
64
+ 'Pt': QColor('#D0D0E0'), 'Au': QColor('#FFD123'), 'Hg': QColor('#B8B8D0'), 'Tl': QColor('#A6544D'), 'Pb': QColor('#575961'),
65
+ 'Bi': QColor('#9E4FB5'), 'Po': QColor('#AB5C00'), 'At': QColor('#754F45'), 'Ac': QColor('#70ABFA'), 'Th': QColor('#00BAFF'),
66
+ 'Pa': QColor('#00A1FF'), 'U': QColor('#008FFF'), 'Np': QColor('#0080FF'), 'Pu': QColor('#006BFF'), 'Am': QColor('#545CF2'),
67
+ 'Cm': QColor('#785CE3'), 'Bk': QColor('#8A4FE3'), 'Cf': QColor('#A136D4'), 'Es': QColor('#B31FD4'), 'Fm': QColor('#B31FBA'),
68
+ 'Md': QColor('#B30DA6'), 'No': QColor('#BD0D87'), 'Lr': QColor('#C70066'), 'DEFAULT': QColor('#FF1493') # Pink fallback
69
+ }
70
+ CPK_COLORS_PV = {
71
+ k: [c.redF(), c.greenF(), c.blueF()] for k, c in CPK_COLORS.items()
72
+ }
73
+
74
+ pt = Chem.GetPeriodicTable()
75
+ VDW_RADII = {pt.GetElementSymbol(i): pt.GetRvdw(i) * 0.3 for i in range(1, 119)}
76
+
77
+ def main():
78
+ app = QApplication(sys.argv)
79
+ window = MainWindow()
80
+ window.show()
81
+ sys.exit(app.exec())
82
+
83
+
84
+ # --- Data Model ---
85
+ class MolecularData:
86
+ def __init__(self):
87
+ self.atoms = {}; self.bonds = {}; self._next_atom_id = 0
88
+ def add_atom(self, symbol, pos, charge=0, radical=0):
89
+ atom_id = self._next_atom_id
90
+ self.atoms[atom_id] = {'symbol': symbol, 'pos': pos, 'item': None, 'charge': charge, 'radical': radical}
91
+ self._next_atom_id += 1; return atom_id
92
+ def add_bond(self, id1, id2, order=1, stereo=0):
93
+ if id1 > id2: id1, id2 = id2, id1
94
+ bond_data = {'order': order, 'stereo': stereo, 'item': None}
95
+ if (id1, id2) in self.bonds:
96
+ self.bonds[(id1, id2)].update(bond_data); return (id1, id2), 'updated'
97
+ else:
98
+ self.bonds[(id1, id2)] = bond_data; return (id1, id2), 'created'
99
+ def remove_atom(self, atom_id):
100
+ if atom_id in self.atoms:
101
+ del self.atoms[atom_id]
102
+ bonds_to_remove = [key for key in self.bonds if atom_id in key]
103
+ for key in bonds_to_remove: del self.bonds[key]
104
+ def remove_bond(self, id1, id2):
105
+ if id1 > id2: id1, id2 = id2, id1
106
+ if (id1, id2) in self.bonds: del self.bonds[(id1, id2)]
107
+ def to_mol_block(self):
108
+ # (このメソッドは変更なし)
109
+ try:
110
+ mol = self.to_rdkit_mol()
111
+ if mol:
112
+ return Chem.MolToMolBlock(mol, includeStereo=True)
113
+ except Exception:
114
+ pass
115
+ if not self.atoms: return None
116
+ atom_map = {old_id: new_id for new_id, old_id in enumerate(self.atoms.keys())}
117
+ num_atoms, num_bonds = len(self.atoms), len(self.bonds)
118
+ mol_block = "\n PyQtEditor\n\n"
119
+ mol_block += f"{num_atoms:3d}{num_bonds:3d} 0 0 0 0 0 0 0 0999 V2000\n"
120
+ for old_id, atom in self.atoms.items():
121
+ x, y, z, symbol = atom['item'].pos().x(), -atom['item'].pos().y(), 0.0, atom['symbol']
122
+ charge = atom.get('charge', 0)
123
+
124
+ chg_code = 0
125
+ if charge == 3: chg_code = 1
126
+ elif charge == 2: chg_code = 2
127
+ elif charge == 1: chg_code = 3
128
+ elif charge == -1: chg_code = 5
129
+ elif charge == -2: chg_code = 6
130
+ elif charge == -3: chg_code = 7
131
+
132
+ mol_block += f"{x:10.4f}{y:10.4f}{z:10.4f} {symbol:<3} 0 0 0{chg_code:3d} 0 0 0 0 0 0 0\n"
133
+
134
+ for (id1, id2), bond in self.bonds.items():
135
+ idx1, idx2, order = atom_map[id1] + 1, atom_map[id2] + 1, bond['order']
136
+ mol_block += f"{idx1:3d}{idx2:3d}{order:3d} 0 0 0 0\n"
137
+ mol_block += "M END\n"
138
+ return mol_block
139
+
140
+ def to_rdkit_mol(self):
141
+ if not self.atoms: return None
142
+ mol = Chem.RWMol()
143
+ rdkit_atom_map = {}
144
+ for atom_id, atom_data in self.atoms.items():
145
+ atom = Chem.Atom(atom_data['symbol'])
146
+ # This line ensures the charge is transferred
147
+ atom.SetFormalCharge(atom_data.get('charge', 0))
148
+ # ラジカル電子数を設定
149
+ atom.SetNumRadicalElectrons(atom_data.get('radical', 0))
150
+ idx = mol.AddAtom(atom)
151
+ rdkit_atom_map[atom_id] = idx
152
+
153
+ for (id1, id2), bond_data in self.bonds.items():
154
+ idx1 = rdkit_atom_map[id1]
155
+ idx2 = rdkit_atom_map[id2]
156
+
157
+ order_val = float(bond_data['order'])
158
+ if order_val == 1.5:
159
+ order = Chem.BondType.AROMATIC
160
+ elif order_val == 2.0:
161
+ order = Chem.BondType.DOUBLE
162
+ elif order_val == 3.0:
163
+ order = Chem.BondType.TRIPLE
164
+ else:
165
+ order = Chem.BondType.SINGLE
166
+
167
+ mol.AddBond(idx1, idx2, order)
168
+
169
+ if bond_data.get('stereo', 0) != 0 and order == Chem.BondType.SINGLE:
170
+ bond = mol.GetBondBetweenAtoms(idx1, idx2)
171
+ if bond_data['stereo'] == 1: # Wedge
172
+ bond.SetBondDir(Chem.BondDir.BEGINWEDGE)
173
+ elif bond_data['stereo'] == 2: # Dash
174
+ bond.SetBondDir(Chem.BondDir.BEGINDASH)
175
+
176
+ mol = mol.GetMol()
177
+ try:
178
+ Chem.SanitizeMol(mol)
179
+ except Exception:
180
+ pass
181
+ return mol
182
+ # --- Custom 2D Graphics Items ---
183
+ class AtomItem(QGraphicsItem):
184
+ def __init__(self, atom_id, symbol, pos, charge=0, radical=0):
185
+ super().__init__()
186
+ self.atom_id, self.symbol, self.charge, self.radical, self.bonds = atom_id, symbol, charge, radical, []
187
+ self.setPos(pos)
188
+ self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
189
+ self.setZValue(1); self.font = QFont("Arial", 20, QFont.Weight.Bold); self.update_style()
190
+
191
+ def boundingRect(self):
192
+ r = globals().get('ATOM_RADIUS', 18)
193
+ extra = 18.0 # 元の値(15.0)から拡大し、電荷・ラジカル表示領域を確保
194
+ return QRectF(-r - extra, -r - extra, (r + extra) * 2.0, (r + extra) * 2.0)
195
+
196
+ def shape(self):
197
+ path = QPainterPath()
198
+ hit_r = max(4.0, ATOM_RADIUS - 6.0)
199
+ path.addEllipse(QRectF(-hit_r, -hit_r, hit_r * 2.0, hit_r * 2.0))
200
+ return path
201
+
202
+ def paint(self, painter, option, widget):
203
+ color = CPK_COLORS.get(self.symbol, CPK_COLORS['DEFAULT'])
204
+ if self.is_visible:
205
+ painter.setFont(self.font); fm = painter.fontMetrics()
206
+ text_rect = fm.boundingRect(self.symbol); text_rect.moveCenter(QPointF(0, 0).toPoint())
207
+ if self.scene():
208
+ bg_brush = self.scene().backgroundBrush(); bg_rect = text_rect.adjusted(-3, -3, 3, 3)
209
+ painter.setBrush(bg_brush); painter.setPen(Qt.PenStyle.NoPen); painter.drawRect(bg_rect)
210
+
211
+ path = QPainterPath()
212
+ path.addText(text_rect.left(), text_rect.bottom(), self.font, self.symbol)
213
+ painter.setPen(QPen(Qt.GlobalColor.black, 1));
214
+ if self.symbol == 'H': painter.setBrush(QBrush(color))
215
+ else: painter.setBrush(Qt.BrushStyle.NoBrush); painter.setPen(QPen(color))
216
+ painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self.symbol)
217
+
218
+ # 電荷の描画
219
+ if self.charge != 0:
220
+ if self.charge == 1: charge_str = "+"
221
+ elif self.charge == -1: charge_str = "-"
222
+ else: charge_str = f"{self.charge:+}"
223
+
224
+ charge_font = QFont("Arial", 12, QFont.Weight.Bold)
225
+ painter.setFont(charge_font)
226
+ charge_fm = QFontMetrics(charge_font)
227
+
228
+ # 原子記号の右上に配置
229
+ charge_rect = charge_fm.boundingRect(charge_str)
230
+ charge_pos_x = text_rect.right() + 2
231
+ charge_pos_y = text_rect.top() + charge_rect.height() - 2
232
+ painter.setPen(Qt.GlobalColor.black)
233
+ painter.drawText(QPointF(charge_pos_x, charge_pos_y), charge_str)
234
+
235
+ # ラジカルの描画 (大きな黒点)
236
+ if self.radical > 0:
237
+ painter.setBrush(QBrush(Qt.GlobalColor.black))
238
+ painter.setPen(Qt.PenStyle.NoPen)
239
+ # 原子記号の真上に配置
240
+ radical_pos_x = text_rect.center().x()
241
+ radical_pos_y = text_rect.top() - 5 # 5px上に
242
+ # ラジカルの数に応じて点を描画
243
+ if self.radical == 1: # Monoradical
244
+ painter.drawEllipse(QPointF(radical_pos_x, radical_pos_y), 3, 3)
245
+ elif self.radical == 2: # Diradical
246
+ painter.drawEllipse(QPointF(radical_pos_x - 5, radical_pos_y), 3, 3)
247
+ painter.drawEllipse(QPointF(radical_pos_x + 5, radical_pos_y), 3, 3)
248
+
249
+ if self.isSelected():
250
+ painter.setBrush(Qt.BrushStyle.NoBrush); painter.setPen(QPen(QColor(0, 100, 255), 3))
251
+ painter.drawRect(self.boundingRect())
252
+
253
+ def update_style(self):
254
+ self.is_visible = not (self.symbol == 'C' and len(self.bonds) > 0 and self.charge == 0 and self.radical == 0)
255
+ self.update()
256
+
257
+ # 約203行目 AtomItem クラス内
258
+
259
+ def itemChange(self, change, value):
260
+ res = super().itemChange(change, value)
261
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
262
+ if self.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsMovable:
263
+ for bond in self.bonds: bond.update_position()
264
+
265
+ return res
266
+
267
+ class BondItem(QGraphicsItem):
268
+ def __init__(self, atom1_item, atom2_item, order=1, stereo=0):
269
+ super().__init__()
270
+ self.atom1, self.atom2, self.order, self.stereo = atom1_item, atom2_item, order, stereo
271
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
272
+ self.pen = QPen(Qt.GlobalColor.black, 2)
273
+ self.setZValue(0)
274
+ self.update_position()
275
+
276
+ def get_line_in_local_coords(self):
277
+ p2 = self.mapFromItem(self.atom2, 0, 0)
278
+ return QLineF(QPointF(0, 0), p2)
279
+
280
+ def boundingRect(self):
281
+ try: line = self.get_line_in_local_coords()
282
+ except Exception: line = QLineF(0, 0, 0, 0)
283
+ bond_offset = globals().get('BOND_OFFSET', 2)
284
+ extra = (getattr(self, 'order', 1) - 1) * bond_offset + 15 # extraを拡大
285
+ return QRectF(line.p1(), line.p2()).normalized().adjusted(-extra, -extra, extra, extra)
286
+
287
+ def shape(self):
288
+ path = QPainterPath()
289
+ try: line = self.get_line_in_local_coords()
290
+ except Exception: return path
291
+ if line.length() == 0: return path
292
+
293
+ normal = line.normalVector()
294
+ offset = QPointF(normal.dx(), normal.dy()) * 12.0 # 当たり判定を拡大
295
+ poly = QPolygonF([line.p1() - offset, line.p1() + offset, line.p2() + offset, line.p2() - offset])
296
+ path.addPolygon(poly)
297
+ return path
298
+
299
+ def paint(self, painter, option, widget):
300
+ line = self.get_line_in_local_coords()
301
+ if line.length() == 0: return
302
+
303
+ current_pen = self.pen
304
+ if self.isSelected():
305
+ current_pen = QPen(QColor("blue"), 3)
306
+ painter.setPen(current_pen)
307
+ painter.setBrush(QBrush(Qt.GlobalColor.black))
308
+
309
+ # --- Stereo (Wedge/Dash) Drawing ---
310
+ if self.order == 1 and self.stereo in [1, 2]:
311
+ vec = line.unitVector()
312
+ normal = vec.normalVector()
313
+
314
+ p1 = line.p1() + vec.p2() * 5 # 少し原子から離す
315
+ p2 = line.p2() - vec.p2() * 5
316
+
317
+ if self.stereo == 1: # Wedge
318
+ offset = QPointF(normal.dx(), normal.dy()) * 6.0
319
+ poly = QPolygonF([p1, p2 + offset, p2 - offset])
320
+ painter.drawPolygon(poly)
321
+
322
+ elif self.stereo == 2: # Dash
323
+ num_dashes = 8
324
+ for i in range(num_dashes + 1):
325
+ t = i / num_dashes
326
+ start_pt = p1 * (1 - t) + p2 * t
327
+ width = 5.0 * (1 - t)
328
+ offset = QPointF(normal.dx(), normal.dy()) * width / 2.0
329
+ painter.drawLine(start_pt - offset, start_pt + offset)
330
+ return
331
+
332
+ # --- Default (Multi-order) Drawing ---
333
+ if self.order == 1:
334
+ painter.drawLine(line)
335
+ else:
336
+ v = line.unitVector().normalVector()
337
+ offset = QPointF(v.dx(), v.dy()) * BOND_OFFSET
338
+ if self.order == 2:
339
+ painter.drawLine(line.translated(offset))
340
+ painter.drawLine(line.translated(-offset))
341
+ elif self.order == 3:
342
+ painter.drawLine(line)
343
+ painter.drawLine(line.translated(offset))
344
+ painter.drawLine(line.translated(-offset))
345
+
346
+ def update_position(self):
347
+ self.prepareGeometryChange()
348
+ if self.atom1:
349
+ self.setPos(self.atom1.pos())
350
+ self.update()
351
+
352
+
353
+ class TemplatePreviewItem(QGraphicsItem):
354
+ def __init__(self):
355
+ super().__init__()
356
+ self.setZValue(2)
357
+ self.pen = QPen(QColor(80, 80, 80, 180), 2)
358
+ self.polygon = QPolygonF()
359
+ self.is_aromatic = False
360
+
361
+ def set_geometry(self, points, is_aromatic=False):
362
+ self.prepareGeometryChange()
363
+ self.polygon = QPolygonF(points)
364
+ self.is_aromatic = is_aromatic
365
+ self.update()
366
+
367
+ def boundingRect(self):
368
+ return self.polygon.boundingRect().adjusted(-5, -5, 5, 5)
369
+
370
+ def paint(self, painter, option, widget):
371
+ painter.setPen(self.pen)
372
+ painter.setBrush(Qt.BrushStyle.NoBrush)
373
+ if not self.polygon.isEmpty():
374
+ painter.drawPolygon(self.polygon)
375
+ if self.is_aromatic:
376
+ center = self.polygon.boundingRect().center()
377
+ radius = QLineF(center, self.polygon.first()).length() * 0.6
378
+ painter.drawEllipse(center, radius, radius)
379
+
380
+ class MoleculeScene(QGraphicsScene):
381
+ def __init__(self, data, window):
382
+ super().__init__()
383
+ self.data, self.window = data, window
384
+ self.mode, self.current_atom_symbol = 'select', 'C'
385
+ self.bond_order, self.bond_stereo = 1, 0
386
+ self.start_atom, self.temp_line, self.start_pos = None, None, None; self.press_pos = None
387
+ self.mouse_moved_since_press = False
388
+ self.data_changed_in_event = False
389
+
390
+ self.key_to_symbol_map = {
391
+ Qt.Key.Key_C: 'C', Qt.Key.Key_N: 'N', Qt.Key.Key_O: 'O', Qt.Key.Key_S: 'S',
392
+ Qt.Key.Key_F: 'F', Qt.Key.Key_B: 'B', Qt.Key.Key_I: 'I', Qt.Key.Key_H: 'H',
393
+ Qt.Key.Key_P: 'P',
394
+ }
395
+ self.key_to_symbol_map_shift = { Qt.Key.Key_C: 'Cl', Qt.Key.Key_B: 'Br', Qt.Key.Key_S: 'Si',}
396
+
397
+ self.key_to_bond_mode_map = {
398
+ Qt.Key.Key_1: 'bond_1_0',
399
+ Qt.Key.Key_2: 'bond_2_0',
400
+ Qt.Key.Key_3: 'bond_3_0',
401
+ Qt.Key.Key_W: 'bond_1_1',
402
+ Qt.Key.Key_D: 'bond_1_2',
403
+ }
404
+ self.reinitialize_items()
405
+
406
+ def reinitialize_items(self):
407
+ self.template_preview = TemplatePreviewItem(); self.addItem(self.template_preview)
408
+ self.template_preview.hide(); self.template_preview_points = []; self.template_context = {}
409
+
410
+ def mousePressEvent(self, event):
411
+ self.press_pos = event.scenePos()
412
+ self.mouse_moved_since_press = False
413
+ self.data_changed_in_event = False
414
+ self.initial_positions_in_event = {item: item.pos() for item in self.items() if isinstance(item, AtomItem)}
415
+
416
+ if self.mode.startswith('template'):
417
+ super().mousePressEvent(event); return
418
+
419
+ item = self.itemAt(self.press_pos, self.views()[0].transform())
420
+ if isinstance(item, AtomItem):
421
+ self.start_atom = item
422
+ if self.mode != 'select':
423
+ self.temp_line=QGraphicsLineItem(QLineF(self.start_atom.pos(),self.press_pos)); self.temp_line.setPen(QPen(Qt.GlobalColor.red,2,Qt.PenStyle.DotLine)); self.addItem(self.temp_line)
424
+ else: super().mousePressEvent(event)
425
+ elif item is None and self.mode.startswith('atom'):
426
+ self.start_pos = self.press_pos
427
+ self.temp_line = QGraphicsLineItem(QLineF(self.start_pos, self.press_pos)); self.temp_line.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.DotLine)); self.addItem(self.temp_line)
428
+ else: super().mousePressEvent(event)
429
+
430
+ def mouseMoveEvent(self, event):
431
+ if self.mode.startswith('template'):
432
+ self.update_template_preview(event.scenePos()); return
433
+
434
+ if not self.mouse_moved_since_press and self.press_pos:
435
+ if (event.scenePos() - self.press_pos).manhattanLength() > QApplication.startDragDistance():
436
+ self.mouse_moved_since_press = True
437
+
438
+ if self.temp_line:
439
+ start_point = self.start_atom.pos() if self.start_atom else self.start_pos
440
+ if start_point: self.temp_line.setLine(QLineF(start_point, event.scenePos()))
441
+ else: super().mouseMoveEvent(event)
442
+
443
+ def mouseReleaseEvent(self, event):
444
+ end_pos = event.scenePos()
445
+ is_click = self.press_pos and (end_pos - self.press_pos).manhattanLength() < QApplication.startDragDistance()
446
+
447
+ if self.temp_line:
448
+ self.removeItem(self.temp_line)
449
+ self.temp_line = None
450
+
451
+ if self.mode.startswith('template') and is_click:
452
+ if self.template_context and self.template_context.get('points'):
453
+ context = self.template_context
454
+ self.add_molecule_fragment(context['points'], context['bonds_info'], existing_items=context.get('items', []))
455
+ self.data_changed_in_event = True
456
+
457
+ released_item = self.itemAt(end_pos, self.views()[0].transform())
458
+
459
+ # 1. 特殊モード(ラジカル/電荷)の処理
460
+ if (self.mode == 'radical') and is_click and isinstance(released_item, AtomItem):
461
+ atom = released_item
462
+ # ラジカルの状態をトグル (0 -> 1 -> 2 -> 0)
463
+ atom.radical = (atom.radical + 1) % 3
464
+ self.data.atoms[atom.atom_id]['radical'] = atom.radical
465
+ atom.update_style()
466
+ self.data_changed_in_event = True
467
+ elif (self.mode == 'charge_plus' or self.mode == 'charge_minus') and is_click and isinstance(released_item, AtomItem):
468
+ atom = released_item
469
+ delta = 1 if self.mode == 'charge_plus' else -1
470
+ atom.charge += delta
471
+ self.data.atoms[atom.atom_id]['charge'] = atom.charge
472
+ atom.update_style()
473
+ self.data_changed_in_event = True
474
+
475
+ # 2. 既存結合の様式更新処理 (全ての結合モードで動作)
476
+ elif self.mode.startswith('bond') and is_click and isinstance(released_item, BondItem):
477
+ b = released_item
478
+ # 既存結合をクリックした場合は、現在のモードの結合様式に更新する
479
+ b.order = self.bond_order
480
+ b.stereo = self.bond_stereo
481
+ id1, id2 = b.atom1.atom_id, b.atom2.atom_id
482
+ if id1 > id2: id1, id2 = id2, id1
483
+ self.data.bonds[(id1,id2)]['order'] = self.bond_order
484
+ self.data.bonds[(id1,id2)]['stereo'] = self.bond_stereo
485
+ b.update()
486
+ self.data_changed_in_event = True
487
+
488
+ # 3. 新規原子・結合の作成処理 (atom_* モード および すべての bond_* モードで許可)
489
+ elif self.start_atom and (self.mode.startswith('atom') or self.mode.startswith('bond')):
490
+ line = QLineF(self.start_atom.pos(), end_pos); end_item = self.itemAt(end_pos, self.views()[0].transform())
491
+
492
+ # 使用する結合様式を決定
493
+ # atomモードの場合は bond_order/stereo を None にして create_bond にデフォルト値(1, 0)を適用
494
+ # bond_* モードの場合は現在の設定 (self.bond_order/stereo) を使用
495
+ order_to_use = self.bond_order if self.mode.startswith('bond') else None # ★変更点★
496
+ stereo_to_use = self.bond_stereo if self.mode.startswith('bond') else None # ★変更点★
497
+
498
+
499
+ if line.length() < 10:
500
+ # 短いクリック: 既存原子のシンボル更新 (atomモードのみ)
501
+ if self.mode.startswith('atom') and self.start_atom.symbol != self.current_atom_symbol:
502
+ self.start_atom.symbol=self.current_atom_symbol; self.data.atoms[self.start_atom.atom_id]['symbol']=self.current_atom_symbol; self.start_atom.update_style()
503
+ self.data_changed_in_event = True
504
+ else:
505
+ # ドラッグ: 新規結合または既存原子への結合
506
+ if isinstance(end_item, AtomItem) and self.start_atom!=end_item:
507
+ self.create_bond(self.start_atom, end_item, bond_order=order_to_use, bond_stereo=stereo_to_use)
508
+ else:
509
+ new_id = self.create_atom(self.current_atom_symbol, end_pos); new_item = self.data.atoms[new_id]['item']
510
+ self.create_bond(self.start_atom, new_item, bond_order=order_to_use, bond_stereo=stereo_to_use)
511
+ self.data_changed_in_event = True
512
+
513
+ # 4. 空白領域からの新規作成処理 (atom_* モード および すべての bond_* モードで許可)
514
+ elif self.start_pos and (self.mode.startswith('atom') or self.mode.startswith('bond')):
515
+ line = QLineF(self.start_pos, end_pos)
516
+
517
+ # 使用する結合様式を決定
518
+ order_to_use = self.bond_order if self.mode.startswith('bond') else None # ★変更点★
519
+ stereo_to_use = self.bond_stereo if self.mode.startswith('bond') else None # ★変更点★
520
+
521
+ if line.length() < 10:
522
+ # 短いクリック: 新規原子を1つ作成
523
+ self.create_atom(self.current_atom_symbol, end_pos); self.data_changed_in_event = True
524
+ else:
525
+ # ドラッグ: 2つの新規原子と結合を作成
526
+ start_id = self.create_atom(self.current_atom_symbol, self.start_pos)
527
+ end_id = self.create_atom(self.current_atom_symbol, end_pos)
528
+ self.create_bond(
529
+ self.data.atoms[start_id]['item'],
530
+ self.data.atoms[end_id]['item'],
531
+ bond_order=order_to_use,
532
+ bond_stereo=stereo_to_use
533
+ ); self.data_changed_in_event = True
534
+
535
+ # 5. それ以外の処理 (Selectモードなど)
536
+ else: super().mouseReleaseEvent(event)
537
+
538
+
539
+ moved_atoms = [item for item, old_pos in self.initial_positions_in_event.items() if item.scene() and item.pos() != old_pos]
540
+ if moved_atoms:
541
+ self.data_changed_in_event = True
542
+ bonds_to_update = set()
543
+ for atom in moved_atoms:
544
+ self.data.atoms[atom.atom_id]['pos'] = atom.pos()
545
+ bonds_to_update.update(atom.bonds)
546
+ for bond in bonds_to_update: bond.update_position()
547
+ if self.views(): self.views()[0].viewport().update()
548
+
549
+ self.start_atom=None; self.start_pos = None; self.press_pos = None; self.temp_line = None
550
+ self.template_context = {}
551
+ if self.data_changed_in_event: self.window.push_undo_state()
552
+
553
+ def create_atom(self, symbol, pos, charge=0, radical=0):
554
+ atom_id = self.data.add_atom(symbol, pos, charge=charge, radical=radical)
555
+ atom_item = AtomItem(atom_id, symbol, pos, charge=charge, radical=radical)
556
+ self.data.atoms[atom_id]['item'] = atom_item; self.addItem(atom_item); return atom_id
557
+
558
+
559
+ def create_bond(self, start_atom, end_atom, bond_order=None, bond_stereo=None):
560
+ exist_b = self.find_bond_between(start_atom, end_atom)
561
+ if exist_b:
562
+ return
563
+
564
+ # 引数で次数が指定されていればそれを使用し、なければ現在のモードの値を使用する
565
+ order_to_use = self.bond_order if bond_order is None else bond_order
566
+ stereo_to_use = self.bond_stereo if bond_stereo is None else bond_stereo
567
+
568
+ key, status = self.data.add_bond(start_atom.atom_id, end_atom.atom_id, order_to_use, stereo_to_use)
569
+ if status == 'created':
570
+ bond_item = BondItem(start_atom, end_atom, order_to_use, stereo_to_use)
571
+ self.data.bonds[key]['item'] = bond_item
572
+ start_atom.bonds.append(bond_item)
573
+ end_atom.bonds.append(bond_item)
574
+ self.addItem(bond_item)
575
+
576
+ start_atom.update_style()
577
+ end_atom.update_style()
578
+
579
+ def add_molecule_fragment(self, points, bonds_info, existing_items=None, symbol='C'):
580
+ """
581
+ add_molecule_fragment の最終確定版。
582
+ - 既存の結合次数を変更しないポリシーを徹底(最重要)。
583
+ - ベンゼン環テンプレートは、フューズされる既存結合の次数に基づき、
584
+ 「新規に作られる二重結合が2本になるように」回転を決定するロジックを適用(条件分岐あり)。
585
+ """
586
+ import math
587
+
588
+ num_points = len(points)
589
+ atom_items = [None] * num_points
590
+
591
+ def coords(p):
592
+ if hasattr(p, 'x') and hasattr(p, 'y'):
593
+ return (p.x(), p.y())
594
+ try:
595
+ return (p[0], p[1])
596
+ except Exception:
597
+ raise ValueError("point has no x/y")
598
+
599
+ def dist_pts(a, b):
600
+ ax, ay = coords(a); bx, by = coords(b)
601
+ return math.hypot(ax - bx, ay - by)
602
+
603
+ # --- 1) 既にクリックされた existing_items をテンプレート頂点にマップ (変更なし) ---
604
+ existing_items = existing_items or []
605
+ used_indices = set()
606
+ ref_lengths = [dist_pts(points[i], points[j]) for i, j, _ in bonds_info if i < num_points and j < num_points]
607
+ avg_len = (sum(ref_lengths) / len(ref_lengths)) if ref_lengths else 20.0
608
+ map_threshold = max(0.5 * avg_len, 8.0)
609
+
610
+ for ex_item in existing_items:
611
+ try:
612
+ ex_pos = ex_item.pos()
613
+ best_idx, best_d = -1, float('inf')
614
+ for i, p in enumerate(points):
615
+ if i in used_indices: continue
616
+ d = dist_pts(p, ex_pos)
617
+ if best_d is None or d < best_d:
618
+ best_d, best_idx = d, i
619
+ if best_idx != -1 and best_d <= max(map_threshold, 1.5 * avg_len):
620
+ atom_items[best_idx] = ex_item
621
+ used_indices.add(best_idx)
622
+ except Exception:
623
+ pass
624
+
625
+ # --- 2) シーン内既存原子を self.data.atoms から列挙してマップ (変更なし) ---
626
+ mapped_atoms = {it for it in atom_items if it is not None}
627
+ for i, p in enumerate(points):
628
+ if atom_items[i] is not None: continue
629
+
630
+ nearby = None
631
+ best_d = float('inf')
632
+
633
+ for atom_data in self.data.atoms.values():
634
+ a_item = atom_data.get('item')
635
+ if not a_item or a_item in mapped_atoms: continue
636
+ try:
637
+ d = dist_pts(p, a_item.pos())
638
+ except Exception:
639
+ continue
640
+ if d < best_d:
641
+ best_d, nearby = d, a_item
642
+
643
+ if nearby and best_d <= map_threshold:
644
+ atom_items[i] = nearby
645
+ mapped_atoms.add(nearby)
646
+
647
+ # --- 3) 足りない頂点は新規作成 (変更なし) ---
648
+ for i, p in enumerate(points):
649
+ if atom_items[i] is None:
650
+ atom_id = self.create_atom(symbol, p)
651
+ atom_items[i] = self.data.atoms[atom_id]['item']
652
+
653
+ # --- 4) テンプレートのボンド配列を決定(ベンゼン回転合わせの処理) ---
654
+ template_bonds_to_use = list(bonds_info)
655
+ is_6ring = (num_points == 6 and len(bonds_info) == 6)
656
+ template_has_double = any(o == 2 for (_, _, o) in bonds_info)
657
+
658
+ if is_6ring and template_has_double:
659
+ existing_orders = {} # key: bonds_infoのインデックス, value: 既存の結合次数
660
+ for k, (i_idx, j_idx, _) in enumerate(bonds_info):
661
+ if i_idx < len(atom_items) and j_idx < len(atom_items):
662
+ a, b = atom_items[i_idx], atom_items[j_idx]
663
+ if a is None or b is None: continue
664
+ eb = self.find_bond_between(a, b)
665
+ if eb:
666
+ existing_orders[k] = getattr(eb, 'order', 1)
667
+
668
+ if existing_orders:
669
+ orig_orders = [o for (_, _, o) in bonds_info]
670
+ best_rot = 0
671
+ max_score = -999 # スコアは「適合度」を意味する
672
+
673
+ # --- ★フューズされた辺の数による条件分岐★ ---
674
+ if len(existing_orders) >= 2:
675
+ # 2辺以上フューズ: 単純に既存の辺の次数とテンプレートの辺の次数が一致するものを最優先する
676
+ # (この場合、新しい環を交互配置にするのは難しく、単に既存の構造を壊さないことを優先)
677
+ for rot in range(num_points):
678
+ current_score = sum(100 for k, exist_order in existing_orders.items()
679
+ if orig_orders[(k + rot) % num_points] == exist_order)
680
+ if current_score > max_score:
681
+ max_score = current_score
682
+ best_rot = rot
683
+
684
+ elif len(existing_orders) == 1:
685
+ # 1辺フューズ: 既存の辺を維持しつつ、その両隣で「反転一致」を達成し、新しい環を交互配置にする
686
+
687
+ # フューズされた辺のインデックスと次数を取得
688
+ k_fuse = next(iter(existing_orders.keys()))
689
+ exist_order = existing_orders[k_fuse]
690
+
691
+ # 目標: フューズされた辺の両隣(k-1とk+1)に来るテンプレートの次数が、既存の辺の次数と逆であること
692
+ # k_adj_1 -> (k_fuse - 1) % 6
693
+ # k_adj_2 -> (k_fuse + 1) % 6
694
+
695
+ for rot in range(num_points):
696
+ current_score = 0
697
+ rotated_template_order = orig_orders[(k_fuse + rot) % num_points]
698
+
699
+ # 1. まず、フューズされた辺自体が次数を反転させられる位置にあるかチェック(必須ではないが、回転を絞る)
700
+ if (exist_order == 1 and rotated_template_order == 2) or \
701
+ (exist_order == 2 and rotated_template_order == 1):
702
+ current_score += 100 # 大幅ボーナス: 理想的な回転
703
+
704
+ # 2. 次に、両隣の辺の次数をチェック(交互配置維持の主目的)
705
+ # 既存辺の両隣は、新規に作成されるため、テンプレートの次数でボンドが作成されます。
706
+ # ここで、テンプレートの次数が既存辺の次数と逆になる回転を選ぶ必要があります。
707
+
708
+ # テンプレートの辺は、回転後のk_fuseの両隣(m_adj1, m_adj2)
709
+ m_adj1 = (k_fuse - 1 + rot) % num_points
710
+ m_adj2 = (k_fuse + 1 + rot) % num_points
711
+
712
+ neighbor_order_1 = orig_orders[m_adj1]
713
+ neighbor_order_2 = orig_orders[m_adj2]
714
+
715
+ # 既存が単結合(1)の場合、両隣は二重結合(2)であってほしい
716
+ if exist_order == 1:
717
+ if neighbor_order_1 == 2: current_score += 50
718
+ if neighbor_order_2 == 2: current_score += 50
719
+
720
+ # 既存が二重結合(2)の場合、両隣は単結合(1)であってほしい
721
+ elif exist_order == 2:
722
+ if neighbor_order_1 == 1: current_score += 50
723
+ if neighbor_order_2 == 1: current_score += 50
724
+
725
+ # 3. タイブレーク: その他の既存結合(フューズ辺ではない)との次数一致度も加味
726
+ for k, e_order in existing_orders.items():
727
+ if k != k_fuse:
728
+ r_t_order = orig_orders[(k + rot) % num_points]
729
+ if r_t_order == e_order: current_score += 10 # 既存構造維持のボーナス
730
+
731
+ if current_score > max_score:
732
+ max_score = current_score
733
+ best_rot = rot
734
+
735
+ # 最終的な回転を反映
736
+ new_tb = []
737
+ for m in range(num_points):
738
+ i_idx, j_idx, _ = bonds_info[m]
739
+ new_order = orig_orders[(m + best_rot) % num_points]
740
+ new_tb.append((i_idx, j_idx, new_order))
741
+ template_bonds_to_use = new_tb
742
+
743
+ # --- 5) ボンド作成/更新 (変更なし) ---
744
+ for id1_idx, id2_idx, order in template_bonds_to_use:
745
+ if id1_idx < len(atom_items) and id2_idx < len(atom_items):
746
+ a_item, b_item = atom_items[id1_idx], atom_items[id2_idx]
747
+ if not a_item or not b_item or a_item is b_item: continue
748
+
749
+ # 順序正規化
750
+ id1, id2 = a_item.atom_id, b_item.atom_id
751
+ if id1 > id2: id1, id2 = id2, id1
752
+
753
+ exist_b = self.find_bond_between(a_item, b_item)
754
+ if exist_b:
755
+ # ★最重要ポリシー(フューズ辺は次数を変更しない)★
756
+ continue
757
+ else:
758
+ # 新規ボンド作成
759
+ self.create_bond(a_item, b_item, bond_order=order)
760
+
761
+ # --- 6) 表示更新(必要なら) (変更なし) ---
762
+ for at in atom_items:
763
+ try:
764
+ if at: at.update_style()
765
+ except Exception:
766
+ pass
767
+
768
+ return atom_items
769
+
770
+
771
+ def update_template_preview(self, pos):
772
+ mode_parts = self.mode.split('_')
773
+ is_aromatic = False
774
+ if mode_parts[1] == 'benzene':
775
+ n = 6
776
+ is_aromatic = True
777
+ else:
778
+ try: n = int(mode_parts[1])
779
+ except ValueError: return
780
+
781
+ items_under = self.items(pos) # top-most first
782
+ item = None
783
+ for it in items_under:
784
+ if isinstance(it, (AtomItem, BondItem)):
785
+ item = it
786
+ break
787
+ # -------------------------------------------------------------
788
+
789
+ points, bonds_info = [], []
790
+ l = DEFAULT_BOND_LENGTH
791
+ self.template_context = {}
792
+
793
+ if isinstance(item, AtomItem):
794
+ # 原子にスナップ
795
+ p0 = item.pos()
796
+ direction = QLineF(p0, pos).unitVector()
797
+ p1 = p0 + direction.p2() * l if direction.length() > 0 else p0 + QPointF(l, 0)
798
+ points = self._calculate_polygon_from_edge(p0, p1, n)
799
+ self.template_context['items'] = [item]
800
+
801
+ elif isinstance(item, BondItem):
802
+ # 結合にスナップ
803
+ p0, p1 = item.atom1.pos(), item.atom2.pos()
804
+ points = self._calculate_polygon_from_edge(p0, p1, n, cursor_pos=pos)
805
+ self.template_context['items'] = [item.atom1, item.atom2]
806
+
807
+ else: # 空白領域に配置
808
+ angle_step = 2 * math.pi / n
809
+ start_angle = -math.pi / 2 if n % 2 != 0 else -math.pi / 2 - angle_step / 2
810
+ points = [
811
+ pos + QPointF(l * math.cos(start_angle + i * angle_step), l * math.sin(start_angle + i * angle_step))
812
+ for i in range(n)
813
+ ]
814
+
815
+ if points:
816
+ if is_aromatic:
817
+ bonds_info = [(i, (i + 1) % n, 2 if i % 2 == 0 else 1) for i in range(n)]
818
+ else:
819
+ bonds_info = [(i, (i + 1) % n, 1) for i in range(n)]
820
+
821
+ self.template_context['points'] = points
822
+ self.template_context['bonds_info'] = bonds_info
823
+
824
+ self.template_preview.set_geometry(points, is_aromatic)
825
+ self.template_preview.show()
826
+ if self.views():
827
+ self.views()[0].viewport().update()
828
+ else:
829
+ self.template_preview.hide()
830
+ if self.views():
831
+ self.views()[0].viewport().update()
832
+
833
+ def _calculate_polygon_from_edge(self, p0, p1, n, cursor_pos=None):
834
+ if n < 3: return []
835
+ v_edge = p1 - p0
836
+ edge_length = math.sqrt(v_edge.x()**2 + v_edge.y()**2)
837
+ if edge_length == 0: return []
838
+
839
+ v_edge = (v_edge / edge_length) * DEFAULT_BOND_LENGTH
840
+ p1 = p0 + v_edge
841
+
842
+ points = [p0, p1]
843
+
844
+ interior_angle = (n - 2) * math.pi / n
845
+ rotation_angle = math.pi - interior_angle
846
+
847
+ if cursor_pos:
848
+ # Note: v_edgeは正規化済みだが、方向は同じなので判定には問題ない
849
+ v_cursor = cursor_pos - p0
850
+ cross_product_z = (p1 - p0).x() * v_cursor.y() - (p1 - p0).y() * v_cursor.x()
851
+ if cross_product_z > 0:
852
+ rotation_angle = -rotation_angle
853
+
854
+ cos_a, sin_a = math.cos(rotation_angle), math.sin(rotation_angle)
855
+
856
+ current_p, current_v = p1, v_edge
857
+ for _ in range(n - 2):
858
+ new_vx = current_v.x() * cos_a - current_v.y() * sin_a
859
+ new_vy = current_v.x() * sin_a + current_v.y() * cos_a
860
+ current_v = QPointF(new_vx, new_vy)
861
+ current_p = current_p + current_v
862
+ points.append(current_p)
863
+ return points
864
+
865
+
866
+ def leaveEvent(self, event):
867
+ self.template_preview.hide(); super().leaveEvent(event)
868
+
869
+ def keyPressEvent(self, event):
870
+ view = self.views()[0]
871
+ cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
872
+ item_at_cursor = self.itemAt(cursor_pos, view.transform())
873
+ key = event.key()
874
+ modifiers = event.modifiers()
875
+
876
+ # --- 0a. ラジカルの変更 (.) ---
877
+ if key == Qt.Key.Key_Period:
878
+ target_atoms = []
879
+ selected = self.selectedItems()
880
+ if selected:
881
+ target_atoms = [item for item in selected if isinstance(item, AtomItem)]
882
+ elif isinstance(item_at_cursor, AtomItem):
883
+ target_atoms = [item_at_cursor]
884
+
885
+ if target_atoms:
886
+ for atom in target_atoms:
887
+ # ラジカルの状態をトグル (0 -> 1 -> 2 -> 0)
888
+ atom.radical = (atom.radical + 1) % 3
889
+ self.data.atoms[atom.atom_id]['radical'] = atom.radical
890
+ atom.update_style()
891
+ self.window.push_undo_state()
892
+ event.accept()
893
+ return
894
+
895
+ # --- 0b. 電荷の変更 (+/-キー) ---
896
+ if key == Qt.Key.Key_Plus or key == Qt.Key.Key_Minus:
897
+ target_atoms = []
898
+ selected = self.selectedItems()
899
+ if selected:
900
+ target_atoms = [item for item in selected if isinstance(item, AtomItem)]
901
+ elif isinstance(item_at_cursor, AtomItem):
902
+ target_atoms = [item_at_cursor]
903
+
904
+ if target_atoms:
905
+ delta = 1 if key == Qt.Key.Key_Plus else -1
906
+ for atom in target_atoms:
907
+ atom.charge += delta
908
+ self.data.atoms[atom.atom_id]['charge'] = atom.charge
909
+ atom.update_style()
910
+ self.window.push_undo_state()
911
+ event.accept()
912
+ return
913
+
914
+ # --- 1. Atomに対する操作 (元素記号の変更) ---
915
+ if isinstance(item_at_cursor, AtomItem):
916
+ new_symbol = None
917
+ if modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_symbol_map:
918
+ new_symbol = self.key_to_symbol_map[key]
919
+ elif modifiers == Qt.KeyboardModifier.ShiftModifier and key in self.key_to_symbol_map_shift:
920
+ new_symbol = self.key_to_symbol_map_shift[key]
921
+
922
+ if new_symbol and item_at_cursor.symbol != new_symbol:
923
+ item_at_cursor.symbol = new_symbol
924
+ self.data.atoms[item_at_cursor.atom_id]['symbol'] = new_symbol
925
+ item_at_cursor.update_style()
926
+ self.window.push_undo_state()
927
+ event.accept()
928
+ return
929
+
930
+ # --- 2. Bondに対する操作 (次数・立体化学の変更) ---
931
+ target_bonds = []
932
+ if isinstance(item_at_cursor, BondItem):
933
+ target_bonds = [item_at_cursor]
934
+ else:
935
+ target_bonds = [it for it in self.selectedItems() if isinstance(it, BondItem)]
936
+
937
+ if target_bonds:
938
+ needs_update = False
939
+ for bond in target_bonds:
940
+ id1, id2 = bond.atom1.atom_id, bond.atom2.atom_id
941
+ if id1 > id2: id1, id2 = id2, id1
942
+
943
+ if key == Qt.Key.Key_1 and (bond.order != 1 or bond.stereo != 0):
944
+ bond.order = 1; bond.stereo = 0; needs_update = True
945
+ elif key == Qt.Key.Key_2 and bond.order != 2:
946
+ bond.order = 2; bond.stereo = 0; needs_update = True
947
+ elif key == Qt.Key.Key_3 and bond.order != 3:
948
+ bond.order = 3; bond.stereo = 0; needs_update = True
949
+ elif key == Qt.Key.Key_W and bond.stereo != 1: # Wedge
950
+ bond.order = 1; bond.stereo = 1; needs_update = True
951
+ elif key == Qt.Key.Key_D and bond.stereo != 2: # Dash
952
+ bond.order = 1; bond.stereo = 2; needs_update = True
953
+
954
+ if needs_update:
955
+ self.data.bonds[(id1, id2)]['order'] = bond.order
956
+ self.data.bonds[(id1, id2)]['stereo'] = bond.stereo
957
+ bond.update()
958
+
959
+ if needs_update:
960
+ self.window.push_undo_state()
961
+ event.accept()
962
+ return
963
+
964
+ # --- 3. Atomに対する操作 (原子の追加 - マージされた機能) ---
965
+ if key == Qt.Key.Key_1:
966
+ start_atom = None
967
+ if isinstance(item_at_cursor, AtomItem):
968
+ start_atom = item_at_cursor
969
+ else:
970
+ selected_atoms = [item for item in self.selectedItems() if isinstance(item, AtomItem)]
971
+ if len(selected_atoms) == 1:
972
+ start_atom = selected_atoms[0]
973
+
974
+ if start_atom:
975
+ start_pos = start_atom.pos()
976
+ l = DEFAULT_BOND_LENGTH
977
+ new_pos_offset = QPointF(0, -l) # デフォルトのオフセット (上)
978
+
979
+ # 接続している原子のリストを取得 (H原子以外)
980
+ neighbor_positions = []
981
+ for bond in start_atom.bonds:
982
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
983
+ if other_atom.symbol != 'H': # 水素原子を無視 (四面体構造の考慮のため)
984
+ neighbor_positions.append(other_atom.pos())
985
+
986
+ num_non_H_neighbors = len(neighbor_positions)
987
+
988
+ if num_non_H_neighbors == 0:
989
+ # 結合ゼロ: デフォルト方向
990
+ new_pos_offset = QPointF(0, -l)
991
+
992
+ elif num_non_H_neighbors == 1:
993
+ # 結合1本: 既存結合と約120度(または60度)の角度
994
+ bond = start_atom.bonds[0]
995
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
996
+ existing_bond_vector = start_pos - other_atom.pos()
997
+
998
+ # 既存の結合から時計回り60度回転 (ベンゼン環のような構造にしやすい)
999
+ angle_rad = math.radians(60)
1000
+ cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
1001
+ vx, vy = existing_bond_vector.x(), existing_bond_vector.y()
1002
+ new_vx, new_vy = vx * cos_a - vy * sin_a, vx * sin_a + vy * cos_a
1003
+ rotated_vector = QPointF(new_vx, new_vy)
1004
+ line = QLineF(QPointF(0, 0), rotated_vector)
1005
+ line.setLength(l)
1006
+ new_pos_offset = line.p2()
1007
+
1008
+ elif num_non_H_neighbors == 3:
1009
+ # ★修正箇所:結合3本 (四面体の4番目の位置を推定)★
1010
+
1011
+ bond_vectors_sum = QPointF(0, 0)
1012
+ for pos in neighbor_positions:
1013
+ # start_pos から neighbor_pos へのベクトル
1014
+ vec = pos - start_pos
1015
+ # 単位ベクトルに変換
1016
+ line_to_other = QLineF(QPointF(0,0), vec)
1017
+ if line_to_other.length() > 0:
1018
+ line_to_other.setLength(1.0)
1019
+ bond_vectors_sum += line_to_other.p2()
1020
+
1021
+ SUM_TOLERANCE = 5.0 # 総和ベクトルのマンハッタン長がこの値以下の場合、ゼロとみなす
1022
+
1023
+ if bond_vectors_sum.manhattanLength() > SUM_TOLERANCE:
1024
+ new_direction_line = QLineF(QPointF(0,0), -bond_vectors_sum)
1025
+ new_direction_line.setLength(l)
1026
+ new_pos_offset = new_direction_line.p2()
1027
+ else:
1028
+ new_pos_offset = QPointF(l * 0.7071, -l * 0.7071)
1029
+ # --- 修正 終わり ---
1030
+
1031
+
1032
+ else: # 2本または4本以上の場合 (一般的な骨格の継続、または過結合)
1033
+ bond_vectors_sum = QPointF(0, 0)
1034
+ for bond in start_atom.bonds:
1035
+ other_atom = bond.atom1 if bond.atom2 is start_atom else bond.atom2
1036
+ line_to_other = QLineF(start_pos, other_atom.pos())
1037
+ if line_to_other.length() > 0:
1038
+ line_to_other.setLength(1.0)
1039
+ bond_vectors_sum += line_to_other.p2() - line_to_other.p1()
1040
+
1041
+ if bond_vectors_sum.manhattanLength() > 0.01:
1042
+ new_direction_line = QLineF(QPointF(0,0), -bond_vectors_sum)
1043
+ new_direction_line.setLength(l)
1044
+ new_pos_offset = new_direction_line.p2()
1045
+ else:
1046
+ # 総和がゼロの場合は、デフォルト(上)
1047
+ new_pos_offset = QPointF(0, -l)
1048
+
1049
+
1050
+ SNAP_DISTANCE = 14.0
1051
+ target_pos = start_pos + new_pos_offset
1052
+
1053
+ # 近くに原子を探す
1054
+ near_atom = self.find_atom_near(target_pos, tol=SNAP_DISTANCE)
1055
+
1056
+ if near_atom and near_atom is not start_atom:
1057
+ # 近くに既存原子があれば結合
1058
+ self.create_bond(start_atom, near_atom)
1059
+ else:
1060
+ # 新規原子を作成し結合
1061
+ new_atom_id = self.create_atom('C', target_pos)
1062
+ new_atom_item = self.data.atoms[new_atom_id]['item']
1063
+ self.create_bond(start_atom, new_atom_item)
1064
+
1065
+ self.clearSelection()
1066
+ self.window.push_undo_state()
1067
+ event.accept()
1068
+ return
1069
+
1070
+ # --- 4. 全体に対する操作 (削除、モード切替など) ---
1071
+ if key == Qt.Key.Key_Delete or key == Qt.Key.Key_Backspace:
1072
+ if self.temp_line:
1073
+ self.removeItem(self.temp_line)
1074
+ self.temp_line = None; self.start_atom = None; self.start_pos = None
1075
+ self.initial_positions_in_event = {}
1076
+ event.accept()
1077
+ return
1078
+
1079
+ items_to_process = set(self.selectedItems())
1080
+ # カーソル下のアイテムも削除対象に加える
1081
+ if item_at_cursor and isinstance(item_at_cursor, (AtomItem, BondItem)):
1082
+ items_to_process.add(item_at_cursor)
1083
+
1084
+ if not items_to_process:
1085
+ return
1086
+
1087
+ # --- 安定した削除ロジック (改) ---
1088
+
1089
+ atoms_to_delete = {item for item in items_to_process if isinstance(item, AtomItem)}
1090
+ bonds_to_delete = {item for item in items_to_process if isinstance(item, BondItem)}
1091
+
1092
+ # 削除対象の原子に接続している結合も、すべて削除対象に加える
1093
+ for atom in atoms_to_delete:
1094
+ bonds_to_delete.update(atom.bonds)
1095
+
1096
+ # 影響を受ける(が削除はされない)原子を特定する
1097
+ atoms_to_update = set()
1098
+ for bond in bonds_to_delete:
1099
+ if bond.atom1 and bond.atom1 not in atoms_to_delete:
1100
+ atoms_to_update.add(bond.atom1)
1101
+ if bond.atom2 and bond.atom2 not in atoms_to_delete:
1102
+ atoms_to_update.add(bond.atom2)
1103
+
1104
+ # --- 処理のフェーズ分離 ---
1105
+
1106
+ # Phase 1: データモデルから原子を削除する
1107
+ # (MolecularData.remove_atomは、関連する結合もデータモデルから削除してくれる)
1108
+ for atom in atoms_to_delete:
1109
+ self.data.remove_atom(atom.atom_id)
1110
+
1111
+ # Phase 2: 明示的に選択された結合(かつ原子削除でまだ消えていないもの)をデータモデルから削除する
1112
+ for bond in bonds_to_delete:
1113
+ if bond.atom1 and bond.atom2:
1114
+ # remove_bondは存在チェックを行うので、二重削除の心配はない
1115
+ self.data.remove_bond(bond.atom1.atom_id, bond.atom2.atom_id)
1116
+
1117
+ # Phase 3: シーンからグラフィックアイテムを削除する(必ず結合を先に)
1118
+ for bond in bonds_to_delete:
1119
+ if bond.scene():
1120
+ self.removeItem(bond)
1121
+ for atom in atoms_to_delete:
1122
+ if atom.scene():
1123
+ self.removeItem(atom)
1124
+
1125
+ # Phase 4: 生き残った原子の内部参照リストをクリーンアップする
1126
+ for atom in atoms_to_update:
1127
+ # 削除されたBondItemへの参照をリストから取り除く
1128
+ atom.bonds = [b for b in atom.bonds if b in bonds_to_delete and b.scene() is not None]
1129
+ atom.update_style()
1130
+
1131
+ self.window.push_undo_state()
1132
+
1133
+ # ... (後略) ...
1134
+
1135
+ # 描画の強制更新
1136
+ if self.views():
1137
+ self.views()[0].viewport().update()
1138
+ QApplication.processEvents()
1139
+
1140
+ event.accept()
1141
+ return
1142
+
1143
+
1144
+ if key == Qt.Key.Key_Space:
1145
+ if self.mode != 'select':
1146
+ self.window.activate_select_mode()
1147
+ else:
1148
+ self.window.select_all()
1149
+ event.accept()
1150
+ return
1151
+
1152
+ # グローバルな描画モード切替
1153
+ mode_to_set = None
1154
+
1155
+ # 1. 原子描画モードへの切り替え
1156
+ symbol_for_mode_change = None
1157
+ if modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_symbol_map:
1158
+ symbol_for_mode_change = self.key_to_symbol_map[key]
1159
+ elif modifiers == Qt.KeyboardModifier.ShiftModifier and key in self.key_to_symbol_map_shift:
1160
+ symbol_for_mode_change = self.key_to_symbol_map_shift[key]
1161
+
1162
+ if symbol_for_mode_change:
1163
+ mode_to_set = f'atom_{symbol_for_mode_change}'
1164
+
1165
+ # 2. 結合描画モードへの切り替え
1166
+ elif modifiers == Qt.KeyboardModifier.NoModifier and key in self.key_to_bond_mode_map:
1167
+ mode_to_set = self.key_to_bond_mode_map[key]
1168
+
1169
+ # モードが決定されていれば、モード変更を実行
1170
+ if mode_to_set:
1171
+ if hasattr(self.window, 'set_mode_and_update_toolbar'):
1172
+ self.window.set_mode_and_update_toolbar(mode_to_set)
1173
+ event.accept()
1174
+ return
1175
+
1176
+ # --- どの操作にも当てはまらない場合 ---
1177
+ super().keyPressEvent(event)
1178
+
1179
+
1180
+ def find_atom_near(self, pos, tol=14.0):
1181
+ for it in self.items():
1182
+ if isinstance(it, AtomItem):
1183
+ if math.hypot(it.pos().x() - pos.x(), it.pos().y() - pos.y()) <= tol:
1184
+ return it
1185
+ return None
1186
+
1187
+ def find_bond_between(self, atom1, atom2):
1188
+ for b in atom1.bonds:
1189
+ if (b.atom1 is atom1 and b.atom2 is atom2) or \
1190
+ (b.atom1 is atom2 and b.atom2 is atom1):
1191
+ return b
1192
+ return None
1193
+
1194
+
1195
+
1196
+ class CalculationWorker(QObject):
1197
+ finished=pyqtSignal(object); error=pyqtSignal(str)
1198
+ def run_calculation(self, mol_block):
1199
+ try:
1200
+ if not mol_block: raise ValueError("No atoms to convert.")
1201
+ mol=Chem.MolFromMolBlock(mol_block, removeHs=False)
1202
+ if mol is None: raise ValueError("Failed to create molecule from MOL block.")
1203
+ mol=Chem.AddHs(mol); AllChem.EmbedMolecule(mol, randomSeed=42); AllChem.MMFFOptimizeMolecule(mol)
1204
+ self.finished.emit(mol)
1205
+ except Exception as e: self.error.emit(str(e))
1206
+
1207
+
1208
+ class PeriodicTableDialog(QDialog):
1209
+ element_selected = pyqtSignal(str)
1210
+ def __init__(self, parent=None):
1211
+ super().__init__(parent)
1212
+ self.setWindowTitle("Select an Element"); layout = QGridLayout(self); self.setLayout(layout)
1213
+ elements = [
1214
+ ('H',1,1), ('He',1,18),
1215
+ ('Li',2,1), ('Be',2,2), ('B',2,13), ('C',2,14), ('N',2,15), ('O',2,16), ('F',2,17), ('Ne',2,18),
1216
+ ('Na',3,1), ('Mg',3,2), ('Al',3,13), ('Si',3,14), ('P',3,15), ('S',3,16), ('Cl',3,17), ('Ar',3,18),
1217
+ ('K',4,1), ('Ca',4,2), ('Sc',4,3), ('Ti',4,4), ('V',4,5), ('Cr',4,6), ('Mn',4,7), ('Fe',4,8),
1218
+ ('Co',4,9), ('Ni',4,10), ('Cu',4,11), ('Zn',4,12), ('Ga',4,13), ('Ge',4,14), ('As',4,15), ('Se',4,16),
1219
+ ('Br',4,17), ('Kr',4,18),
1220
+ ('Rb',5,1), ('Sr',5,2), ('Y',5,3), ('Zr',5,4), ('Nb',5,5), ('Mo',5,6), ('Tc',5,7), ('Ru',5,8),
1221
+ ('Rh',5,9), ('Pd',5,10), ('Ag',5,11), ('Cd',5,12), ('In',5,13), ('Sn',5,14), ('Sb',5,15), ('Te',5,16),
1222
+ ('I',5,17), ('Xe',5,18),
1223
+ ('Cs',6,1), ('Ba',6,2), ('La',6,3), ('Hf',6,4), ('Ta',6,5), ('W',6,6), ('Re',6,7), ('Os',6,8),
1224
+ ('Ir',6,9), ('Pt',6,10), ('Au',6,11), ('Hg',6,12), ('Tl',6,13), ('Pb',6,14), ('Bi',6,15), ('Po',6,16),
1225
+ ('At',6,17), ('Rn',6,18),
1226
+ ('Fr',7,1), ('Ra',7,2), ('Ac',7,3), ('Rf',7,4), ('Db',7,5), ('Sg',7,6), ('Bh',7,7), ('Hs',7,8),
1227
+ ('Mt',7,9), ('Ds',7,10), ('Rg',7,11), ('Cn',7,12), ('Nh',7,13), ('Fl',7,14), ('Mc',7,15), ('Lv',7,16),
1228
+ ('Ts',7,17), ('Og',7,18),
1229
+ # Lanthanides (placed on a separate row)
1230
+ ('Ce',8,4), ('Pr',8,5), ('Nd',8,6), ('Pm',8,7), ('Sm',8,8), ('Eu',8,9), ('Gd',8,10), ('Tb',8,11),
1231
+ ('Dy',8,12), ('Ho',8,13), ('Er',8,14), ('Tm',8,15), ('Yb',8,16), ('Lu',8,17),
1232
+ # Actinides (separate row)
1233
+ ('Th',9,4), ('Pa',9,5), ('U',9,6), ('Np',9,7), ('Pu',9,8), ('Am',9,9), ('Cm',9,10), ('Bk',9,11),
1234
+ ('Cf',9,12), ('Es',9,13), ('Fm',9,14), ('Md',9,15), ('No',9,16), ('Lr',9,17),
1235
+ ]
1236
+ for symbol, row, col in elements:
1237
+ b=QPushButton(symbol); b.setFixedSize(40,40); b.clicked.connect(self.on_button_clicked); layout.addWidget(b, row, col)
1238
+ def on_button_clicked(self):
1239
+ b=self.sender(); self.element_selected.emit(b.text()); self.accept()
1240
+
1241
+ # --- 最終版 AnalysisWindow クラス ---
1242
+ class AnalysisWindow(QDialog):
1243
+ def __init__(self, mol, parent=None):
1244
+ super().__init__(parent)
1245
+ self.mol = mol
1246
+ self.setWindowTitle("Molecule Analysis")
1247
+ self.setMinimumWidth(400)
1248
+ self.init_ui()
1249
+
1250
+ def init_ui(self):
1251
+ main_layout = QVBoxLayout(self)
1252
+ grid_layout = QGridLayout()
1253
+
1254
+ # --- 分子特性を計算 ---
1255
+ try:
1256
+ # RDKitのモジュールをインポート
1257
+ from rdkit import Chem
1258
+ from rdkit.Chem import Descriptors, rdMolDescriptors
1259
+
1260
+ # SMILES生成用に、一時的に水素原子を取り除いた分子オブジェクトを作成
1261
+ mol_for_smiles = Chem.RemoveHs(self.mol)
1262
+ # 水素を取り除いた分子からSMILESを生成(常に簡潔な表記になる)
1263
+ smiles = Chem.MolToSmiles(mol_for_smiles, isomericSmiles=True)
1264
+
1265
+ # 各種プロパティを計算
1266
+ mol_formula = rdMolDescriptors.CalcMolFormula(self.mol)
1267
+ mol_wt = Descriptors.MolWt(self.mol)
1268
+ exact_mw = Descriptors.ExactMolWt(self.mol)
1269
+ num_heavy_atoms = self.mol.GetNumHeavyAtoms()
1270
+ num_rings = rdMolDescriptors.CalcNumRings(self.mol)
1271
+ log_p = Descriptors.MolLogP(self.mol)
1272
+ tpsa = Descriptors.TPSA(self.mol)
1273
+ num_h_donors = rdMolDescriptors.CalcNumHBD(self.mol)
1274
+ num_h_acceptors = rdMolDescriptors.CalcNumHBA(self.mol)
1275
+
1276
+ # 表示するプロパティを辞書にまとめる
1277
+ properties = {
1278
+ "SMILES:": smiles,
1279
+ "Molecular Formula:": mol_formula,
1280
+ "Molecular Weight:": f"{mol_wt:.4f}",
1281
+ "Exact Mass:": f"{exact_mw:.4f}",
1282
+ "Heavy Atoms:": str(num_heavy_atoms),
1283
+ "Ring Count:": str(num_rings),
1284
+ "LogP (o/w):": f"{log_p:.3f}",
1285
+ "TPSA (Ų):": f"{tpsa:.2f}",
1286
+ "H-Bond Donors:": str(num_h_donors),
1287
+ "H-Bond Acceptors:": str(num_h_acceptors),
1288
+ }
1289
+ except Exception as e:
1290
+ main_layout.addWidget(QLabel(f"Error calculating properties: {e}"))
1291
+ return
1292
+
1293
+ # --- 計算結果をUIに表示 ---
1294
+ row = 0
1295
+ for label_text, value_text in properties.items():
1296
+ label = QLabel(f"<b>{label_text}</b>")
1297
+ value = QLineEdit(value_text)
1298
+ value.setReadOnly(True)
1299
+
1300
+ copy_btn = QPushButton("Copy")
1301
+ copy_btn.clicked.connect(lambda _, v=value: self.copy_to_clipboard(v.text()))
1302
+
1303
+ grid_layout.addWidget(label, row, 0)
1304
+ grid_layout.addWidget(value, row, 1)
1305
+ grid_layout.addWidget(copy_btn, row, 2)
1306
+ row += 1
1307
+
1308
+ main_layout.addLayout(grid_layout)
1309
+
1310
+ # --- OKボタン ---
1311
+ ok_button = QPushButton("OK")
1312
+ ok_button.clicked.connect(self.accept)
1313
+ main_layout.addWidget(ok_button, 0, Qt.AlignmentFlag.AlignCenter)
1314
+
1315
+ self.setLayout(main_layout)
1316
+
1317
+ def copy_to_clipboard(self, text):
1318
+ clipboard = QApplication.clipboard()
1319
+ clipboard.setText(text)
1320
+ if self.parent() and hasattr(self.parent(), 'statusBar'):
1321
+ self.parent().statusBar().showMessage(f"Copied '{text}' to clipboard.", 2000)
1322
+
1323
+ class MainWindow(QMainWindow):
1324
+
1325
+ start_calculation = pyqtSignal(str)
1326
+ def __init__(self):
1327
+ super().__init__()
1328
+ self.setWindowTitle("moleditpy -- Python Molecular Editor by HY"); self.setGeometry(100, 100, 1400, 800)
1329
+ self.data = MolecularData(); self.current_mol = None
1330
+ self.current_3d_style = 'ball_and_stick'
1331
+ self.undo_stack = []
1332
+ self.redo_stack = []
1333
+ self.mode_actions = {}
1334
+ self.init_ui()
1335
+ self.init_worker_thread()
1336
+ self.reset_undo_stack()
1337
+ self.scene.selectionChanged.connect(self.update_edit_menu_actions)
1338
+ QApplication.clipboard().dataChanged.connect(self.update_edit_menu_actions)
1339
+ self.update_edit_menu_actions()
1340
+
1341
+ def init_ui(self):
1342
+ self.init_menu_bar()
1343
+
1344
+ splitter=QSplitter(Qt.Orientation.Horizontal)
1345
+ self.setCentralWidget(splitter)
1346
+
1347
+ left_pane=QWidget()
1348
+ left_layout=QVBoxLayout(left_pane)
1349
+
1350
+ self.scene=MoleculeScene(self.data,self)
1351
+ self.scene.setSceneRect(-400,-400,800,800)
1352
+ self.scene.setBackgroundBrush(QColor("#FFFFFF"))
1353
+
1354
+ self.view_2d=QGraphicsView(self.scene)
1355
+ self.view_2d.setRenderHint(QPainter.RenderHint.Antialiasing)
1356
+ self.view_2d.setSizePolicy(
1357
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
1358
+ )
1359
+ left_layout.addWidget(self.view_2d)
1360
+
1361
+ self.cleanup_button=QPushButton("Optimize 2D")
1362
+ self.cleanup_button.clicked.connect(self.clean_up_2d_structure)
1363
+ left_layout.addWidget(self.cleanup_button)
1364
+ splitter.addWidget(left_pane)
1365
+
1366
+ right_pane=QWidget()
1367
+ right_layout=QVBoxLayout(right_pane)
1368
+ self.plotter=QtInteractor(right_pane)
1369
+ self.plotter.setSizePolicy(
1370
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
1371
+ )
1372
+ right_layout.addWidget(self.plotter)
1373
+
1374
+ self.plotter.installEventFilter(self)
1375
+
1376
+ self.convert_button=QPushButton("Convert to 3D")
1377
+ self.convert_button.clicked.connect(self.trigger_conversion)
1378
+ right_layout.addWidget(self.convert_button)
1379
+ splitter.addWidget(right_pane)
1380
+ splitter.setSizes([600, 600])
1381
+
1382
+ splitter.setStretchFactor(0, 1)
1383
+ splitter.setStretchFactor(1, 1)
1384
+
1385
+ self.view_2d.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
1386
+
1387
+ toolbar = QToolBar("Main Toolbar")
1388
+ self.addToolBar(toolbar)
1389
+ self.tool_group = QActionGroup(self)
1390
+ self.tool_group.setExclusive(True)
1391
+
1392
+ actions_data = [
1393
+ ("Select", 'select', 'Space'), ("C", 'atom_C', 'c'), ("H", 'atom_H', 'h'), ("B", 'atom_B', 'b'),
1394
+ ("N", 'atom_N', 'n'), ("O", 'atom_O', 'o'), ("S", 'atom_S', 's'), ("Si", 'atom_Si', 'Shift+S'), ("P", 'atom_P', 'p'),
1395
+ ("F", 'atom_F', 'f'), ("Cl", 'atom_Cl', 'Shift+C'), ("Br", 'atom_Br', 'Shift+B'), ("I", 'atom_I', 'i'),
1396
+ ("Other...", 'atom_other', '')
1397
+ ]
1398
+
1399
+ for text, mode, shortcut_text in actions_data:
1400
+ if text == "C": toolbar.addSeparator()
1401
+
1402
+ action = QAction(text, self, checkable=(mode != 'atom_other'))
1403
+ if shortcut_text: action.setToolTip(f"{text} ({shortcut_text})")
1404
+
1405
+ if mode == 'atom_other':
1406
+ action.triggered.connect(self.open_periodic_table_dialog)
1407
+ else:
1408
+ action.triggered.connect(lambda c, m=mode: self.set_mode(m))
1409
+ self.mode_actions[mode] = action
1410
+
1411
+ toolbar.addAction(action)
1412
+ if mode != 'atom_other': self.tool_group.addAction(action)
1413
+
1414
+ if text == "Select":
1415
+ select_action = action
1416
+
1417
+ toolbar.addSeparator()
1418
+
1419
+ # --- 結合ボタンのアイコンを生成するヘルパー関数 ---
1420
+ def create_bond_icon(bond_type, size=32):
1421
+ pixmap = QPixmap(size, size)
1422
+ pixmap.fill(Qt.GlobalColor.transparent)
1423
+ painter = QPainter(pixmap)
1424
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1425
+
1426
+ p1 = QPointF(6, size / 2)
1427
+ p2 = QPointF(size - 6, size / 2)
1428
+ line = QLineF(p1, p2)
1429
+
1430
+ painter.setPen(QPen(Qt.GlobalColor.black, 2))
1431
+ painter.setBrush(QBrush(Qt.GlobalColor.black))
1432
+
1433
+ if bond_type == 'single':
1434
+ painter.drawLine(line)
1435
+ elif bond_type == 'double':
1436
+ v = line.unitVector().normalVector()
1437
+ offset = QPointF(v.dx(), v.dy()) * 2.5
1438
+ painter.drawLine(line.translated(offset))
1439
+ painter.drawLine(line.translated(-offset))
1440
+ elif bond_type == 'triple':
1441
+ v = line.unitVector().normalVector()
1442
+ offset = QPointF(v.dx(), v.dy()) * 3.0
1443
+ painter.drawLine(line)
1444
+ painter.drawLine(line.translated(offset))
1445
+ painter.drawLine(line.translated(-offset))
1446
+ elif bond_type == 'wedge':
1447
+ vec = line.unitVector()
1448
+ normal = vec.normalVector()
1449
+ offset = QPointF(normal.dx(), normal.dy()) * 6.0
1450
+ poly = QPolygonF([p1, p2 + offset, p2 - offset])
1451
+ painter.drawPolygon(poly)
1452
+ elif bond_type == 'dash':
1453
+ vec = line.unitVector()
1454
+ normal = vec.normalVector()
1455
+
1456
+ num_dashes = 6
1457
+ for i in range(num_dashes + 1):
1458
+ t = i / num_dashes
1459
+ start_pt = p1 * (1 - t) + p2 * t
1460
+ width = 5.0 * (1 - t)
1461
+ offset = QPointF(normal.dx(), normal.dy()) * width / 2.0
1462
+ painter.setPen(QPen(Qt.GlobalColor.black, 1.5))
1463
+ painter.drawLine(start_pt - offset, start_pt + offset)
1464
+
1465
+ painter.end()
1466
+ return QIcon(pixmap)
1467
+
1468
+ # --- 結合ボタンをツールバーに追加 ---
1469
+ bond_actions_data = [
1470
+ ("Single Bond", 'bond_1_0', '1', 'single'),
1471
+ ("Double Bond", 'bond_2_0', '2', 'double'),
1472
+ ("Triple Bond", 'bond_3_0', '3', 'triple'),
1473
+ ("Wedge Bond", 'bond_1_1', 'W', 'wedge'),
1474
+ ("Dash Bond", 'bond_1_2', 'D', 'dash'),
1475
+ ]
1476
+
1477
+ for text, mode, shortcut_text, icon_type in bond_actions_data:
1478
+ action = QAction(self)
1479
+ action.setIcon(create_bond_icon(icon_type))
1480
+ action.setToolTip(f"{text} ({shortcut_text})")
1481
+ action.setCheckable(True)
1482
+ action.triggered.connect(lambda checked, m=mode: self.set_mode(m))
1483
+ self.mode_actions[mode] = action
1484
+ toolbar.addAction(action)
1485
+ self.tool_group.addAction(action)
1486
+
1487
+ toolbar.addSeparator()
1488
+
1489
+ charge_plus_action = QAction("+ Charge", self, checkable=True)
1490
+ charge_plus_action.setToolTip("Increase Atom Charge (+)")
1491
+ charge_plus_action.triggered.connect(lambda c, m='charge_plus': self.set_mode(m))
1492
+ self.mode_actions['charge_plus'] = charge_plus_action
1493
+ toolbar.addAction(charge_plus_action)
1494
+ self.tool_group.addAction(charge_plus_action)
1495
+
1496
+ charge_minus_action = QAction("- Charge", self, checkable=True)
1497
+ charge_minus_action.setToolTip("Decrease Atom Charge (-)")
1498
+ charge_minus_action.triggered.connect(lambda c, m='charge_minus': self.set_mode(m))
1499
+ self.mode_actions['charge_minus'] = charge_minus_action
1500
+ toolbar.addAction(charge_minus_action)
1501
+ self.tool_group.addAction(charge_minus_action)
1502
+
1503
+ radical_action = QAction("Radical", self, checkable=True)
1504
+ radical_action.setToolTip("Toggle Radical (0/1/2) (.)")
1505
+ radical_action.triggered.connect(lambda c, m='radical': self.set_mode(m))
1506
+ self.mode_actions['radical'] = radical_action
1507
+ toolbar.addAction(radical_action)
1508
+ self.tool_group.addAction(radical_action)
1509
+
1510
+ toolbar.addSeparator()
1511
+ toolbar.addWidget(QLabel(" Templates:"))
1512
+
1513
+ templates = [("Benzene", "template_benzene")] + [(f"{i}-Ring", f"template_{i}") for i in range(3, 10)]
1514
+ for text, mode in templates:
1515
+ action = QAction(text, self, checkable=True)
1516
+ action.setToolTip(f"{text} Template")
1517
+ action.triggered.connect(lambda c, m=mode: self.set_mode(m))
1518
+ self.mode_actions[mode] = action
1519
+ toolbar.addAction(action)
1520
+ self.tool_group.addAction(action)
1521
+
1522
+
1523
+ select_action.setChecked(True)
1524
+ self.set_mode('select')
1525
+
1526
+ # スペーサーを追加して、次のウィジェットを右端に配置する
1527
+ spacer = QWidget()
1528
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
1529
+ toolbar.addWidget(spacer)
1530
+
1531
+ # 3Dスタイル変更ボタンとメニューを作成
1532
+
1533
+ self.style_button = QToolButton()
1534
+ self.style_button.setText("3D Style")
1535
+ self.style_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
1536
+ toolbar.addWidget(self.style_button)
1537
+
1538
+ style_menu = QMenu(self)
1539
+ self.style_button.setMenu(style_menu)
1540
+
1541
+ style_group = QActionGroup(self)
1542
+ style_group.setExclusive(True)
1543
+
1544
+ # Ball & Stick アクション
1545
+ bs_action = QAction("Ball & Stick", self, checkable=True)
1546
+ bs_action.setChecked(True)
1547
+ bs_action.triggered.connect(lambda: self.set_3d_style('ball_and_stick'))
1548
+ style_menu.addAction(bs_action)
1549
+ style_group.addAction(bs_action)
1550
+
1551
+ # CPK アクション
1552
+ cpk_action = QAction("CPK (Space-filling)", self, checkable=True)
1553
+ cpk_action.triggered.connect(lambda: self.set_3d_style('cpk'))
1554
+ style_menu.addAction(cpk_action)
1555
+ style_group.addAction(cpk_action)
1556
+
1557
+ self.view_2d.setFocus()
1558
+
1559
+ def init_menu_bar(self):
1560
+ menu_bar = self.menuBar()
1561
+
1562
+ file_menu = menu_bar.addMenu("&File")
1563
+ load_mol_action = QAction("Open MOL/SDF...", self); load_mol_action.triggered.connect(self.load_mol_file)
1564
+ file_menu.addAction(load_mol_action)
1565
+ file_menu.addSeparator()
1566
+ save_mol_action = QAction("Save 2D as MOL...", self); save_mol_action.triggered.connect(self.save_as_mol)
1567
+ file_menu.addAction(save_mol_action)
1568
+
1569
+ save_3d_mol_action = QAction("Save 3D as MOL...", self); save_3d_mol_action.triggered.connect(self.save_3d_as_mol)
1570
+ file_menu.addAction(save_3d_mol_action)
1571
+
1572
+ save_xyz_action = QAction("Save 3D as XYZ...", self); save_xyz_action.triggered.connect(self.save_as_xyz)
1573
+ file_menu.addAction(save_xyz_action)
1574
+ file_menu.addSeparator()
1575
+ save_raw_action = QAction("Save Project...", self); save_raw_action.triggered.connect(self.save_raw_data)
1576
+ save_raw_action.setShortcut(QKeySequence.StandardKey.Save)
1577
+ file_menu.addAction(save_raw_action)
1578
+ load_raw_action = QAction("Open Project...", self); load_raw_action.triggered.connect(self.load_raw_data)
1579
+ file_menu.addAction(load_raw_action)
1580
+
1581
+ file_menu.addSeparator()
1582
+ quit_action = QAction("Quit", self)
1583
+ quit_action.setShortcut(QKeySequence.StandardKey.Quit) # Ctrl+Q ショートカット
1584
+ quit_action.triggered.connect(self.close)
1585
+ file_menu.addAction(quit_action)
1586
+
1587
+ edit_menu = menu_bar.addMenu("&Edit")
1588
+ self.undo_action = QAction("Undo", self); self.undo_action.setShortcut(QKeySequence.StandardKey.Undo)
1589
+ self.undo_action.triggered.connect(self.undo); edit_menu.addAction(self.undo_action)
1590
+
1591
+ self.redo_action = QAction("Redo", self); self.redo_action.setShortcut(QKeySequence.StandardKey.Redo)
1592
+ self.redo_action.triggered.connect(self.redo); edit_menu.addAction(self.redo_action)
1593
+
1594
+ edit_menu.addSeparator()
1595
+
1596
+ self.cut_action = QAction("Cut", self)
1597
+ self.cut_action.setShortcut(QKeySequence.StandardKey.Cut)
1598
+ self.cut_action.triggered.connect(self.cut_selection)
1599
+ edit_menu.addAction(self.cut_action)
1600
+
1601
+ self.copy_action = QAction("Copy", self)
1602
+ self.copy_action.setShortcut(QKeySequence.StandardKey.Copy)
1603
+ self.copy_action.triggered.connect(self.copy_selection)
1604
+ edit_menu.addAction(self.copy_action)
1605
+
1606
+ self.paste_action = QAction("Paste", self)
1607
+ self.paste_action.setShortcut(QKeySequence.StandardKey.Paste)
1608
+ self.paste_action.triggered.connect(self.paste_from_clipboard)
1609
+ edit_menu.addAction(self.paste_action)
1610
+
1611
+ edit_menu.addSeparator()
1612
+
1613
+ select_all_action = QAction("Select All", self); select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
1614
+ select_all_action.triggered.connect(self.select_all); edit_menu.addAction(select_all_action)
1615
+
1616
+ clear_all_action = QAction("Clear All", self)
1617
+ clear_all_action.triggered.connect(self.clear_all); edit_menu.addAction(clear_all_action)
1618
+
1619
+ analysis_menu = menu_bar.addMenu("&Analysis")
1620
+ self.analysis_action = QAction("Show Analysis...", self)
1621
+ self.analysis_action.triggered.connect(self.open_analysis_window)
1622
+ self.analysis_action.setEnabled(False)
1623
+ analysis_menu.addAction(self.analysis_action)
1624
+
1625
+ def init_worker_thread(self):
1626
+ self.thread=QThread();self.worker=CalculationWorker();self.worker.moveToThread(self.thread)
1627
+ self.start_calculation.connect(self.worker.run_calculation)
1628
+ self.worker.finished.connect(self.on_calculation_finished); self.worker.error.connect(self.on_calculation_error)
1629
+ self.thread.start()
1630
+
1631
+ def set_mode(self, mode_str):
1632
+ self.scene.mode = mode_str
1633
+
1634
+ if mode_str.startswith('template'):
1635
+ self.view_2d.setMouseTracking(True)
1636
+ else:
1637
+ self.view_2d.setMouseTracking(False)
1638
+ self.scene.template_preview.hide()
1639
+
1640
+ if mode_str.startswith('atom'):
1641
+ self.scene.current_atom_symbol = mode_str.split('_')[1]
1642
+ self.statusBar().showMessage(f"Mode: Draw Atom ({self.scene.current_atom_symbol})")
1643
+ self.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
1644
+ # ★追加: atomモードに切り替わったら結合様式をデフォルトにリセット★
1645
+ self.scene.bond_order = 1
1646
+ self.scene.bond_stereo = 0
1647
+ # ★追加 終わり★
1648
+ elif mode_str.startswith('bond'):
1649
+ self.scene.current_atom_symbol = 'C'
1650
+ parts = mode_str.split('_')
1651
+ self.scene.bond_order = int(parts[1])
1652
+ self.scene.bond_stereo = int(parts[2]) if len(parts) > 2 else 0
1653
+ stereo_text = {0: "", 1: " (Wedge)", 2: " (Dash)"}.get(self.scene.bond_stereo, "")
1654
+ self.statusBar().showMessage(f"Mode: Draw Bond (Order: {self.scene.bond_order}{stereo_text})")
1655
+ self.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
1656
+ elif mode_str.startswith('template'):
1657
+ self.statusBar().showMessage(f"Mode: {mode_str.split('_')[1].capitalize()} Template")
1658
+ self.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
1659
+ elif mode_str == 'charge_plus':
1660
+ self.statusBar().showMessage("Mode: Increase Charge (Click on Atom)")
1661
+ self.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
1662
+ elif mode_str == 'charge_minus':
1663
+ self.statusBar().showMessage("Mode: Decrease Charge (Click on Atom)")
1664
+ self.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
1665
+ elif mode_str == 'radical':
1666
+ self.statusBar().showMessage("Mode: Toggle Radical (Click on Atom)")
1667
+ self.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
1668
+ else: # Select mode
1669
+ self.statusBar().showMessage("Mode: Select")
1670
+ self.view_2d.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
1671
+ # ★追加: selectモードに切り替わったら結合様式をデフォルトにリセット★
1672
+ self.scene.bond_order = 1
1673
+ self.scene.bond_stereo = 0
1674
+ # ★追加 終わり★
1675
+
1676
+ def set_mode_and_update_toolbar(self, mode_str):
1677
+ self.set_mode(mode_str)
1678
+ if mode_str in self.mode_actions:
1679
+ self.mode_actions[mode_str].setChecked(True)
1680
+
1681
+ def set_3d_style(self, style_name):
1682
+ """3D表示スタイルを設定し、ビューを更新する"""
1683
+ if self.current_3d_style == style_name:
1684
+ return # スタイルが変更されていない場合は何もしない
1685
+
1686
+ self.current_3d_style = style_name
1687
+ self.statusBar().showMessage(f"3D style set to: {style_name}")
1688
+
1689
+ # 現在表示中の分子があれば、新しいスタイルで再描画する
1690
+ if self.current_mol:
1691
+ self.draw_molecule_3d(self.current_mol)
1692
+
1693
+ def copy_selection(self):
1694
+ """選択された原子と結合をクリップボードにコピーする"""
1695
+ selected_atoms = [item for item in self.scene.selectedItems() if isinstance(item, AtomItem)]
1696
+ if not selected_atoms:
1697
+ return
1698
+
1699
+ # 選択された原子のIDセットを作成
1700
+ selected_atom_ids = {atom.atom_id for atom in selected_atoms}
1701
+
1702
+ # 選択された原子の幾何学的中心を計算
1703
+ center = QPointF(
1704
+ sum(atom.pos().x() for atom in selected_atoms) / len(selected_atoms),
1705
+ sum(atom.pos().y() for atom in selected_atoms) / len(selected_atoms)
1706
+ )
1707
+
1708
+ # コピー対象の原子データをリストに格納(位置は中心からの相対座標)
1709
+ # 同時に、元のatom_idから新しいインデックス(0, 1, 2...)へのマッピングを作成
1710
+ atom_id_to_idx_map = {}
1711
+ fragment_atoms = []
1712
+ for i, atom in enumerate(selected_atoms):
1713
+ atom_id_to_idx_map[atom.atom_id] = i
1714
+ fragment_atoms.append({
1715
+ 'symbol': atom.symbol,
1716
+ 'rel_pos': atom.pos() - center,
1717
+ 'charge': atom.charge,
1718
+ 'radical': atom.radical,
1719
+ })
1720
+
1721
+ # 選択された原子同士を結ぶ結合のみをリストに格納
1722
+ fragment_bonds = []
1723
+ for (id1, id2), bond_data in self.data.bonds.items():
1724
+ if id1 in selected_atom_ids and id2 in selected_atom_ids:
1725
+ fragment_bonds.append({
1726
+ 'idx1': atom_id_to_idx_map[id1],
1727
+ 'idx2': atom_id_to_idx_map[id2],
1728
+ 'order': bond_data['order'],
1729
+ 'stereo': bond_data['stereo'],
1730
+ })
1731
+
1732
+ # pickleを使ってデータをバイト配列にシリアライズ
1733
+ data_to_pickle = {'atoms': fragment_atoms, 'bonds': fragment_bonds}
1734
+ byte_array = QByteArray()
1735
+ buffer = io.BytesIO()
1736
+ pickle.dump(data_to_pickle, buffer)
1737
+ byte_array.append(buffer.getvalue())
1738
+
1739
+ # カスタムMIMEタイプでクリップボードに設定
1740
+ mime_data = QMimeData()
1741
+ mime_data.setData(CLIPBOARD_MIME_TYPE, byte_array)
1742
+ QApplication.clipboard().setMimeData(mime_data)
1743
+ self.statusBar().showMessage(f"Copied {len(fragment_atoms)} atoms and {len(fragment_bonds)} bonds.", 2000)
1744
+
1745
+ def cut_selection(self):
1746
+ """選択されたアイテムを切り取り(コピーしてから削除) - 堅牢版"""
1747
+ # 最初にコピー処理を実行
1748
+ self.copy_selection()
1749
+
1750
+ # 次に削除処理を実行
1751
+ items_to_delete = self.scene.selectedItems()
1752
+ if not items_to_delete:
1753
+ return
1754
+
1755
+ atoms_to_delete = {item for item in items_to_delete if isinstance(item, AtomItem)}
1756
+ bonds_to_delete = {item for item in items_to_delete if isinstance(item, BondItem)}
1757
+
1758
+ # 選択された原子に接続している結合もすべて削除対象に加える
1759
+ for atom in atoms_to_delete:
1760
+ bonds_to_delete.update(atom.bonds)
1761
+
1762
+ # 削除はされないが、結合が消えることで影響を受ける原子を特定
1763
+ atoms_to_update = set()
1764
+ for bond in bonds_to_delete:
1765
+ if bond.atom1 and bond.atom1 not in atoms_to_delete:
1766
+ atoms_to_update.add(bond.atom1)
1767
+ if bond.atom2 and bond.atom2 not in atoms_to_delete:
1768
+ atoms_to_update.add(bond.atom2)
1769
+
1770
+
1771
+ # --- 削除プロセス ---
1772
+ # 1. 先に結合を削除する(グラフィック、内部参照、データモデル)
1773
+ for bond in list(bonds_to_delete):
1774
+ if not bond.scene():
1775
+ continue
1776
+
1777
+ # --- ▼ 修正箇所 ▼ ---
1778
+ # 接続している原子の内部リストから、この結合への参照を安全に削除する
1779
+ if bond.atom1 and bond in bond.atom1.bonds:
1780
+ # 古いコード: bond.atom1.bonds.remove(bond)
1781
+ bond.atom1.bonds = [b for b in bond.atom1.bonds if b is not bond]
1782
+ if bond.atom2 and bond in bond.atom2.bonds:
1783
+ # 古いコード: bond.atom2.bonds.remove(bond)
1784
+ bond.atom2.bonds = [b for b in bond.atom2.bonds if b is not bond]
1785
+ # --- ▲ 修正箇所 ▲ ---
1786
+
1787
+ # データモデルから削除(原子IDが必要)
1788
+ if bond.atom1 and bond.atom2:
1789
+ self.data.remove_bond(bond.atom1.atom_id, bond.atom2.atom_id)
1790
+
1791
+ # グラフィックシーンから削除
1792
+ self.scene.removeItem(bond)
1793
+
1794
+ # 2. 次に原子を削除する(データモデルとシーン)
1795
+ for atom in list(atoms_to_delete):
1796
+ if not atom.scene():
1797
+ continue
1798
+ # データモデルからの削除
1799
+ self.data.remove_atom(atom.atom_id)
1800
+ # グラフィックシーンから削除
1801
+ self.scene.removeItem(atom)
1802
+
1803
+ # 3. 影響を受けた原子の表示を更新
1804
+ for atom in atoms_to_update:
1805
+ atom.update_style()
1806
+
1807
+ self.push_undo_state()
1808
+ self.statusBar().showMessage("Cut selection.", 2000)
1809
+
1810
+ def paste_from_clipboard(self):
1811
+ """クリップボードから分子フラグメントを貼り付け"""
1812
+ clipboard = QApplication.clipboard()
1813
+ mime_data = clipboard.mimeData()
1814
+ if not mime_data.hasFormat(CLIPBOARD_MIME_TYPE):
1815
+ return
1816
+
1817
+ byte_array = mime_data.data(CLIPBOARD_MIME_TYPE)
1818
+ buffer = io.BytesIO(byte_array)
1819
+ try:
1820
+ fragment_data = pickle.load(buffer)
1821
+ except pickle.UnpicklingError:
1822
+ return
1823
+
1824
+ paste_center_pos = self.view_2d.mapToScene(self.view_2d.mapFromGlobal(QCursor.pos()))
1825
+ self.scene.clearSelection()
1826
+
1827
+ new_atoms = []
1828
+ for atom_data in fragment_data['atoms']:
1829
+ pos = paste_center_pos + atom_data['rel_pos']
1830
+ new_id = self.scene.create_atom(
1831
+ atom_data['symbol'], pos,
1832
+ charge=atom_data.get('charge', 0),
1833
+ radical=atom_data.get('radical', 0)
1834
+ )
1835
+ new_item = self.data.atoms[new_id]['item']
1836
+ new_atoms.append(new_item)
1837
+ new_item.setSelected(True)
1838
+
1839
+ for bond_data in fragment_data['bonds']:
1840
+ atom1 = new_atoms[bond_data['idx1']]
1841
+ atom2 = new_atoms[bond_data['idx2']]
1842
+ self.scene.create_bond(
1843
+ atom1, atom2,
1844
+ bond_order=bond_data.get('order', 1),
1845
+ bond_stereo=bond_data.get('stereo', 0)
1846
+ )
1847
+
1848
+ self.push_undo_state()
1849
+ self.statusBar().showMessage(f"Pasted {len(new_atoms)} atoms.", 2000)
1850
+
1851
+ def update_edit_menu_actions(self):
1852
+ """選択状態やクリップボードの状態に応じて編集メニューを更新"""
1853
+ has_selection = len(self.scene.selectedItems()) > 0
1854
+ self.cut_action.setEnabled(has_selection)
1855
+ self.copy_action.setEnabled(has_selection)
1856
+
1857
+ clipboard = QApplication.clipboard()
1858
+ mime_data = clipboard.mimeData()
1859
+ self.paste_action.setEnabled(mime_data is not None and mime_data.hasFormat(CLIPBOARD_MIME_TYPE))
1860
+
1861
+
1862
+ def activate_select_mode(self):
1863
+ self.set_mode('select')
1864
+ if 'select' in self.mode_actions:
1865
+ self.mode_actions['select'].setChecked(True)
1866
+ def trigger_conversion(self):
1867
+ mol = self.data.to_rdkit_mol()
1868
+ if not mol or mol.GetNumAtoms() == 0:
1869
+ self.statusBar().showMessage("Error: No atoms to convert.")
1870
+ return
1871
+
1872
+ try:
1873
+ Chem.SanitizeMol(mol)
1874
+ except Exception:
1875
+ self.statusBar().showMessage("Error: Invalid chemical structure (sanitization failed).")
1876
+ return
1877
+
1878
+ if len(Chem.GetMolFrags(mol)) > 1:
1879
+ self.statusBar().showMessage("Error: 3D conversion not supported for multiple molecules.")
1880
+ return
1881
+
1882
+ mol_block = Chem.MolToMolBlock(mol, includeStereo=True)
1883
+ self.convert_button.setEnabled(False)
1884
+ self.statusBar().showMessage("Calculating 3D structure...")
1885
+ self.start_calculation.emit(mol_block)
1886
+
1887
+ self.view_2d.setFocus()
1888
+
1889
+ def on_calculation_finished(self, mol):
1890
+ self.current_mol=mol
1891
+ self.draw_molecule_3d(mol)
1892
+ self.statusBar().showMessage("3D conversion successful.")
1893
+ self.convert_button.setEnabled(True)
1894
+ self.analysis_action.setEnabled(True)
1895
+ self.push_undo_state()
1896
+ self.view_2d.setFocus()
1897
+
1898
+ def on_calculation_error(self, error_message):
1899
+ self.statusBar().showMessage(f"Error: {error_message}")
1900
+ self.convert_button.setEnabled(True)
1901
+ self.analysis_action.setEnabled(False)
1902
+ self.view_2d.setFocus()
1903
+
1904
+ def eventFilter(self, obj, event):
1905
+ if obj is self.plotter and event.type() == QEvent.Type.MouseButtonPress:
1906
+ self.view_2d.setFocus()
1907
+ return super().eventFilter(obj, event)
1908
+
1909
+ def get_current_state(self):
1910
+ atoms = {atom_id: {'symbol': data['symbol'],
1911
+ 'pos': (data['item'].pos().x(), data['item'].pos().y()),
1912
+ 'charge': data.get('charge', 0),
1913
+ 'radical': data.get('radical', 0)} # <-- この行を追加
1914
+ for atom_id, data in self.data.atoms.items()}
1915
+ bonds = {key: {'order': data['order'], 'stereo': data.get('stereo', 0)} for key, data in self.data.bonds.items()}
1916
+ state = {'atoms': atoms, 'bonds': bonds, '_next_atom_id': self.data._next_atom_id}
1917
+
1918
+ if self.current_mol: state['mol_3d'] = self.current_mol.ToBinary()
1919
+
1920
+ return state
1921
+
1922
+ def set_state_from_data(self, state_data):
1923
+ self.clear_2d_editor(push_to_undo=False)
1924
+
1925
+ loaded_data = copy.deepcopy(state_data)
1926
+ raw_atoms = loaded_data.get('atoms', {})
1927
+ raw_bonds = loaded_data.get('bonds', {})
1928
+
1929
+ for atom_id, data in raw_atoms.items():
1930
+ pos = QPointF(data['pos'][0], data['pos'][1])
1931
+ charge = data.get('charge', 0)
1932
+ radical = data.get('radical', 0) # <-- ラジカル情報を取得
1933
+ # AtomItem生成時にradicalを渡す
1934
+ atom_item = AtomItem(atom_id, data['symbol'], pos, charge=charge, radical=radical)
1935
+ # self.data.atomsにもradical情報を格納する
1936
+ self.data.atoms[atom_id] = {'symbol': data['symbol'], 'pos': pos, 'item': atom_item, 'charge': charge, 'radical': radical}
1937
+ self.scene.addItem(atom_item)
1938
+
1939
+ self.data._next_atom_id = loaded_data.get('_next_atom_id', max(self.data.atoms.keys()) + 1 if self.data.atoms else 0)
1940
+
1941
+ for key_tuple, data in raw_bonds.items():
1942
+ id1, id2 = key_tuple
1943
+ if id1 in self.data.atoms and id2 in self.data.atoms:
1944
+ atom1_item = self.data.atoms[id1]['item']; atom2_item = self.data.atoms[id2]['item']
1945
+ bond_item = BondItem(atom1_item, atom2_item, data.get('order', 1), data.get('stereo', 0))
1946
+ self.data.bonds[key_tuple] = {'order': data.get('order', 1), 'stereo': data.get('stereo', 0), 'item': bond_item}
1947
+ atom1_item.bonds.append(bond_item); atom2_item.bonds.append(bond_item)
1948
+ self.scene.addItem(bond_item)
1949
+
1950
+ for atom_data in self.data.atoms.values():
1951
+ if atom_data['item']: atom_data['item'].update_style()
1952
+ self.scene.update()
1953
+
1954
+ if 'mol_3d' in loaded_data:
1955
+ try:
1956
+ self.current_mol = Chem.Mol(loaded_data['mol_3d'])
1957
+ self.draw_molecule_3d(self.current_mol)
1958
+ self.analysis_action.setEnabled(True)
1959
+ except Exception as e:
1960
+ self.statusBar().showMessage(f"Could not load 3D model from project: {e}")
1961
+ self.current_mol = None; self.analysis_action.setEnabled(False)
1962
+ else:
1963
+ self.current_mol = None; self.plotter.clear(); self.analysis_action.setEnabled(False)
1964
+
1965
+
1966
+ if 'mol_3d' in loaded_data:
1967
+ try:
1968
+ self.current_mol = Chem.Mol(loaded_data['mol_3d'])
1969
+ self.draw_molecule_3d(self.current_mol)
1970
+ self.analysis_action.setEnabled(True)
1971
+ except Exception as e:
1972
+ self.statusBar().showMessage(f"Could not load 3D model from project: {e}")
1973
+ self.current_mol = None; self.analysis_action.setEnabled(False)
1974
+ else:
1975
+ self.current_mol = None; self.plotter.clear(); self.analysis_action.setEnabled(False)
1976
+
1977
+ def push_undo_state(self):
1978
+ current_state_for_comparison = {
1979
+ 'atoms': {k: (v['symbol'], v['item'].pos().x(), v['item'].pos().y(), v.get('charge', 0), v.get('radical', 0)) for k, v in self.data.atoms.items()},
1980
+ 'bonds': {k: (v['order'], v.get('stereo', 0)) for k, v in self.data.bonds.items()},
1981
+ '_next_atom_id': self.data._next_atom_id
1982
+ }
1983
+
1984
+ last_state_for_comparison = None
1985
+ if self.undo_stack:
1986
+ last_atoms = self.undo_stack[-1].get('atoms', {})
1987
+ last_bonds = self.undo_stack[-1].get('bonds', {})
1988
+ last_state_for_comparison = {
1989
+ 'atoms': {k: (v['symbol'], v['pos'][0], v['pos'][1], v.get('charge', 0), v.get('radical', 0)) for k, v in last_atoms.items()},
1990
+ 'bonds': {k: (v['order'], v.get('stereo', 0)) for k, v in last_bonds.items()},
1991
+ '_next_atom_id': self.undo_stack[-1].get('_next_atom_id')
1992
+ }
1993
+
1994
+ if not last_state_for_comparison or current_state_for_comparison != last_state_for_comparison:
1995
+ state = self.get_current_state()
1996
+ self.undo_stack.append(state)
1997
+ self.redo_stack.clear()
1998
+
1999
+ self.update_undo_redo_actions()
2000
+
2001
+ def reset_undo_stack(self):
2002
+ self.undo_stack.clear()
2003
+ self.redo_stack.clear()
2004
+ self.push_undo_state()
2005
+
2006
+ def undo(self):
2007
+ if len(self.undo_stack) > 1:
2008
+ self.redo_stack.append(self.undo_stack.pop())
2009
+ state = self.undo_stack[-1]
2010
+ self.set_state_from_data(state)
2011
+ self.update_undo_redo_actions()
2012
+ self.view_2d.setFocus()
2013
+
2014
+ def redo(self):
2015
+ if self.redo_stack:
2016
+ state = self.redo_stack.pop()
2017
+ self.undo_stack.append(state)
2018
+ self.set_state_from_data(state)
2019
+ self.update_undo_redo_actions()
2020
+ self.view_2d.setFocus()
2021
+
2022
+ def update_undo_redo_actions(self):
2023
+ self.undo_action.setEnabled(len(self.undo_stack) > 1)
2024
+ self.redo_action.setEnabled(len(self.redo_stack) > 0)
2025
+
2026
+ def select_all(self):
2027
+ for item in self.scene.items():
2028
+ if isinstance(item, (AtomItem, BondItem)):
2029
+ item.setSelected(True)
2030
+
2031
+ def clear_all(self):
2032
+ if not self.data.atoms and self.current_mol is None: return
2033
+ self.clear_2d_editor(push_to_undo=False)
2034
+ self.current_mol = None
2035
+ self.plotter.clear()
2036
+ self.analysis_action.setEnabled(False)
2037
+ self.reset_undo_stack()
2038
+
2039
+ def clear_2d_editor(self, push_to_undo=True):
2040
+ self.data = MolecularData()
2041
+ self.scene.data = self.data
2042
+ self.scene.clear()
2043
+ self.scene.reinitialize_items()
2044
+ if push_to_undo:
2045
+ self.push_undo_state()
2046
+
2047
+
2048
+ def load_mol_file(self):
2049
+ options = QFileDialog.Option.DontUseNativeDialog
2050
+ file_path, _ = QFileDialog.getOpenFileName(self, "Open MOL/SDF File", "", "Chemical Files (*.mol *.sdf);;All Files (*)", options=options)
2051
+ if not file_path: return
2052
+ try:
2053
+ suppl = Chem.SDMolSupplier(file_path, removeHs=False)
2054
+ mol = next(suppl, None)
2055
+ if mol is None: raise ValueError("Failed to read molecule from file.")
2056
+ self.clear_2d_editor(push_to_undo=False)
2057
+ self.current_mol = None; self.plotter.clear(); self.analysis_action.setEnabled(False)
2058
+
2059
+ if mol.GetNumConformers() == 0: AllChem.Compute2DCoords(mol)
2060
+
2061
+ conf = mol.GetConformer(); SCALE_FACTOR = 50.0
2062
+ rdkit_idx_to_my_id = {}
2063
+ positions = [conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
2064
+ center_x = sum(p.x for p in positions)/len(positions) if positions else 0
2065
+ center_y = sum(p.y for p in positions)/len(positions) if positions else 0
2066
+ for i in range(mol.GetNumAtoms()):
2067
+ atom = mol.GetAtomWithIdx(i); pos = conf.GetAtomPosition(i)
2068
+ charge = atom.GetFormalCharge()
2069
+ scene_x=(pos.x-center_x)*SCALE_FACTOR; scene_y=-(pos.y-center_y)*SCALE_FACTOR
2070
+ atom_id = self.scene.create_atom(atom.GetSymbol(), QPointF(scene_x, scene_y), charge=charge)
2071
+ rdkit_idx_to_my_id[i] = atom_id
2072
+ for bond in mol.GetBonds():
2073
+ # (以降のBond読み込み部分は変更なし)
2074
+ b_idx,e_idx=bond.GetBeginAtomIdx(),bond.GetEndAtomIdx()
2075
+ b_type = bond.GetBondTypeAsDouble(); b_dir = bond.GetBondDir()
2076
+ stereo = 0
2077
+ if b_dir == Chem.BondDir.BEGINWEDGE: stereo = 1
2078
+ elif b_dir == Chem.BondDir.BEGINDASH: stereo = 2
2079
+ a1_id, a2_id = rdkit_idx_to_my_id[b_idx], rdkit_idx_to_my_id[e_idx]
2080
+ a1_item,a2_item=self.data.atoms[a1_id]['item'],self.data.atoms[a2_id]['item']
2081
+
2082
+ self.scene.create_bond(a1_item, a2_item, bond_order=int(b_type), bond_stereo=stereo)
2083
+
2084
+ self.statusBar().showMessage(f"Successfully loaded {file_path}")
2085
+ self.reset_undo_stack()
2086
+ except Exception as e: self.statusBar().showMessage(f"Error loading file: {e}")
2087
+
2088
+ def save_raw_data(self):
2089
+ if not self.data.atoms: self.statusBar().showMessage("Error: Nothing to save."); return
2090
+ save_data = self.get_current_state()
2091
+ options = QFileDialog.Option.DontUseNativeDialog
2092
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save Project File", "", "Project Files (*.pmeraw);;All Files (*)", options=options)
2093
+ if file_path:
2094
+ if not file_path.lower().endswith('.pmeraw'): file_path += '.pmeraw'
2095
+ try:
2096
+ with open(file_path, 'wb') as f: pickle.dump(save_data, f)
2097
+ self.statusBar().showMessage(f"Project saved to {file_path}")
2098
+ except Exception as e: self.statusBar().showMessage(f"Error saving project file: {e}")
2099
+
2100
+ def load_raw_data(self):
2101
+ options = QFileDialog.Option.DontUseNativeDialog
2102
+ file_path, _ = QFileDialog.getOpenFileName(self, "Open Project File", "", "Project Files (*.pmeraw);;All Files (*)", options=options)
2103
+ if not file_path: return
2104
+ try:
2105
+ with open(file_path, 'rb') as f: loaded_data = pickle.load(f)
2106
+ self.set_state_from_data(loaded_data)
2107
+ self.statusBar().showMessage(f"Project loaded from {file_path}")
2108
+ self.reset_undo_stack()
2109
+ except Exception as e: self.statusBar().showMessage(f"Error loading project file: {e}")
2110
+
2111
+ def save_as_mol(self):
2112
+ mol_block = self.data.to_mol_block()
2113
+ if not mol_block: self.statusBar().showMessage("Error: No 2D data to save."); return
2114
+ options=QFileDialog.Option.DontUseNativeDialog
2115
+ file_path,_=QFileDialog.getSaveFileName(self,"Save 2D MOL File","","MOL Files (*.mol);;All Files (*)",options=options)
2116
+ if file_path:
2117
+ if not file_path.lower().endswith('.mol'): file_path += '.mol'
2118
+ try:
2119
+ with open(file_path,'w') as f: f.write(mol_block)
2120
+ self.statusBar().showMessage(f"2D data saved to {file_path}")
2121
+ except Exception as e: self.statusBar().showMessage(f"Error saving file: {e}")
2122
+
2123
+ def save_3d_as_mol(self):
2124
+ if not self.current_mol:
2125
+ self.statusBar().showMessage("Error: Please generate a 3D structure first.")
2126
+ return
2127
+ options = QFileDialog.Option.DontUseNativeDialog
2128
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save 3D MOL File", "", "MOL Files (*.mol);;All Files (*)", options=options)
2129
+ if file_path:
2130
+ if not file_path.lower().endswith('.mol'):
2131
+ file_path += '.mol'
2132
+ try:
2133
+ mol_block = Chem.MolToMolBlock(self.current_mol, includeStereo=True)
2134
+ with open(file_path, 'w') as f:
2135
+ f.write(mol_block)
2136
+ self.statusBar().showMessage(f"3D data saved to {file_path}")
2137
+ except Exception as e: self.statusBar().showMessage(f"Error saving 3D MOL file: {e}")
2138
+
2139
+ def save_as_xyz(self):
2140
+ if not self.current_mol: self.statusBar().showMessage("Error: Please generate a 3D structure first."); return
2141
+ options=QFileDialog.Option.DontUseNativeDialog
2142
+ file_path,_=QFileDialog.getSaveFileName(self,"Save 3D XYZ File","","XYZ Files (*.xyz);;All Files (*)",options=options)
2143
+ if file_path:
2144
+ if not file_path.lower().endswith('.xyz'): file_path += '.xyz'
2145
+ try:
2146
+ conf=self.current_mol.GetConformer(); num_atoms=self.current_mol.GetNumAtoms()
2147
+ xyz_lines=[str(num_atoms)]; smiles=Chem.MolToSmiles(Chem.RemoveHs(self.current_mol))
2148
+ xyz_lines.append(f"Generated by moleditpy. SMILES: {smiles}")
2149
+ for i in range(num_atoms):
2150
+ pos=conf.GetAtomPosition(i); symbol=self.current_mol.GetAtomWithIdx(i).GetSymbol()
2151
+ xyz_lines.append(f"{symbol} {pos.x:.6f} {pos.y:.6f} {pos.z:.6f}")
2152
+ with open(file_path,'w') as f: f.write("\n".join(xyz_lines))
2153
+ self.statusBar().showMessage(f"Successfully saved to {file_path}")
2154
+ except Exception as e: self.statusBar().showMessage(f"Error saving file: {e}")
2155
+
2156
+ def open_periodic_table_dialog(self):
2157
+ dialog=PeriodicTableDialog(self); dialog.element_selected.connect(self.set_atom_from_periodic_table)
2158
+ checked_action=self.tool_group.checkedAction()
2159
+ if checked_action: self.tool_group.setExclusive(False); checked_action.setChecked(False); self.tool_group.setExclusive(True)
2160
+ dialog.exec()
2161
+
2162
+ def set_atom_from_periodic_table(self, symbol):
2163
+ self.set_mode(f'atom_{symbol}')
2164
+
2165
+ def clean_up_2d_structure(self):
2166
+ self.statusBar().showMessage("Optimizing 2D structure.")
2167
+ mol = self.data.to_rdkit_mol() # <--- to_rdkit_mol() を使用する方が簡潔
2168
+ if mol is None or mol.GetNumAtoms() == 0:
2169
+ self.statusBar().showMessage("Error: No atoms to optimize."); return
2170
+
2171
+ try:
2172
+ # RDKitのインデックスから原子IDへのマッピングを再構築
2173
+ # to_rdkit_mol() の実装では Atom.SetProp() などで元のIDを保持していないため、
2174
+ # to_mol_block() で使用されているのと同じ順序依存のロジックを再利用する。
2175
+
2176
+ # (1) RDKitの原子インデックス (i) とオリジナルの原子ID (atom_id) のマッピングを作成
2177
+ # to_mol_block() と同じ順序、つまり self.data.atoms.keys() の順序が
2178
+ # RDKitのインデックス順 (0, 1, 2, ...) となることを利用する。
2179
+ original_ids = list(self.data.atoms.keys())
2180
+ idx_to_atom_id = {i: original_ids[i] for i in range(mol.GetNumAtoms())}
2181
+
2182
+ # 2D座標の計算と中心化
2183
+ AllChem.Compute2DCoords(mol); conf=mol.GetConformer(); SCALE=50.0
2184
+ positions=[conf.GetAtomPosition(i) for i in range(mol.GetNumAtoms())]
2185
+ if not positions: self.statusBar().showMessage("Optimization complete."); return
2186
+ cx=sum(p.x for p in positions)/len(positions); cy=sum(p.y for p in positions)/len(positions)
2187
+
2188
+ # (2) マッピングを使用して正しい原子IDに対応する位置を更新
2189
+ for i in range(mol.GetNumAtoms()):
2190
+ atom_id = idx_to_atom_id.get(i)
2191
+ if atom_id is not None and atom_id in self.data.atoms:
2192
+ item=self.data.atoms[atom_id]['item']
2193
+ new_pos=conf.GetAtomPosition(i)
2194
+ sx=(new_pos.x-cx)*SCALE; sy=-(new_pos.y-cy)*SCALE
2195
+
2196
+ # QPointFで位置を設定し、データモデルとItemを更新
2197
+ item.setPos(sx,sy)
2198
+ self.data.atoms[atom_id]['pos'] = QPointF(sx, sy)
2199
+
2200
+ # ... (以下、結合の更新などは変更なし)
2201
+ for bond_data in self.data.bonds.values():
2202
+ if bond_data.get('item'): bond_data['item'].update_position()
2203
+ self.statusBar().showMessage("2D structure optimization successful.")
2204
+ self.push_undo_state()
2205
+ self.view_2d.setFocus()
2206
+ except Exception as e: self.statusBar().showMessage(f"Error during 2D optimization: {e}")
2207
+ finally:
2208
+ self.view_2d.setFocus()
2209
+
2210
+ def draw_molecule_3d(self, mol):
2211
+ self.plotter.clear()
2212
+ conf = mol.GetConformer()
2213
+ pos = np.array([list(conf.GetAtomPosition(i)) for i in range(mol.GetNumAtoms())])
2214
+ sym = [a.GetSymbol() for a in mol.GetAtoms()]
2215
+ col = np.array([CPK_COLORS_PV.get(s, [0.5, 0.5, 0.5]) for s in sym])
2216
+
2217
+ # --- ここからが修正箇所です ---
2218
+
2219
+ # スタイルに応じて原子半径を決定
2220
+ if self.current_3d_style == 'cpk':
2221
+ # CPKモデル: Van der Waals半径をそのまま使う(見栄えのため少しだけ縮小なら0.8くらい)
2222
+ rad = np.array([pt.GetRvdw(pt.GetAtomicNumber(s)) * 1.0 for s in sym])
2223
+ else: # ball_and_stick (デフォルト)
2224
+ # Ball & Stickモデル: 元のコード通り半径を小さくする
2225
+ rad = np.array([VDW_RADII.get(s, 0.4) for s in sym])
2226
+
2227
+ poly = pv.PolyData(pos)
2228
+ poly['colors'] = col
2229
+ poly['radii'] = rad
2230
+ glyphs = poly.glyph(scale='radii', geom=pv.Sphere(radius=1.0), orient=False)
2231
+
2232
+ edge_color = '#505050'
2233
+ self.plotter.add_mesh(glyphs, scalars='colors', rgb=True, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3, line_width=0.1)
2234
+
2235
+ # Ball & Stick スタイルの時のみ結合を描画
2236
+ if self.current_3d_style == 'ball_and_stick':
2237
+ for bond in mol.GetBonds():
2238
+ sp = np.array(conf.GetAtomPosition(bond.GetBeginAtomIdx()))
2239
+ ep = np.array(conf.GetAtomPosition(bond.GetEndAtomIdx()))
2240
+ bt = bond.GetBondType()
2241
+ c = (sp + ep) / 2
2242
+ d = ep - sp
2243
+ h = np.linalg.norm(d)
2244
+ if h == 0: continue
2245
+ color = [0.5, 0.5, 0.5]
2246
+ if bt == Chem.rdchem.BondType.SINGLE or bt == Chem.rdchem.BondType.AROMATIC:
2247
+ cyl = pv.Cylinder(center=c, direction=d, radius=0.1, height=h)
2248
+ self.plotter.add_mesh(cyl, color=color, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
2249
+ else:
2250
+ v1 = d / h
2251
+ v_arb = np.array([0, 0, 1])
2252
+ if np.allclose(np.abs(np.dot(v1, v_arb)), 1.0): v_arb = np.array([0, 1, 0])
2253
+ off_dir = np.cross(v1, v_arb)
2254
+ off_dir /= np.linalg.norm(off_dir)
2255
+ if bt == Chem.rdchem.BondType.DOUBLE:
2256
+ r = 0.09
2257
+ s = 0.15
2258
+ c1 = c + off_dir * (s / 2)
2259
+ c2 = c - off_dir * (s / 2)
2260
+ cyl1 = pv.Cylinder(center=c1, direction=d, radius=r, height=h)
2261
+ cyl2 = pv.Cylinder(center=c2, direction=d, radius=r, height=h)
2262
+ self.plotter.add_mesh(cyl1, color=color, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
2263
+ self.plotter.add_mesh(cyl2, color=color, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
2264
+ elif bt == Chem.rdchem.BondType.TRIPLE:
2265
+ r = 0.08
2266
+ s = 0.18
2267
+ cc = pv.Cylinder(center=c, direction=d, radius=r, height=h)
2268
+ c1 = pv.Cylinder(center=c + off_dir * s, direction=d, radius=r, height=h)
2269
+ c2 = pv.Cylinder(center=c - off_dir * s, direction=d, radius=r, height=h)
2270
+ self.plotter.add_mesh(cc, color=color, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
2271
+ self.plotter.add_mesh(c1, color=color, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
2272
+ self.plotter.add_mesh(c2, color=color, smooth_shading=True, show_edges=True, edge_color=edge_color, edge_opacity=0.3)
2273
+
2274
+ self.plotter.reset_camera()
2275
+
2276
+ def open_analysis_window(self):
2277
+ if self.current_mol:
2278
+ dialog = AnalysisWindow(self.current_mol, self)
2279
+ dialog.exec()
2280
+ else:
2281
+ self.statusBar().showMessage("Please generate a 3D structure first to show analysis.")
2282
+
2283
+
2284
+ def closeEvent(self, event):
2285
+ self.thread.quit(); self.thread.wait(); super().closeEvent(event)
2286
+
2287
+ # --- Application Execution ---
2288
+ if __name__ == '__main__':
2289
+ main()