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,117 @@
|
|
|
1
|
+
"""Dialog windows: SMILES input, file open/save, export settings."""
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk, filedialog, messagebox
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SmilesDialog:
|
|
8
|
+
"""Dialog for entering a SMILES string."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, parent):
|
|
11
|
+
self.result = None
|
|
12
|
+
self.dialog = tk.Toplevel(parent)
|
|
13
|
+
self.dialog.title("Enter SMILES")
|
|
14
|
+
self.dialog.geometry("400x150")
|
|
15
|
+
self.dialog.transient(parent)
|
|
16
|
+
self.dialog.grab_set()
|
|
17
|
+
|
|
18
|
+
ttk.Label(self.dialog, text="SMILES string:").pack(padx=10, pady=(10, 0), anchor="w")
|
|
19
|
+
self.entry = ttk.Entry(self.dialog, width=50)
|
|
20
|
+
self.entry.pack(padx=10, pady=5, fill="x")
|
|
21
|
+
self.entry.focus_set()
|
|
22
|
+
self.entry.bind("<Return>", lambda e: self._ok())
|
|
23
|
+
|
|
24
|
+
ttk.Label(self.dialog, text="Name (optional):").pack(padx=10, anchor="w")
|
|
25
|
+
self.name_entry = ttk.Entry(self.dialog, width=50)
|
|
26
|
+
self.name_entry.pack(padx=10, pady=5, fill="x")
|
|
27
|
+
|
|
28
|
+
bf = ttk.Frame(self.dialog)
|
|
29
|
+
bf.pack(pady=10)
|
|
30
|
+
ttk.Button(bf, text="OK", command=self._ok).pack(side="left", padx=5)
|
|
31
|
+
ttk.Button(bf, text="Cancel", command=self._cancel).pack(side="left", padx=5)
|
|
32
|
+
|
|
33
|
+
parent.wait_window(self.dialog)
|
|
34
|
+
|
|
35
|
+
def _ok(self):
|
|
36
|
+
smi = self.entry.get().strip()
|
|
37
|
+
name = self.name_entry.get().strip()
|
|
38
|
+
if smi:
|
|
39
|
+
self.result = (smi, name)
|
|
40
|
+
self.dialog.destroy()
|
|
41
|
+
|
|
42
|
+
def _cancel(self):
|
|
43
|
+
self.dialog.destroy()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FormulaDialog:
|
|
47
|
+
"""Dialog for entering a molecular formula (for simple VSEPR molecules)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, parent):
|
|
50
|
+
self.result = None
|
|
51
|
+
self.dialog = tk.Toplevel(parent)
|
|
52
|
+
self.dialog.title("Enter Formula")
|
|
53
|
+
self.dialog.geometry("350x120")
|
|
54
|
+
self.dialog.transient(parent)
|
|
55
|
+
self.dialog.grab_set()
|
|
56
|
+
|
|
57
|
+
ttk.Label(self.dialog, text="Molecular formula (e.g. H2O, CH4):").pack(padx=10, pady=(10, 0), anchor="w")
|
|
58
|
+
self.entry = ttk.Entry(self.dialog, width=30)
|
|
59
|
+
self.entry.pack(padx=10, pady=5)
|
|
60
|
+
self.entry.focus_set()
|
|
61
|
+
self.entry.bind("<Return>", lambda e: self._ok())
|
|
62
|
+
|
|
63
|
+
ttk.Label(self.dialog, text="Charge:").pack(padx=10, anchor="w")
|
|
64
|
+
self.charge = ttk.Entry(self.dialog, width=5)
|
|
65
|
+
self.charge.insert(0, "0")
|
|
66
|
+
self.charge.pack(padx=10, pady=2, anchor="w")
|
|
67
|
+
|
|
68
|
+
bf = ttk.Frame(self.dialog)
|
|
69
|
+
bf.pack(pady=5)
|
|
70
|
+
ttk.Button(bf, text="OK", command=self._ok).pack(side="left", padx=5)
|
|
71
|
+
ttk.Button(bf, text="Cancel", command=self._cancel).pack(side="left", padx=5)
|
|
72
|
+
|
|
73
|
+
parent.wait_window(self.dialog)
|
|
74
|
+
|
|
75
|
+
def _ok(self):
|
|
76
|
+
formula = self.entry.get().strip()
|
|
77
|
+
try:
|
|
78
|
+
charge = int(self.charge.get().strip())
|
|
79
|
+
except ValueError:
|
|
80
|
+
charge = 0
|
|
81
|
+
if formula:
|
|
82
|
+
self.result = (formula, charge)
|
|
83
|
+
self.dialog.destroy()
|
|
84
|
+
|
|
85
|
+
def _cancel(self):
|
|
86
|
+
self.dialog.destroy()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def file_open_dialog(parent, filetypes=None):
|
|
90
|
+
"""Show file open dialog, return filepath or None."""
|
|
91
|
+
if filetypes is None:
|
|
92
|
+
filetypes = [
|
|
93
|
+
("All supported", "*.xyz *.mol *.sdf *.pdb *.json *.smi"),
|
|
94
|
+
("XYZ files", "*.xyz"),
|
|
95
|
+
("MOL files", "*.mol"),
|
|
96
|
+
("SDF files", "*.sdf"),
|
|
97
|
+
("PDB files", "*.pdb"),
|
|
98
|
+
("JSON files", "*.json"),
|
|
99
|
+
("SMILES files", "*.smi"),
|
|
100
|
+
("All files", "*.*"),
|
|
101
|
+
]
|
|
102
|
+
return filedialog.askopenfilename(parent=parent, filetypes=filetypes)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def file_save_dialog(parent, default_ext=".xyz", filetypes=None):
|
|
106
|
+
"""Show file save dialog, return filepath or None."""
|
|
107
|
+
if filetypes is None:
|
|
108
|
+
filetypes = [
|
|
109
|
+
("XYZ files", "*.xyz"),
|
|
110
|
+
("MOL files", "*.mol"),
|
|
111
|
+
("SDF files", "*.sdf"),
|
|
112
|
+
("PDB files", "*.pdb"),
|
|
113
|
+
("JSON files", "*.json"),
|
|
114
|
+
("SMILES files", "*.smi"),
|
|
115
|
+
]
|
|
116
|
+
return filedialog.asksaveasfilename(parent=parent, defaultextension=default_ext,
|
|
117
|
+
filetypes=filetypes)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Event handler: atom placement, selection, coordinate computation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import numpy as np
|
|
5
|
+
from molbuilder.molecule.graph import Molecule, Hybridization
|
|
6
|
+
from molbuilder.core.bond_data import bond_length, SP3_ANGLE
|
|
7
|
+
from molbuilder.core.geometry import normalize, available_tetrahedral_dirs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MolEventHandler:
|
|
11
|
+
"""Manages molecule editing state and operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.mol = Molecule("untitled")
|
|
15
|
+
self.selected_atoms: list[int] = [] # selected atom indices
|
|
16
|
+
self.current_element = "C"
|
|
17
|
+
self.current_bond_order = 1
|
|
18
|
+
|
|
19
|
+
def add_atom_free(self, element: str = None) -> int:
|
|
20
|
+
"""Add an atom at a computed position.
|
|
21
|
+
|
|
22
|
+
If no atoms exist, place at origin.
|
|
23
|
+
If atoms are selected, place bonded to the first selected atom.
|
|
24
|
+
Otherwise place bonded to the last atom.
|
|
25
|
+
"""
|
|
26
|
+
sym = element or self.current_element
|
|
27
|
+
|
|
28
|
+
if len(self.mol.atoms) == 0:
|
|
29
|
+
idx = self.mol.add_atom(sym, [0.0, 0.0, 0.0], Hybridization.SP3)
|
|
30
|
+
return idx
|
|
31
|
+
|
|
32
|
+
# Determine attachment point
|
|
33
|
+
if self.selected_atoms:
|
|
34
|
+
parent = self.selected_atoms[0]
|
|
35
|
+
else:
|
|
36
|
+
parent = len(self.mol.atoms) - 1
|
|
37
|
+
|
|
38
|
+
idx = self.mol.add_atom_bonded(
|
|
39
|
+
sym, parent,
|
|
40
|
+
bond_order=self.current_bond_order,
|
|
41
|
+
hybridization=Hybridization.SP3,
|
|
42
|
+
)
|
|
43
|
+
return idx
|
|
44
|
+
|
|
45
|
+
def add_bond_between_selected(self) -> bool:
|
|
46
|
+
"""Add a bond between two selected atoms."""
|
|
47
|
+
if len(self.selected_atoms) < 2:
|
|
48
|
+
return False
|
|
49
|
+
i, j = self.selected_atoms[0], self.selected_atoms[1]
|
|
50
|
+
existing = self.mol.get_bond(i, j)
|
|
51
|
+
if existing:
|
|
52
|
+
return False
|
|
53
|
+
self.mol.add_bond(i, j, order=self.current_bond_order)
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
def delete_selected(self):
|
|
57
|
+
"""Delete selected atoms (rebuild molecule without them)."""
|
|
58
|
+
if not self.selected_atoms:
|
|
59
|
+
return
|
|
60
|
+
keep = [i for i in range(len(self.mol.atoms)) if i not in self.selected_atoms]
|
|
61
|
+
if not keep:
|
|
62
|
+
self.mol = Molecule(self.mol.name)
|
|
63
|
+
self.selected_atoms.clear()
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
remap = {}
|
|
67
|
+
new_mol = Molecule(self.mol.name)
|
|
68
|
+
for new_i, old_i in enumerate(keep):
|
|
69
|
+
a = self.mol.atoms[old_i]
|
|
70
|
+
new_mol.add_atom(a.symbol, a.position.copy(), a.hybridization)
|
|
71
|
+
remap[old_i] = new_i
|
|
72
|
+
|
|
73
|
+
for bond in self.mol.bonds:
|
|
74
|
+
if bond.atom_i in remap and bond.atom_j in remap:
|
|
75
|
+
new_mol.add_bond(remap[bond.atom_i], remap[bond.atom_j],
|
|
76
|
+
bond.order, bond.rotatable)
|
|
77
|
+
|
|
78
|
+
self.mol = new_mol
|
|
79
|
+
self.selected_atoms.clear()
|
|
80
|
+
|
|
81
|
+
def add_hydrogens(self):
|
|
82
|
+
"""Add missing hydrogens to all heavy atoms (simple valence fill)."""
|
|
83
|
+
from molbuilder.core.geometry import add_sp3_hydrogens
|
|
84
|
+
|
|
85
|
+
target_valence = {"C": 4, "N": 3, "O": 2, "S": 2, "P": 3, "B": 3}
|
|
86
|
+
for i, atom in enumerate(list(self.mol.atoms)):
|
|
87
|
+
if atom.symbol == "H":
|
|
88
|
+
continue
|
|
89
|
+
target = target_valence.get(atom.symbol)
|
|
90
|
+
if target is None:
|
|
91
|
+
continue
|
|
92
|
+
current = sum(b.order for b in self.mol.bonds
|
|
93
|
+
if b.atom_i == i or b.atom_j == i)
|
|
94
|
+
needed = target - current
|
|
95
|
+
if needed > 0 and atom.symbol == "C":
|
|
96
|
+
add_sp3_hydrogens(self.mol, i, needed)
|
|
97
|
+
elif needed > 0:
|
|
98
|
+
# Generic hydrogen addition
|
|
99
|
+
from molbuilder.core.bond_data import bond_length as bl
|
|
100
|
+
pos = atom.position + np.array([0.0, 0.0, bl(atom.symbol, "H", 1)])
|
|
101
|
+
for _ in range(needed):
|
|
102
|
+
h_idx = self.mol.add_atom_bonded("H", i, bond_order=1, rotatable=False)
|
|
103
|
+
|
|
104
|
+
def clear(self):
|
|
105
|
+
"""Remove all atoms."""
|
|
106
|
+
self.mol = Molecule(self.mol.name)
|
|
107
|
+
self.selected_atoms.clear()
|
|
108
|
+
|
|
109
|
+
def toggle_selection(self, atom_idx: int):
|
|
110
|
+
"""Toggle selection of an atom."""
|
|
111
|
+
if atom_idx in self.selected_atoms:
|
|
112
|
+
self.selected_atoms.remove(atom_idx)
|
|
113
|
+
else:
|
|
114
|
+
self.selected_atoms.append(atom_idx)
|
|
115
|
+
|
|
116
|
+
def clear_selection(self):
|
|
117
|
+
"""Clear atom selection."""
|
|
118
|
+
self.selected_atoms.clear()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Sidebar: molecule properties, analysis options."""
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MolSidebar(ttk.Frame):
|
|
8
|
+
"""Side panel showing molecule properties and analysis controls."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, parent, on_analyze=None):
|
|
11
|
+
super().__init__(parent, width=280)
|
|
12
|
+
self.pack_propagate(False)
|
|
13
|
+
self._on_analyze = on_analyze
|
|
14
|
+
self._build()
|
|
15
|
+
|
|
16
|
+
def _build(self):
|
|
17
|
+
# Info panel
|
|
18
|
+
info = ttk.LabelFrame(self, text="Molecule Info")
|
|
19
|
+
info.pack(fill="x", padx=5, pady=5)
|
|
20
|
+
|
|
21
|
+
self.name_var = tk.StringVar(value="(none)")
|
|
22
|
+
self.formula_var = tk.StringVar(value="")
|
|
23
|
+
self.atoms_var = tk.StringVar(value="0 atoms")
|
|
24
|
+
self.bonds_var = tk.StringVar(value="0 bonds")
|
|
25
|
+
self.mw_var = tk.StringVar(value="MW: --")
|
|
26
|
+
|
|
27
|
+
for var in [self.name_var, self.formula_var, self.atoms_var,
|
|
28
|
+
self.bonds_var, self.mw_var]:
|
|
29
|
+
ttk.Label(info, textvariable=var).pack(anchor="w", padx=5)
|
|
30
|
+
|
|
31
|
+
# Analysis buttons
|
|
32
|
+
af = ttk.LabelFrame(self, text="Analysis")
|
|
33
|
+
af.pack(fill="x", padx=5, pady=5)
|
|
34
|
+
analyses = [
|
|
35
|
+
"Functional Groups",
|
|
36
|
+
"Bond Analysis",
|
|
37
|
+
"Stereochemistry",
|
|
38
|
+
"SMILES",
|
|
39
|
+
"Retrosynthesis",
|
|
40
|
+
"Process Engineering",
|
|
41
|
+
]
|
|
42
|
+
for name in analyses:
|
|
43
|
+
b = ttk.Button(af, text=name,
|
|
44
|
+
command=lambda n=name: self._analyze(n))
|
|
45
|
+
b.pack(fill="x", padx=5, pady=1)
|
|
46
|
+
|
|
47
|
+
# Export buttons
|
|
48
|
+
ef = ttk.LabelFrame(self, text="Export")
|
|
49
|
+
ef.pack(fill="x", padx=5, pady=5)
|
|
50
|
+
for fmt in ["XYZ", "MOL/SDF", "PDB", "JSON", "SMILES"]:
|
|
51
|
+
b = ttk.Button(ef, text=f"Save as {fmt}",
|
|
52
|
+
command=lambda f=fmt: self._analyze(f"export_{f}"))
|
|
53
|
+
b.pack(fill="x", padx=5, pady=1)
|
|
54
|
+
|
|
55
|
+
# Results text area
|
|
56
|
+
rf = ttk.LabelFrame(self, text="Results")
|
|
57
|
+
rf.pack(fill="both", expand=True, padx=5, pady=5)
|
|
58
|
+
self.results_text = tk.Text(rf, wrap="word", bg="#111122",
|
|
59
|
+
fg="#ccddee", font=("Consolas", 9),
|
|
60
|
+
height=12)
|
|
61
|
+
scroll = ttk.Scrollbar(rf, orient="vertical",
|
|
62
|
+
command=self.results_text.yview)
|
|
63
|
+
self.results_text.configure(yscrollcommand=scroll.set)
|
|
64
|
+
scroll.pack(side="right", fill="y")
|
|
65
|
+
self.results_text.pack(fill="both", expand=True)
|
|
66
|
+
|
|
67
|
+
def update_info(self, mol):
|
|
68
|
+
"""Update displayed molecule info."""
|
|
69
|
+
if mol is None:
|
|
70
|
+
self.name_var.set("(none)")
|
|
71
|
+
self.formula_var.set("")
|
|
72
|
+
self.atoms_var.set("0 atoms")
|
|
73
|
+
self.bonds_var.set("0 bonds")
|
|
74
|
+
self.mw_var.set("MW: --")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
self.name_var.set(mol.name or "(unnamed)")
|
|
78
|
+
|
|
79
|
+
# Compute formula
|
|
80
|
+
from collections import Counter
|
|
81
|
+
counts = Counter(a.symbol for a in mol.atoms)
|
|
82
|
+
formula = ""
|
|
83
|
+
for sym in ["C", "H", "N", "O", "S", "P"]:
|
|
84
|
+
if sym in counts:
|
|
85
|
+
formula += sym + (str(counts[sym]) if counts[sym] > 1 else "")
|
|
86
|
+
del counts[sym]
|
|
87
|
+
for sym in sorted(counts):
|
|
88
|
+
formula += sym + (str(counts[sym]) if counts[sym] > 1 else "")
|
|
89
|
+
self.formula_var.set(formula)
|
|
90
|
+
|
|
91
|
+
self.atoms_var.set(f"{len(mol.atoms)} atoms")
|
|
92
|
+
self.bonds_var.set(f"{len(mol.bonds)} bonds")
|
|
93
|
+
|
|
94
|
+
from molbuilder.core.elements import atomic_weight
|
|
95
|
+
mw = sum(atomic_weight(a.symbol) for a in mol.atoms)
|
|
96
|
+
self.mw_var.set(f"MW: {mw:.2f} g/mol")
|
|
97
|
+
|
|
98
|
+
def show_results(self, text: str):
|
|
99
|
+
"""Display analysis results."""
|
|
100
|
+
self.results_text.delete("1.0", "end")
|
|
101
|
+
self.results_text.insert("1.0", text)
|
|
102
|
+
|
|
103
|
+
def _analyze(self, name):
|
|
104
|
+
if self._on_analyze:
|
|
105
|
+
self._on_analyze(name)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Toolbar: element palette, bond tools, mode buttons."""
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MolToolbar(ttk.Frame):
|
|
8
|
+
"""Toolbar with element selector, bond tools, and action buttons."""
|
|
9
|
+
|
|
10
|
+
COMMON_ELEMENTS = ["H", "C", "N", "O", "F", "P", "S", "Cl", "Br", "I"]
|
|
11
|
+
BOND_TYPES = [("Single", 1), ("Double", 2), ("Triple", 3)]
|
|
12
|
+
|
|
13
|
+
def __init__(self, parent, on_element_selected=None, on_bond_selected=None,
|
|
14
|
+
on_action=None):
|
|
15
|
+
super().__init__(parent)
|
|
16
|
+
self._on_element = on_element_selected
|
|
17
|
+
self._on_bond = on_bond_selected
|
|
18
|
+
self._on_action = on_action
|
|
19
|
+
self.selected_element = tk.StringVar(value="C")
|
|
20
|
+
self.selected_bond = tk.IntVar(value=1)
|
|
21
|
+
self._build()
|
|
22
|
+
|
|
23
|
+
def _build(self):
|
|
24
|
+
# Element palette
|
|
25
|
+
ef = ttk.LabelFrame(self, text="Element")
|
|
26
|
+
ef.pack(side="left", padx=5, pady=2)
|
|
27
|
+
for sym in self.COMMON_ELEMENTS:
|
|
28
|
+
b = ttk.Radiobutton(ef, text=sym, value=sym,
|
|
29
|
+
variable=self.selected_element,
|
|
30
|
+
command=self._elem_changed)
|
|
31
|
+
b.pack(side="left", padx=1)
|
|
32
|
+
|
|
33
|
+
# Custom element entry
|
|
34
|
+
self._custom = ttk.Entry(ef, width=4)
|
|
35
|
+
self._custom.pack(side="left", padx=2)
|
|
36
|
+
self._custom.bind("<Return>", self._custom_elem)
|
|
37
|
+
|
|
38
|
+
# Bond type
|
|
39
|
+
bf = ttk.LabelFrame(self, text="Bond")
|
|
40
|
+
bf.pack(side="left", padx=5, pady=2)
|
|
41
|
+
for label, val in self.BOND_TYPES:
|
|
42
|
+
b = ttk.Radiobutton(bf, text=label, value=val,
|
|
43
|
+
variable=self.selected_bond,
|
|
44
|
+
command=self._bond_changed)
|
|
45
|
+
b.pack(side="left", padx=1)
|
|
46
|
+
|
|
47
|
+
# Action buttons
|
|
48
|
+
af = ttk.LabelFrame(self, text="Actions")
|
|
49
|
+
af.pack(side="left", padx=5, pady=2)
|
|
50
|
+
for label in ["Add Atom", "Add Bond", "Delete", "Add H", "Clear"]:
|
|
51
|
+
b = ttk.Button(af, text=label,
|
|
52
|
+
command=lambda l=label: self._action(l))
|
|
53
|
+
b.pack(side="left", padx=2)
|
|
54
|
+
|
|
55
|
+
def _elem_changed(self):
|
|
56
|
+
if self._on_element:
|
|
57
|
+
self._on_element(self.selected_element.get())
|
|
58
|
+
|
|
59
|
+
def _custom_elem(self, event):
|
|
60
|
+
sym = self._custom.get().strip().capitalize()
|
|
61
|
+
if sym:
|
|
62
|
+
self.selected_element.set(sym)
|
|
63
|
+
self._elem_changed()
|
|
64
|
+
|
|
65
|
+
def _bond_changed(self):
|
|
66
|
+
if self._on_bond:
|
|
67
|
+
self._on_bond(self.selected_bond.get())
|
|
68
|
+
|
|
69
|
+
def _action(self, action):
|
|
70
|
+
if self._on_action:
|
|
71
|
+
self._on_action(action)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""File I/O: XYZ, JSON, MOL/SDF, PDB, SMILES."""
|
molbuilder/io/json_io.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""JSON serialisation / deserialisation for Molecule objects.
|
|
2
|
+
|
|
3
|
+
Schema::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"name": "ethanol",
|
|
7
|
+
"atoms": [
|
|
8
|
+
{"index": 0, "symbol": "C", "position": [0.0, 0.0, 0.0],
|
|
9
|
+
"hybridization": "SP3"},
|
|
10
|
+
...
|
|
11
|
+
],
|
|
12
|
+
"bonds": [
|
|
13
|
+
{"atom_i": 0, "atom_j": 1, "order": 1, "rotatable": true},
|
|
14
|
+
...
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"formula": "C2H6O",
|
|
18
|
+
"atom_count": 9,
|
|
19
|
+
"bond_count": 8
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections import Counter
|
|
28
|
+
|
|
29
|
+
from molbuilder.molecule.graph import Molecule, Hybridization
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# Map for serialising Hybridization to a string and back.
|
|
35
|
+
_HYB_TO_STR = {
|
|
36
|
+
Hybridization.SP3: "SP3",
|
|
37
|
+
Hybridization.SP2: "SP2",
|
|
38
|
+
Hybridization.SP: "SP",
|
|
39
|
+
}
|
|
40
|
+
_STR_TO_HYB = {v: k for k, v in _HYB_TO_STR.items()}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _molecular_formula(mol: Molecule) -> str:
|
|
44
|
+
"""Return a Hill-system molecular formula (C first, H second, then
|
|
45
|
+
alphabetical)."""
|
|
46
|
+
counts: Counter[str] = Counter()
|
|
47
|
+
for atom in mol.atoms:
|
|
48
|
+
counts[atom.symbol] += 1
|
|
49
|
+
|
|
50
|
+
parts: list[str] = []
|
|
51
|
+
for sym in ("C", "H"):
|
|
52
|
+
if sym in counts:
|
|
53
|
+
n = counts.pop(sym)
|
|
54
|
+
parts.append(sym if n == 1 else f"{sym}{n}")
|
|
55
|
+
for sym in sorted(counts):
|
|
56
|
+
n = counts[sym]
|
|
57
|
+
parts.append(sym if n == 1 else f"{sym}{n}")
|
|
58
|
+
return "".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Molecule -> dict -> JSON ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _mol_to_dict(mol: Molecule) -> dict:
|
|
64
|
+
"""Convert a Molecule to a JSON-serialisable dictionary."""
|
|
65
|
+
atoms = []
|
|
66
|
+
for atom in mol.atoms:
|
|
67
|
+
hyb_str = _HYB_TO_STR.get(atom.hybridization) if atom.hybridization else None
|
|
68
|
+
atoms.append({
|
|
69
|
+
"index": atom.index,
|
|
70
|
+
"symbol": atom.symbol,
|
|
71
|
+
"position": atom.position.tolist(),
|
|
72
|
+
"hybridization": hyb_str,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
bonds = []
|
|
76
|
+
for bond in mol.bonds:
|
|
77
|
+
bonds.append({
|
|
78
|
+
"atom_i": bond.atom_i,
|
|
79
|
+
"atom_j": bond.atom_j,
|
|
80
|
+
"order": bond.order,
|
|
81
|
+
"rotatable": bond.rotatable,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"name": mol.name,
|
|
86
|
+
"atoms": atoms,
|
|
87
|
+
"bonds": bonds,
|
|
88
|
+
"properties": {
|
|
89
|
+
"formula": _molecular_formula(mol),
|
|
90
|
+
"atom_count": len(mol.atoms),
|
|
91
|
+
"bond_count": len(mol.bonds),
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _dict_to_mol(data: dict) -> Molecule:
|
|
97
|
+
"""Reconstruct a Molecule from a JSON-derived dictionary."""
|
|
98
|
+
mol = Molecule(name=data.get("name", ""))
|
|
99
|
+
|
|
100
|
+
for atom_data in data["atoms"]:
|
|
101
|
+
hyb = None
|
|
102
|
+
hyb_str = atom_data.get("hybridization")
|
|
103
|
+
if hyb_str is not None:
|
|
104
|
+
hyb = _STR_TO_HYB.get(hyb_str)
|
|
105
|
+
mol.add_atom(
|
|
106
|
+
symbol=atom_data["symbol"],
|
|
107
|
+
position=atom_data["position"],
|
|
108
|
+
hybridization=hyb,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
for bond_data in data["bonds"]:
|
|
112
|
+
mol.add_bond(
|
|
113
|
+
i=bond_data["atom_i"],
|
|
114
|
+
j=bond_data["atom_j"],
|
|
115
|
+
order=bond_data.get("order", 1),
|
|
116
|
+
rotatable=bond_data.get("rotatable", True),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return mol
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── String serialisation ─────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def to_json_string(mol: Molecule, indent: int = 2) -> str:
|
|
125
|
+
"""Serialise a Molecule to a JSON string."""
|
|
126
|
+
return json.dumps(_mol_to_dict(mol), indent=indent)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def from_json_string(content: str) -> Molecule:
|
|
130
|
+
"""Deserialise a Molecule from a JSON string."""
|
|
131
|
+
data = json.loads(content)
|
|
132
|
+
return _dict_to_mol(data)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── File I/O ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def write_json(mol: Molecule, filepath: str) -> None:
|
|
138
|
+
"""Write a Molecule to a JSON file."""
|
|
139
|
+
with open(filepath, "w") as f:
|
|
140
|
+
f.write(to_json_string(mol))
|
|
141
|
+
|
|
142
|
+
def read_json(filepath: str) -> Molecule:
|
|
143
|
+
"""Read a Molecule from a JSON file."""
|
|
144
|
+
with open(filepath, "r") as f:
|
|
145
|
+
content = f.read()
|
|
146
|
+
return from_json_string(content)
|