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.
Files changed (78) hide show
  1. molbuilder/__init__.py +8 -0
  2. molbuilder/__main__.py +6 -0
  3. molbuilder/atomic/__init__.py +4 -0
  4. molbuilder/atomic/bohr.py +235 -0
  5. molbuilder/atomic/quantum_atom.py +334 -0
  6. molbuilder/atomic/quantum_numbers.py +196 -0
  7. molbuilder/atomic/wavefunctions.py +297 -0
  8. molbuilder/bonding/__init__.py +4 -0
  9. molbuilder/bonding/covalent.py +442 -0
  10. molbuilder/bonding/lewis.py +347 -0
  11. molbuilder/bonding/vsepr.py +433 -0
  12. molbuilder/cli/__init__.py +1 -0
  13. molbuilder/cli/demos.py +516 -0
  14. molbuilder/cli/menu.py +127 -0
  15. molbuilder/cli/wizard.py +831 -0
  16. molbuilder/core/__init__.py +6 -0
  17. molbuilder/core/bond_data.py +170 -0
  18. molbuilder/core/constants.py +51 -0
  19. molbuilder/core/element_properties.py +183 -0
  20. molbuilder/core/elements.py +181 -0
  21. molbuilder/core/geometry.py +232 -0
  22. molbuilder/gui/__init__.py +2 -0
  23. molbuilder/gui/app.py +286 -0
  24. molbuilder/gui/canvas3d.py +115 -0
  25. molbuilder/gui/dialogs.py +117 -0
  26. molbuilder/gui/event_handler.py +118 -0
  27. molbuilder/gui/sidebar.py +105 -0
  28. molbuilder/gui/toolbar.py +71 -0
  29. molbuilder/io/__init__.py +1 -0
  30. molbuilder/io/json_io.py +146 -0
  31. molbuilder/io/mol_sdf.py +169 -0
  32. molbuilder/io/pdb.py +184 -0
  33. molbuilder/io/smiles_io.py +47 -0
  34. molbuilder/io/xyz.py +103 -0
  35. molbuilder/molecule/__init__.py +2 -0
  36. molbuilder/molecule/amino_acids.py +919 -0
  37. molbuilder/molecule/builders.py +257 -0
  38. molbuilder/molecule/conformations.py +70 -0
  39. molbuilder/molecule/functional_groups.py +484 -0
  40. molbuilder/molecule/graph.py +712 -0
  41. molbuilder/molecule/peptides.py +13 -0
  42. molbuilder/molecule/stereochemistry.py +6 -0
  43. molbuilder/process/__init__.py +3 -0
  44. molbuilder/process/conditions.py +260 -0
  45. molbuilder/process/costing.py +316 -0
  46. molbuilder/process/purification.py +285 -0
  47. molbuilder/process/reactor.py +297 -0
  48. molbuilder/process/safety.py +476 -0
  49. molbuilder/process/scale_up.py +427 -0
  50. molbuilder/process/solvent_systems.py +204 -0
  51. molbuilder/reactions/__init__.py +3 -0
  52. molbuilder/reactions/functional_group_detect.py +728 -0
  53. molbuilder/reactions/knowledge_base.py +1716 -0
  54. molbuilder/reactions/reaction_types.py +102 -0
  55. molbuilder/reactions/reagent_data.py +1248 -0
  56. molbuilder/reactions/retrosynthesis.py +1430 -0
  57. molbuilder/reactions/synthesis_route.py +377 -0
  58. molbuilder/reports/__init__.py +158 -0
  59. molbuilder/reports/cost_report.py +206 -0
  60. molbuilder/reports/molecule_report.py +279 -0
  61. molbuilder/reports/safety_report.py +296 -0
  62. molbuilder/reports/synthesis_report.py +283 -0
  63. molbuilder/reports/text_formatter.py +170 -0
  64. molbuilder/smiles/__init__.py +4 -0
  65. molbuilder/smiles/parser.py +487 -0
  66. molbuilder/smiles/tokenizer.py +291 -0
  67. molbuilder/smiles/writer.py +375 -0
  68. molbuilder/visualization/__init__.py +1 -0
  69. molbuilder/visualization/bohr_viz.py +166 -0
  70. molbuilder/visualization/molecule_viz.py +368 -0
  71. molbuilder/visualization/quantum_viz.py +434 -0
  72. molbuilder/visualization/theme.py +12 -0
  73. molbuilder-1.0.0.dist-info/METADATA +360 -0
  74. molbuilder-1.0.0.dist-info/RECORD +78 -0
  75. molbuilder-1.0.0.dist-info/WHEEL +5 -0
  76. molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
  77. molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
  78. 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
@@ -0,0 +1,2 @@
1
+ """Interactive 3D GUI for molecule building and analysis."""
2
+ from molbuilder.gui.app import launch
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