molbuilder 1.0.0__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.
- molbuilder/__init__.py +8 -0
- molbuilder/__main__.py +6 -0
- molbuilder/atomic/__init__.py +4 -0
- molbuilder/atomic/bohr.py +235 -0
- molbuilder/atomic/quantum_atom.py +334 -0
- molbuilder/atomic/quantum_numbers.py +196 -0
- molbuilder/atomic/wavefunctions.py +297 -0
- molbuilder/bonding/__init__.py +4 -0
- molbuilder/bonding/covalent.py +442 -0
- molbuilder/bonding/lewis.py +347 -0
- molbuilder/bonding/vsepr.py +433 -0
- molbuilder/cli/__init__.py +1 -0
- molbuilder/cli/demos.py +516 -0
- molbuilder/cli/menu.py +127 -0
- molbuilder/cli/wizard.py +831 -0
- molbuilder/core/__init__.py +6 -0
- molbuilder/core/bond_data.py +170 -0
- molbuilder/core/constants.py +51 -0
- molbuilder/core/element_properties.py +183 -0
- molbuilder/core/elements.py +181 -0
- molbuilder/core/geometry.py +232 -0
- molbuilder/gui/__init__.py +2 -0
- molbuilder/gui/app.py +286 -0
- molbuilder/gui/canvas3d.py +115 -0
- molbuilder/gui/dialogs.py +117 -0
- molbuilder/gui/event_handler.py +118 -0
- molbuilder/gui/sidebar.py +105 -0
- molbuilder/gui/toolbar.py +71 -0
- molbuilder/io/__init__.py +1 -0
- molbuilder/io/json_io.py +146 -0
- molbuilder/io/mol_sdf.py +169 -0
- molbuilder/io/pdb.py +184 -0
- molbuilder/io/smiles_io.py +47 -0
- molbuilder/io/xyz.py +103 -0
- molbuilder/molecule/__init__.py +2 -0
- molbuilder/molecule/amino_acids.py +919 -0
- molbuilder/molecule/builders.py +257 -0
- molbuilder/molecule/conformations.py +70 -0
- molbuilder/molecule/functional_groups.py +484 -0
- molbuilder/molecule/graph.py +712 -0
- molbuilder/molecule/peptides.py +13 -0
- molbuilder/molecule/stereochemistry.py +6 -0
- molbuilder/process/__init__.py +3 -0
- molbuilder/process/conditions.py +260 -0
- molbuilder/process/costing.py +316 -0
- molbuilder/process/purification.py +285 -0
- molbuilder/process/reactor.py +297 -0
- molbuilder/process/safety.py +476 -0
- molbuilder/process/scale_up.py +427 -0
- molbuilder/process/solvent_systems.py +204 -0
- molbuilder/reactions/__init__.py +3 -0
- molbuilder/reactions/functional_group_detect.py +728 -0
- molbuilder/reactions/knowledge_base.py +1716 -0
- molbuilder/reactions/reaction_types.py +102 -0
- molbuilder/reactions/reagent_data.py +1248 -0
- molbuilder/reactions/retrosynthesis.py +1430 -0
- molbuilder/reactions/synthesis_route.py +377 -0
- molbuilder/reports/__init__.py +158 -0
- molbuilder/reports/cost_report.py +206 -0
- molbuilder/reports/molecule_report.py +279 -0
- molbuilder/reports/safety_report.py +296 -0
- molbuilder/reports/synthesis_report.py +283 -0
- molbuilder/reports/text_formatter.py +170 -0
- molbuilder/smiles/__init__.py +4 -0
- molbuilder/smiles/parser.py +487 -0
- molbuilder/smiles/tokenizer.py +291 -0
- molbuilder/smiles/writer.py +375 -0
- molbuilder/visualization/__init__.py +1 -0
- molbuilder/visualization/bohr_viz.py +166 -0
- molbuilder/visualization/molecule_viz.py +368 -0
- molbuilder/visualization/quantum_viz.py +434 -0
- molbuilder/visualization/theme.py +12 -0
- molbuilder-1.0.0.dist-info/METADATA +360 -0
- molbuilder-1.0.0.dist-info/RECORD +78 -0
- molbuilder-1.0.0.dist-info/WHEEL +5 -0
- molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
- molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
- molbuilder-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
3D geometry utilities for molecular coordinate construction.
|
|
3
|
+
|
|
4
|
+
Public API for functions that were previously private (_normalize,
|
|
5
|
+
_rotation_matrix, etc.) in molecular_conformations.py. Also includes
|
|
6
|
+
coordinate conversions from quantum_wavefunctions.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from molbuilder.molecule.graph import Molecule
|
|
18
|
+
|
|
19
|
+
# Tolerance constants for near-zero comparisons
|
|
20
|
+
_ZERO_VECTOR_TOL = 1e-12 # For zero-length vector detection
|
|
21
|
+
_COLLINEAR_TOL = 1e-10 # For collinear atom detection
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ===================================================================
|
|
25
|
+
# Vector utilities
|
|
26
|
+
# ===================================================================
|
|
27
|
+
|
|
28
|
+
def normalize(v: np.ndarray) -> np.ndarray:
|
|
29
|
+
"""Return unit vector, or zero vector if input is near-zero."""
|
|
30
|
+
n = np.linalg.norm(v)
|
|
31
|
+
if n < _ZERO_VECTOR_TOL:
|
|
32
|
+
return np.zeros(3)
|
|
33
|
+
return v / n
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rotation_matrix(axis: np.ndarray, theta: float) -> np.ndarray:
|
|
37
|
+
"""Rodrigues rotation matrix: rotate by *theta* radians about *axis*."""
|
|
38
|
+
u = normalize(axis)
|
|
39
|
+
K = np.array([
|
|
40
|
+
[0.0, -u[2], u[1]],
|
|
41
|
+
[u[2], 0.0, -u[0]],
|
|
42
|
+
[-u[1], u[0], 0.0],
|
|
43
|
+
])
|
|
44
|
+
return np.eye(3) + math.sin(theta) * K + (1.0 - math.cos(theta)) * (K @ K)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ===================================================================
|
|
48
|
+
# Z-matrix atom placement
|
|
49
|
+
# ===================================================================
|
|
50
|
+
|
|
51
|
+
def place_atom_zmatrix(pos_ref: np.ndarray,
|
|
52
|
+
pos_angle_ref: np.ndarray,
|
|
53
|
+
pos_dihedral_ref: np.ndarray,
|
|
54
|
+
bond_length: float,
|
|
55
|
+
bond_angle_deg: float,
|
|
56
|
+
dihedral_deg: float) -> np.ndarray:
|
|
57
|
+
"""Place an atom using internal (z-matrix) coordinates.
|
|
58
|
+
|
|
59
|
+
Given three reference positions (j, i, k), place a new atom m so that
|
|
60
|
+
distance(j,m)=bond_length, angle(i-j-m)=bond_angle_deg, and
|
|
61
|
+
dihedral(k-i-j-m)=dihedral_deg.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
pos_ref : position of bonded atom j.
|
|
66
|
+
pos_angle_ref : position of atom i (defines bond angle).
|
|
67
|
+
pos_dihedral_ref : position of atom k (defines dihedral).
|
|
68
|
+
bond_length, bond_angle_deg, dihedral_deg : internal coordinates.
|
|
69
|
+
"""
|
|
70
|
+
theta = math.radians(bond_angle_deg)
|
|
71
|
+
phi = math.radians(dihedral_deg)
|
|
72
|
+
|
|
73
|
+
v_ij = normalize(pos_ref - pos_angle_ref)
|
|
74
|
+
v_ki = normalize(pos_angle_ref - pos_dihedral_ref)
|
|
75
|
+
|
|
76
|
+
# Plane normal
|
|
77
|
+
n = np.cross(v_ki, v_ij)
|
|
78
|
+
if np.linalg.norm(n) < _COLLINEAR_TOL:
|
|
79
|
+
# Collinear fallback
|
|
80
|
+
perp = np.array([1.0, 0.0, 0.0])
|
|
81
|
+
if abs(np.dot(v_ij, perp)) > 0.9:
|
|
82
|
+
perp = np.array([0.0, 1.0, 0.0])
|
|
83
|
+
n = np.cross(v_ij, perp)
|
|
84
|
+
n = normalize(n)
|
|
85
|
+
|
|
86
|
+
# In-plane perpendicular
|
|
87
|
+
d = normalize(np.cross(n, v_ij))
|
|
88
|
+
|
|
89
|
+
new_dir = (
|
|
90
|
+
-v_ij * math.cos(theta)
|
|
91
|
+
+ d * math.sin(theta) * math.cos(phi)
|
|
92
|
+
+ n * math.sin(theta) * math.sin(phi)
|
|
93
|
+
)
|
|
94
|
+
return pos_ref + bond_length * normalize(new_dir)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ===================================================================
|
|
98
|
+
# Tetrahedral direction computation
|
|
99
|
+
# ===================================================================
|
|
100
|
+
|
|
101
|
+
def available_tetrahedral_dirs(
|
|
102
|
+
existing_dirs: list[np.ndarray],
|
|
103
|
+
count: int,
|
|
104
|
+
) -> list[np.ndarray]:
|
|
105
|
+
"""Compute *count* tetrahedral directions that avoid *existing_dirs*.
|
|
106
|
+
|
|
107
|
+
Given the unit-direction vectors of bonds already on a tetrahedral
|
|
108
|
+
centre, returns *count* new unit-direction vectors that complete the
|
|
109
|
+
tetrahedral arrangement.
|
|
110
|
+
"""
|
|
111
|
+
n = len(existing_dirs)
|
|
112
|
+
tet_angle = math.acos(-1.0 / 3.0) # ~109.47 deg
|
|
113
|
+
|
|
114
|
+
if n == 0:
|
|
115
|
+
dirs = [
|
|
116
|
+
np.array([1, 1, 1]) / math.sqrt(3),
|
|
117
|
+
np.array([1, -1, -1]) / math.sqrt(3),
|
|
118
|
+
np.array([-1, 1, -1]) / math.sqrt(3),
|
|
119
|
+
np.array([-1, -1, 1]) / math.sqrt(3),
|
|
120
|
+
]
|
|
121
|
+
return dirs[:count]
|
|
122
|
+
|
|
123
|
+
if n == 1:
|
|
124
|
+
v0 = normalize(existing_dirs[0])
|
|
125
|
+
perp = np.array([1.0, 0.0, 0.0])
|
|
126
|
+
if abs(np.dot(v0, perp)) > 0.9:
|
|
127
|
+
perp = np.array([0.0, 1.0, 0.0])
|
|
128
|
+
p = normalize(np.cross(v0, perp))
|
|
129
|
+
q = normalize(np.cross(v0, p))
|
|
130
|
+
|
|
131
|
+
ct = math.cos(tet_angle)
|
|
132
|
+
st = math.sin(tet_angle)
|
|
133
|
+
|
|
134
|
+
dirs = []
|
|
135
|
+
for i in range(count):
|
|
136
|
+
phi = math.radians(120.0 * i)
|
|
137
|
+
d = v0 * ct + (p * math.cos(phi) + q * math.sin(phi)) * st
|
|
138
|
+
dirs.append(normalize(d))
|
|
139
|
+
return dirs
|
|
140
|
+
|
|
141
|
+
if n == 2:
|
|
142
|
+
v0 = normalize(existing_dirs[0])
|
|
143
|
+
v1 = normalize(existing_dirs[1])
|
|
144
|
+
bisector = normalize(v0 + v1)
|
|
145
|
+
out_of_plane = normalize(np.cross(v0, v1))
|
|
146
|
+
|
|
147
|
+
bv0 = float(np.dot(bisector, v0))
|
|
148
|
+
if abs(bv0) > _COLLINEAR_TOL:
|
|
149
|
+
cb = (1.0 / 3.0) / bv0
|
|
150
|
+
else:
|
|
151
|
+
cb = 0.0
|
|
152
|
+
cb = np.clip(cb, -1.0, 1.0)
|
|
153
|
+
sb = math.sqrt(max(0.0, 1.0 - cb * cb))
|
|
154
|
+
|
|
155
|
+
dirs = []
|
|
156
|
+
if count >= 1:
|
|
157
|
+
dirs.append(normalize(-bisector * cb + out_of_plane * sb))
|
|
158
|
+
if count >= 2:
|
|
159
|
+
dirs.append(normalize(-bisector * cb - out_of_plane * sb))
|
|
160
|
+
return dirs
|
|
161
|
+
|
|
162
|
+
if n >= 3:
|
|
163
|
+
centroid = normalize(sum(existing_dirs))
|
|
164
|
+
return [normalize(-centroid)][:count]
|
|
165
|
+
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ===================================================================
|
|
170
|
+
# Hydrogen placement helper
|
|
171
|
+
# ===================================================================
|
|
172
|
+
|
|
173
|
+
def add_sp3_hydrogens(mol: Molecule, carbon_idx: int, count: int) -> None:
|
|
174
|
+
"""Add *count* hydrogens to fill remaining tetrahedral positions.
|
|
175
|
+
|
|
176
|
+
Uses direct 3D geometry to find available tetrahedral directions
|
|
177
|
+
relative to the existing bonds on the carbon.
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
mol : Molecule
|
|
182
|
+
Molecule object to modify in-place.
|
|
183
|
+
carbon_idx : int
|
|
184
|
+
Index of the atom to add hydrogens to.
|
|
185
|
+
count : int
|
|
186
|
+
Number of hydrogens to add.
|
|
187
|
+
"""
|
|
188
|
+
from molbuilder.core.bond_data import bond_length as _bond_length
|
|
189
|
+
|
|
190
|
+
c_pos = mol.atoms[carbon_idx].position
|
|
191
|
+
existing = mol.neighbors(carbon_idx)
|
|
192
|
+
if not existing:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
existing_dirs = [
|
|
196
|
+
normalize(mol.atoms[n].position - c_pos) for n in existing
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
CH = _bond_length("C", "H", 1)
|
|
200
|
+
new_dirs = available_tetrahedral_dirs(existing_dirs, count)
|
|
201
|
+
|
|
202
|
+
for d in new_dirs:
|
|
203
|
+
h_pos = c_pos + CH * d
|
|
204
|
+
h_idx = mol.add_atom("H", h_pos)
|
|
205
|
+
mol.add_bond(carbon_idx, h_idx, order=1, rotatable=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ===================================================================
|
|
209
|
+
# Coordinate conversions (from quantum_wavefunctions.py)
|
|
210
|
+
# ===================================================================
|
|
211
|
+
|
|
212
|
+
def cartesian_to_spherical(x, y, z):
|
|
213
|
+
"""Convert (x, y, z) to (r, theta, phi).
|
|
214
|
+
|
|
215
|
+
theta: polar angle [0, pi], phi: azimuthal [0, 2*pi].
|
|
216
|
+
"""
|
|
217
|
+
x, y, z = np.asarray(x, float), np.asarray(y, float), np.asarray(z, float)
|
|
218
|
+
r = np.sqrt(x**2 + y**2 + z**2)
|
|
219
|
+
theta = np.where(r > 0, np.arccos(np.clip(z / np.where(r > 0, r, 1.0), -1, 1)), 0.0)
|
|
220
|
+
phi = np.arctan2(y, x) % (2 * np.pi)
|
|
221
|
+
return r, theta, phi
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def spherical_to_cartesian(r, theta, phi):
|
|
225
|
+
"""Convert (r, theta, phi) to (x, y, z)."""
|
|
226
|
+
r = np.asarray(r, float)
|
|
227
|
+
theta = np.asarray(theta, float)
|
|
228
|
+
phi = np.asarray(phi, float)
|
|
229
|
+
x = r * np.sin(theta) * np.cos(phi)
|
|
230
|
+
y = r * np.sin(theta) * np.sin(phi)
|
|
231
|
+
z = r * np.cos(theta)
|
|
232
|
+
return x, y, z
|
molbuilder/gui/app.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Main tkinter application for the MolBuilder 3D GUI."""
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk, messagebox
|
|
5
|
+
|
|
6
|
+
from molbuilder.gui.canvas3d import MolCanvas3D
|
|
7
|
+
from molbuilder.gui.toolbar import MolToolbar
|
|
8
|
+
from molbuilder.gui.sidebar import MolSidebar
|
|
9
|
+
from molbuilder.gui.dialogs import SmilesDialog, FormulaDialog, file_open_dialog, file_save_dialog
|
|
10
|
+
from molbuilder.gui.event_handler import MolEventHandler
|
|
11
|
+
from molbuilder.molecule.graph import Molecule
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MolBuilderApp:
|
|
15
|
+
"""Main MolBuilder GUI application."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.root = tk.Tk()
|
|
19
|
+
self.root.title("MolBuilder - Molecular Engineering Tool")
|
|
20
|
+
self.root.geometry("1200x800")
|
|
21
|
+
self.root.configure(bg="#1a1a2e")
|
|
22
|
+
|
|
23
|
+
self.handler = MolEventHandler()
|
|
24
|
+
self._build_menu()
|
|
25
|
+
self._build_ui()
|
|
26
|
+
self._refresh()
|
|
27
|
+
|
|
28
|
+
def _build_menu(self):
|
|
29
|
+
menubar = tk.Menu(self.root)
|
|
30
|
+
self.root.config(menu=menubar)
|
|
31
|
+
|
|
32
|
+
# File menu
|
|
33
|
+
file_menu = tk.Menu(menubar, tearoff=0)
|
|
34
|
+
menubar.add_cascade(label="File", menu=file_menu)
|
|
35
|
+
file_menu.add_command(label="New", command=self._new)
|
|
36
|
+
file_menu.add_command(label="Open...", command=self._open)
|
|
37
|
+
file_menu.add_command(label="Save As...", command=self._save)
|
|
38
|
+
file_menu.add_separator()
|
|
39
|
+
file_menu.add_command(label="Exit", command=self.root.quit)
|
|
40
|
+
|
|
41
|
+
# Build menu
|
|
42
|
+
build_menu = tk.Menu(menubar, tearoff=0)
|
|
43
|
+
menubar.add_cascade(label="Build", menu=build_menu)
|
|
44
|
+
build_menu.add_command(label="From SMILES...", command=self._from_smiles)
|
|
45
|
+
build_menu.add_command(label="From Formula (VSEPR)...", command=self._from_formula)
|
|
46
|
+
build_menu.add_separator()
|
|
47
|
+
build_menu.add_command(label="Preset: Ethanol", command=lambda: self._preset("CCO", "ethanol"))
|
|
48
|
+
build_menu.add_command(label="Preset: Aspirin", command=lambda: self._preset("CC(=O)Oc1ccccc1C(=O)O", "aspirin"))
|
|
49
|
+
build_menu.add_command(label="Preset: Caffeine", command=lambda: self._preset("Cn1c(=O)c2c(ncn2C)n(C)c1=O", "caffeine"))
|
|
50
|
+
build_menu.add_command(label="Preset: Benzene", command=lambda: self._preset("c1ccccc1", "benzene"))
|
|
51
|
+
build_menu.add_command(label="Preset: Glucose", command=lambda: self._preset("OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O", "glucose"))
|
|
52
|
+
|
|
53
|
+
# Analysis menu
|
|
54
|
+
analysis_menu = tk.Menu(menubar, tearoff=0)
|
|
55
|
+
menubar.add_cascade(label="Analysis", menu=analysis_menu)
|
|
56
|
+
analysis_menu.add_command(label="Detect Functional Groups", command=lambda: self._run_analysis("Functional Groups"))
|
|
57
|
+
analysis_menu.add_command(label="Bond Analysis", command=lambda: self._run_analysis("Bond Analysis"))
|
|
58
|
+
analysis_menu.add_command(label="Generate SMILES", command=lambda: self._run_analysis("SMILES"))
|
|
59
|
+
|
|
60
|
+
def _build_ui(self):
|
|
61
|
+
# Toolbar at top
|
|
62
|
+
self.toolbar = MolToolbar(
|
|
63
|
+
self.root,
|
|
64
|
+
on_element_selected=self._on_element,
|
|
65
|
+
on_bond_selected=self._on_bond,
|
|
66
|
+
on_action=self._on_action,
|
|
67
|
+
)
|
|
68
|
+
self.toolbar.pack(fill="x")
|
|
69
|
+
|
|
70
|
+
# Main area: canvas + sidebar
|
|
71
|
+
main = ttk.PanedWindow(self.root, orient="horizontal")
|
|
72
|
+
main.pack(fill="both", expand=True)
|
|
73
|
+
|
|
74
|
+
canvas_frame = ttk.Frame(main)
|
|
75
|
+
self.canvas = MolCanvas3D(canvas_frame)
|
|
76
|
+
main.add(canvas_frame, weight=3)
|
|
77
|
+
|
|
78
|
+
self.sidebar = MolSidebar(main, on_analyze=self._run_analysis)
|
|
79
|
+
main.add(self.sidebar, weight=1)
|
|
80
|
+
|
|
81
|
+
# Status bar
|
|
82
|
+
self.status_var = tk.StringVar(value="Ready")
|
|
83
|
+
status = ttk.Label(self.root, textvariable=self.status_var, relief="sunken")
|
|
84
|
+
status.pack(fill="x", side="bottom")
|
|
85
|
+
|
|
86
|
+
def _refresh(self):
|
|
87
|
+
"""Re-render molecule and update sidebar."""
|
|
88
|
+
self.canvas.render(self.handler.mol)
|
|
89
|
+
self.sidebar.update_info(self.handler.mol)
|
|
90
|
+
|
|
91
|
+
def _on_element(self, sym):
|
|
92
|
+
self.handler.current_element = sym
|
|
93
|
+
self.status_var.set(f"Element: {sym}")
|
|
94
|
+
|
|
95
|
+
def _on_bond(self, order):
|
|
96
|
+
self.handler.current_bond_order = order
|
|
97
|
+
labels = {1: "Single", 2: "Double", 3: "Triple"}
|
|
98
|
+
self.status_var.set(f"Bond: {labels.get(order, '?')}")
|
|
99
|
+
|
|
100
|
+
def _on_action(self, action):
|
|
101
|
+
if action == "Add Atom":
|
|
102
|
+
self.handler.add_atom_free()
|
|
103
|
+
elif action == "Add Bond":
|
|
104
|
+
if not self.handler.add_bond_between_selected():
|
|
105
|
+
self.status_var.set("Select 2 atoms to bond")
|
|
106
|
+
return
|
|
107
|
+
elif action == "Delete":
|
|
108
|
+
self.handler.delete_selected()
|
|
109
|
+
elif action == "Add H":
|
|
110
|
+
self.handler.add_hydrogens()
|
|
111
|
+
elif action == "Clear":
|
|
112
|
+
self.handler.clear()
|
|
113
|
+
self._refresh()
|
|
114
|
+
|
|
115
|
+
def _new(self):
|
|
116
|
+
self.handler.clear()
|
|
117
|
+
self.handler.mol.name = "untitled"
|
|
118
|
+
self._refresh()
|
|
119
|
+
|
|
120
|
+
def _open(self):
|
|
121
|
+
path = file_open_dialog(self.root)
|
|
122
|
+
if not path:
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
ext = path.rsplit(".", 1)[-1].lower()
|
|
126
|
+
if ext == "xyz":
|
|
127
|
+
from molbuilder.io.xyz import read_xyz
|
|
128
|
+
self.handler.mol = read_xyz(path)
|
|
129
|
+
elif ext in ("mol", "sdf"):
|
|
130
|
+
from molbuilder.io.mol_sdf import read_mol
|
|
131
|
+
self.handler.mol = read_mol(path)
|
|
132
|
+
elif ext == "pdb":
|
|
133
|
+
from molbuilder.io.pdb import read_pdb
|
|
134
|
+
self.handler.mol = read_pdb(path)
|
|
135
|
+
elif ext == "json":
|
|
136
|
+
from molbuilder.io.json_io import read_json
|
|
137
|
+
self.handler.mol = read_json(path)
|
|
138
|
+
else:
|
|
139
|
+
messagebox.showerror("Error", f"Unsupported format: .{ext}")
|
|
140
|
+
return
|
|
141
|
+
self.status_var.set(f"Opened: {path}")
|
|
142
|
+
self._refresh()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
messagebox.showerror("Error", str(e))
|
|
145
|
+
|
|
146
|
+
def _save(self):
|
|
147
|
+
path = file_save_dialog(self.root)
|
|
148
|
+
if not path:
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
ext = path.rsplit(".", 1)[-1].lower()
|
|
152
|
+
if ext == "xyz":
|
|
153
|
+
from molbuilder.io.xyz import write_xyz
|
|
154
|
+
write_xyz(self.handler.mol, path)
|
|
155
|
+
elif ext == "mol":
|
|
156
|
+
from molbuilder.io.mol_sdf import write_mol
|
|
157
|
+
write_mol(self.handler.mol, path)
|
|
158
|
+
elif ext == "pdb":
|
|
159
|
+
from molbuilder.io.pdb import write_pdb
|
|
160
|
+
write_pdb(self.handler.mol, path)
|
|
161
|
+
elif ext == "json":
|
|
162
|
+
from molbuilder.io.json_io import write_json
|
|
163
|
+
write_json(self.handler.mol, path)
|
|
164
|
+
elif ext == "smi":
|
|
165
|
+
from molbuilder.io.smiles_io import write_smiles
|
|
166
|
+
write_smiles(self.handler.mol, path)
|
|
167
|
+
else:
|
|
168
|
+
messagebox.showerror("Error", f"Unsupported format: .{ext}")
|
|
169
|
+
return
|
|
170
|
+
self.status_var.set(f"Saved: {path}")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
messagebox.showerror("Error", str(e))
|
|
173
|
+
|
|
174
|
+
def _from_smiles(self):
|
|
175
|
+
dlg = SmilesDialog(self.root)
|
|
176
|
+
if dlg.result:
|
|
177
|
+
smi, name = dlg.result
|
|
178
|
+
try:
|
|
179
|
+
from molbuilder.smiles import parse
|
|
180
|
+
mol = parse(smi)
|
|
181
|
+
mol.name = name or smi
|
|
182
|
+
self.handler.mol = mol
|
|
183
|
+
self.handler.selected_atoms.clear()
|
|
184
|
+
self.status_var.set(f"Built from SMILES: {smi}")
|
|
185
|
+
self._refresh()
|
|
186
|
+
except Exception as e:
|
|
187
|
+
messagebox.showerror("SMILES Error", str(e))
|
|
188
|
+
|
|
189
|
+
def _from_formula(self):
|
|
190
|
+
dlg = FormulaDialog(self.root)
|
|
191
|
+
if dlg.result:
|
|
192
|
+
formula, charge = dlg.result
|
|
193
|
+
try:
|
|
194
|
+
from molbuilder.bonding.vsepr import VSEPRMolecule
|
|
195
|
+
vsepr = VSEPRMolecule(formula, charge)
|
|
196
|
+
# Convert VSEPR coords to Molecule
|
|
197
|
+
coords = vsepr.coordinates
|
|
198
|
+
mol = Molecule(formula)
|
|
199
|
+
for sym, pos in coords["atom_positions"]:
|
|
200
|
+
if sym is not None and pos is not None:
|
|
201
|
+
mol.add_atom(sym, pos)
|
|
202
|
+
for ai, aj, order in coords["bonds"]:
|
|
203
|
+
mol.add_bond(ai, aj, order, rotatable=False)
|
|
204
|
+
self.handler.mol = mol
|
|
205
|
+
self.handler.selected_atoms.clear()
|
|
206
|
+
self.status_var.set(f"Built {formula} via VSEPR")
|
|
207
|
+
self._refresh()
|
|
208
|
+
except Exception as e:
|
|
209
|
+
messagebox.showerror("Formula Error", str(e))
|
|
210
|
+
|
|
211
|
+
def _preset(self, smiles, name):
|
|
212
|
+
try:
|
|
213
|
+
from molbuilder.smiles import parse
|
|
214
|
+
mol = parse(smiles)
|
|
215
|
+
mol.name = name
|
|
216
|
+
self.handler.mol = mol
|
|
217
|
+
self.handler.selected_atoms.clear()
|
|
218
|
+
self.status_var.set(f"Loaded preset: {name}")
|
|
219
|
+
self._refresh()
|
|
220
|
+
except Exception as e:
|
|
221
|
+
messagebox.showerror("Error", str(e))
|
|
222
|
+
|
|
223
|
+
def _run_analysis(self, name):
|
|
224
|
+
mol = self.handler.mol
|
|
225
|
+
if not mol or len(mol.atoms) == 0:
|
|
226
|
+
self.sidebar.show_results("No molecule loaded.")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
if name == "Functional Groups":
|
|
231
|
+
from molbuilder.reactions.functional_group_detect import detect_functional_groups
|
|
232
|
+
groups = detect_functional_groups(mol)
|
|
233
|
+
if groups:
|
|
234
|
+
lines = [f"Found {len(groups)} functional group(s):\n"]
|
|
235
|
+
for g in groups:
|
|
236
|
+
lines.append(f" {g.name} (atom {g.center})")
|
|
237
|
+
self.sidebar.show_results("\n".join(lines))
|
|
238
|
+
else:
|
|
239
|
+
self.sidebar.show_results("No functional groups detected.")
|
|
240
|
+
|
|
241
|
+
elif name == "Bond Analysis":
|
|
242
|
+
lines = [f"Bond Analysis: {mol.name}\n"]
|
|
243
|
+
lines.append(f"{'Bond':<12} {'Order':>5} {'Length':>8}")
|
|
244
|
+
lines.append("-" * 30)
|
|
245
|
+
for b in mol.bonds:
|
|
246
|
+
sa = mol.atoms[b.atom_i].symbol
|
|
247
|
+
sb = mol.atoms[b.atom_j].symbol
|
|
248
|
+
d = mol.distance(b.atom_i, b.atom_j)
|
|
249
|
+
sym = {1: "-", 2: "=", 3: "#"}.get(b.order, "?")
|
|
250
|
+
lines.append(f"{sa}{sym}{sb:<8} {b.order:>5} {d:>7.3f} A")
|
|
251
|
+
self.sidebar.show_results("\n".join(lines))
|
|
252
|
+
|
|
253
|
+
elif name == "SMILES":
|
|
254
|
+
from molbuilder.smiles.writer import to_smiles
|
|
255
|
+
smi = to_smiles(mol)
|
|
256
|
+
self.sidebar.show_results(f"SMILES: {smi}")
|
|
257
|
+
|
|
258
|
+
elif name == "Stereochemistry":
|
|
259
|
+
lines = ["Stereochemistry Analysis:\n"]
|
|
260
|
+
for i, atom in enumerate(mol.atoms):
|
|
261
|
+
if atom.symbol == "H":
|
|
262
|
+
continue
|
|
263
|
+
nbrs = mol.neighbors(i)
|
|
264
|
+
if len(nbrs) == 4:
|
|
265
|
+
desc = mol.assign_rs(i)
|
|
266
|
+
if desc.name != "NONE":
|
|
267
|
+
lines.append(f" {atom.symbol}[{i}]: {desc.name}")
|
|
268
|
+
if len(lines) == 1:
|
|
269
|
+
lines.append(" No stereocenters found.")
|
|
270
|
+
self.sidebar.show_results("\n".join(lines))
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
self.sidebar.show_results(f"Analysis '{name}' not yet implemented.")
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
self.sidebar.show_results(f"Error: {e}")
|
|
277
|
+
|
|
278
|
+
def run(self):
|
|
279
|
+
"""Start the application main loop."""
|
|
280
|
+
self.root.mainloop()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def launch():
|
|
284
|
+
"""Launch the MolBuilder GUI."""
|
|
285
|
+
app = MolBuilderApp()
|
|
286
|
+
app.run()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Embedded matplotlib 3D canvas for tkinter."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib
|
|
5
|
+
matplotlib.use("TkAgg")
|
|
6
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
|
7
|
+
from matplotlib.figure import Figure
|
|
8
|
+
from mpl_toolkits.mplot3d import Axes3D
|
|
9
|
+
|
|
10
|
+
from molbuilder.core.element_properties import cpk_color, covalent_radius_pm
|
|
11
|
+
from molbuilder.visualization.theme import BG_COLOR, TEXT_COLOR, GRID_COLOR, BOND_COLOR
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MolCanvas3D:
|
|
15
|
+
"""3D molecule viewport embedded in a tkinter frame."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, parent_frame):
|
|
18
|
+
self.parent = parent_frame
|
|
19
|
+
self.fig = Figure(figsize=(7, 6), facecolor=BG_COLOR)
|
|
20
|
+
self.ax = self.fig.add_subplot(111, projection="3d", facecolor=BG_COLOR)
|
|
21
|
+
|
|
22
|
+
self.canvas = FigureCanvasTkAgg(self.fig, master=parent_frame)
|
|
23
|
+
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
|
24
|
+
|
|
25
|
+
self.toolbar = NavigationToolbar2Tk(self.canvas, parent_frame)
|
|
26
|
+
self.toolbar.update()
|
|
27
|
+
|
|
28
|
+
self._style_axes()
|
|
29
|
+
self.mol = None
|
|
30
|
+
self._pick_callback = None
|
|
31
|
+
|
|
32
|
+
def _style_axes(self):
|
|
33
|
+
"""Apply dark theme to 3D axes."""
|
|
34
|
+
for pane in [self.ax.xaxis.pane, self.ax.yaxis.pane, self.ax.zaxis.pane]:
|
|
35
|
+
pane.set_facecolor(BG_COLOR)
|
|
36
|
+
pane.set_edgecolor(GRID_COLOR)
|
|
37
|
+
self.ax.tick_params(colors=TEXT_COLOR, labelsize=7)
|
|
38
|
+
self.ax.set_xlabel("x (A)", color=TEXT_COLOR, fontsize=8)
|
|
39
|
+
self.ax.set_ylabel("y (A)", color=TEXT_COLOR, fontsize=8)
|
|
40
|
+
self.ax.set_zlabel("z (A)", color=TEXT_COLOR, fontsize=8)
|
|
41
|
+
|
|
42
|
+
def set_pick_callback(self, callback):
|
|
43
|
+
"""Set callback for atom picking: callback(atom_index)."""
|
|
44
|
+
self._pick_callback = callback
|
|
45
|
+
|
|
46
|
+
def render(self, mol):
|
|
47
|
+
"""Render a Molecule object."""
|
|
48
|
+
self.mol = mol
|
|
49
|
+
self.ax.clear()
|
|
50
|
+
self._style_axes()
|
|
51
|
+
|
|
52
|
+
if mol is None or len(mol.atoms) == 0:
|
|
53
|
+
self.canvas.draw()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Draw bonds
|
|
57
|
+
for bond in mol.bonds:
|
|
58
|
+
pa = mol.atoms[bond.atom_i].position
|
|
59
|
+
pb = mol.atoms[bond.atom_j].position
|
|
60
|
+
if bond.order == 1:
|
|
61
|
+
self.ax.plot([pa[0], pb[0]], [pa[1], pb[1]], [pa[2], pb[2]],
|
|
62
|
+
color=BOND_COLOR, linewidth=2.0, zorder=3)
|
|
63
|
+
elif bond.order == 2:
|
|
64
|
+
mid = (pa + pb) / 2
|
|
65
|
+
perp = self._perp_offset(pa, pb, 0.06)
|
|
66
|
+
for s in [1, -1]:
|
|
67
|
+
self.ax.plot([pa[0] + s * perp[0], pb[0] + s * perp[0]],
|
|
68
|
+
[pa[1] + s * perp[1], pb[1] + s * perp[1]],
|
|
69
|
+
[pa[2] + s * perp[2], pb[2] + s * perp[2]],
|
|
70
|
+
color=BOND_COLOR, linewidth=1.8, zorder=3)
|
|
71
|
+
elif bond.order == 3:
|
|
72
|
+
self.ax.plot([pa[0], pb[0]], [pa[1], pb[1]], [pa[2], pb[2]],
|
|
73
|
+
color=BOND_COLOR, linewidth=2.0, zorder=3)
|
|
74
|
+
perp = self._perp_offset(pa, pb, 0.07)
|
|
75
|
+
for s in [1, -1]:
|
|
76
|
+
self.ax.plot([pa[0] + s * perp[0], pb[0] + s * perp[0]],
|
|
77
|
+
[pa[1] + s * perp[1], pb[1] + s * perp[1]],
|
|
78
|
+
[pa[2] + s * perp[2], pb[2] + s * perp[2]],
|
|
79
|
+
color=BOND_COLOR, linewidth=1.2, zorder=3)
|
|
80
|
+
|
|
81
|
+
# Draw atoms
|
|
82
|
+
for atom in mol.atoms:
|
|
83
|
+
color = cpk_color(atom.symbol)
|
|
84
|
+
size = 120 + covalent_radius_pm(atom.symbol) * 0.6
|
|
85
|
+
self.ax.scatter(*atom.position, c=color, s=size,
|
|
86
|
+
edgecolors="white", linewidths=0.3,
|
|
87
|
+
alpha=0.92, zorder=5, depthshade=True)
|
|
88
|
+
self.ax.text(atom.position[0] + 0.06, atom.position[1] + 0.06,
|
|
89
|
+
atom.position[2] + 0.06, atom.symbol,
|
|
90
|
+
color=TEXT_COLOR, fontsize=8, fontweight="bold", zorder=10)
|
|
91
|
+
|
|
92
|
+
# Auto-scale
|
|
93
|
+
all_pos = np.array([a.position for a in mol.atoms])
|
|
94
|
+
if len(all_pos) > 0:
|
|
95
|
+
mx = max(np.max(np.abs(all_pos)) * 1.4, 1.0)
|
|
96
|
+
self.ax.set_xlim(-mx, mx)
|
|
97
|
+
self.ax.set_ylim(-mx, mx)
|
|
98
|
+
self.ax.set_zlim(-mx, mx)
|
|
99
|
+
|
|
100
|
+
self.ax.set_title(mol.name or "Molecule", color=TEXT_COLOR, fontsize=11)
|
|
101
|
+
self.canvas.draw()
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _perp_offset(pa, pb, mag):
|
|
105
|
+
"""Compute a perpendicular offset vector for drawing multi-bonds."""
|
|
106
|
+
d = pb - pa
|
|
107
|
+
n = np.linalg.norm(d)
|
|
108
|
+
if n < 1e-10:
|
|
109
|
+
return np.array([mag, 0, 0])
|
|
110
|
+
d = d / n
|
|
111
|
+
ref = np.array([1.0, 0, 0])
|
|
112
|
+
if abs(np.dot(d, ref)) > 0.9:
|
|
113
|
+
ref = np.array([0, 1.0, 0])
|
|
114
|
+
p = np.cross(d, ref)
|
|
115
|
+
return p / np.linalg.norm(p) * mag
|