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()
|