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,166 @@
|
|
|
1
|
+
"""Bohr model visualization: animated orbital diagram.
|
|
2
|
+
|
|
3
|
+
Migrated from legacy/bohr_model.py -- wavelength_to_rgb and visualize functions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import numpy as np
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import matplotlib.animation as animation
|
|
10
|
+
from matplotlib.patches import Circle
|
|
11
|
+
from molbuilder.visualization.theme import BG_COLOR, TEXT_COLOR, GRID_COLOR
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def wavelength_to_rgb(wavelength_nm: float) -> tuple:
|
|
15
|
+
"""Convert a visible-light wavelength (380-780 nm) to an RGB tuple.
|
|
16
|
+
Returns white for wavelengths outside visible range."""
|
|
17
|
+
if wavelength_nm < 380 or wavelength_nm > 780:
|
|
18
|
+
return (1.0, 1.0, 1.0)
|
|
19
|
+
|
|
20
|
+
if wavelength_nm < 440:
|
|
21
|
+
r = -(wavelength_nm - 440) / (440 - 380)
|
|
22
|
+
g = 0.0
|
|
23
|
+
b = 1.0
|
|
24
|
+
elif wavelength_nm < 490:
|
|
25
|
+
r = 0.0
|
|
26
|
+
g = (wavelength_nm - 440) / (490 - 440)
|
|
27
|
+
b = 1.0
|
|
28
|
+
elif wavelength_nm < 510:
|
|
29
|
+
r = 0.0
|
|
30
|
+
g = 1.0
|
|
31
|
+
b = -(wavelength_nm - 510) / (510 - 490)
|
|
32
|
+
elif wavelength_nm < 580:
|
|
33
|
+
r = (wavelength_nm - 510) / (580 - 510)
|
|
34
|
+
g = 1.0
|
|
35
|
+
b = 0.0
|
|
36
|
+
elif wavelength_nm < 645:
|
|
37
|
+
r = 1.0
|
|
38
|
+
g = -(wavelength_nm - 645) / (645 - 580)
|
|
39
|
+
b = 0.0
|
|
40
|
+
else:
|
|
41
|
+
r = 1.0
|
|
42
|
+
g = 0.0
|
|
43
|
+
b = 0.0
|
|
44
|
+
|
|
45
|
+
# intensity fall-off at edges of visible spectrum
|
|
46
|
+
if wavelength_nm < 420:
|
|
47
|
+
factor = 0.3 + 0.7 * (wavelength_nm - 380) / (420 - 380)
|
|
48
|
+
elif wavelength_nm > 700:
|
|
49
|
+
factor = 0.3 + 0.7 * (780 - wavelength_nm) / (780 - 700)
|
|
50
|
+
else:
|
|
51
|
+
factor = 1.0
|
|
52
|
+
|
|
53
|
+
return (r * factor, g * factor, b * factor)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def visualize(atom, animate: bool = True, interval_ms: int = 30):
|
|
57
|
+
"""Render an animated Bohr model diagram of the atom.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
atom : BohrAtom
|
|
62
|
+
The atom to visualize.
|
|
63
|
+
animate : bool
|
|
64
|
+
If True, electrons orbit the nucleus. If False, show a static frame.
|
|
65
|
+
interval_ms : int
|
|
66
|
+
Milliseconds between animation frames.
|
|
67
|
+
"""
|
|
68
|
+
n_shells = atom.num_shells
|
|
69
|
+
if n_shells == 0:
|
|
70
|
+
print("No electrons to visualize.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
fig, ax = plt.subplots(1, 1, figsize=(8, 8), facecolor="black")
|
|
74
|
+
ax.set_facecolor("black")
|
|
75
|
+
ax.set_aspect("equal")
|
|
76
|
+
ax.set_xlim(-n_shells - 1, n_shells + 1)
|
|
77
|
+
ax.set_ylim(-n_shells - 1, n_shells + 1)
|
|
78
|
+
ax.axis("off")
|
|
79
|
+
|
|
80
|
+
# Title
|
|
81
|
+
charge_label = ""
|
|
82
|
+
if atom.charge > 0:
|
|
83
|
+
charge_label = f"$^{{+{atom.charge}}}$"
|
|
84
|
+
elif atom.charge < 0:
|
|
85
|
+
charge_label = f"$^{{{atom.charge}}}$"
|
|
86
|
+
ax.set_title(
|
|
87
|
+
f"Bohr Model - {atom.name} ({atom.symbol}{charge_label}) "
|
|
88
|
+
f"Z={atom.atomic_number} e={atom.num_electrons}",
|
|
89
|
+
color="white", fontsize=14, pad=12,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# ----- Nucleus -----
|
|
93
|
+
nucleus_radius = 0.25 + 0.03 * atom.atomic_number**0.33
|
|
94
|
+
nucleus = Circle((0, 0), nucleus_radius, color="#ff6633", zorder=10)
|
|
95
|
+
ax.add_patch(nucleus)
|
|
96
|
+
ax.text(0, 0, f"{atom.protons}p\n{atom.neutrons}n",
|
|
97
|
+
ha="center", va="center", fontsize=7, color="white",
|
|
98
|
+
fontweight="bold", zorder=11)
|
|
99
|
+
|
|
100
|
+
# ----- Orbital rings -----
|
|
101
|
+
shell_radii = []
|
|
102
|
+
for n in range(1, n_shells + 1):
|
|
103
|
+
r = n # use integer spacing for visual clarity
|
|
104
|
+
shell_radii.append(r)
|
|
105
|
+
orbit = Circle((0, 0), r, fill=False, edgecolor="#334466",
|
|
106
|
+
linewidth=0.8, linestyle="--", zorder=1)
|
|
107
|
+
ax.add_patch(orbit)
|
|
108
|
+
ax.text(r + 0.15, 0.15, f"n={n}", fontsize=7, color="#5588aa", zorder=2)
|
|
109
|
+
|
|
110
|
+
# ----- Electron dots (initial positions) -----
|
|
111
|
+
electron_artists = []
|
|
112
|
+
electron_positions = [] # (shell_index, angle_offset, shell_radius)
|
|
113
|
+
|
|
114
|
+
for shell_idx, count in enumerate(atom.shell_config):
|
|
115
|
+
r = shell_radii[shell_idx]
|
|
116
|
+
for e in range(count):
|
|
117
|
+
angle = 2 * math.pi * e / count
|
|
118
|
+
x = r * math.cos(angle)
|
|
119
|
+
y = r * math.sin(angle)
|
|
120
|
+
dot = ax.plot(x, y, 'o', color="#44ccff", markersize=5, zorder=5)[0]
|
|
121
|
+
electron_artists.append(dot)
|
|
122
|
+
electron_positions.append((shell_idx, angle, r, count))
|
|
123
|
+
|
|
124
|
+
# ----- Shell electron count labels -----
|
|
125
|
+
for shell_idx, count in enumerate(atom.shell_config):
|
|
126
|
+
r = shell_radii[shell_idx]
|
|
127
|
+
ax.text(-r - 0.15, -0.25, str(count), fontsize=8, color="#88bbdd",
|
|
128
|
+
ha="right", zorder=2)
|
|
129
|
+
|
|
130
|
+
# ----- Energy level sidebar -----
|
|
131
|
+
sidebar_x = n_shells + 0.6
|
|
132
|
+
e_min = atom.energy_level(1)
|
|
133
|
+
e_max = atom.energy_level(n_shells) if n_shells > 1 else e_min * 0.1
|
|
134
|
+
e_range = abs(e_max - e_min) if abs(e_max - e_min) > 0 else 1.0
|
|
135
|
+
bar_bottom = -n_shells
|
|
136
|
+
bar_height = 2 * n_shells
|
|
137
|
+
|
|
138
|
+
for n in range(1, n_shells + 1):
|
|
139
|
+
e = atom.energy_level(n)
|
|
140
|
+
if n_shells > 1:
|
|
141
|
+
y_pos = bar_bottom + bar_height * (e - e_min) / e_range
|
|
142
|
+
else:
|
|
143
|
+
y_pos = 0
|
|
144
|
+
ax.plot([sidebar_x, sidebar_x + 0.5], [y_pos, y_pos],
|
|
145
|
+
color="#ffaa33", linewidth=1.5, zorder=3)
|
|
146
|
+
ax.text(sidebar_x + 0.6, y_pos, f"{e:.2f} eV",
|
|
147
|
+
fontsize=6, color="#ffcc66", va="center", zorder=3)
|
|
148
|
+
|
|
149
|
+
# ----- Animation -----
|
|
150
|
+
def update(frame):
|
|
151
|
+
for i, (shell_idx, angle0, r, count) in enumerate(electron_positions):
|
|
152
|
+
n = shell_idx + 1
|
|
153
|
+
angular_speed = 0.06 / n # outer shells orbit slower
|
|
154
|
+
angle = angle0 + angular_speed * frame
|
|
155
|
+
x = r * math.cos(angle)
|
|
156
|
+
y = r * math.sin(angle)
|
|
157
|
+
electron_artists[i].set_data([x], [y])
|
|
158
|
+
return electron_artists
|
|
159
|
+
|
|
160
|
+
if animate:
|
|
161
|
+
anim = animation.FuncAnimation(
|
|
162
|
+
fig, update, frames=None, interval=interval_ms, blit=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
plt.tight_layout()
|
|
166
|
+
plt.show()
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Molecular Geometry Visualization
|
|
2
|
+
|
|
3
|
+
Renders 3D ball-and-stick models of VSEPR-predicted molecular
|
|
4
|
+
geometries using matplotlib. Matches the dark theme from the
|
|
5
|
+
existing quantum visualization module.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Atom spheres with CPK colours
|
|
9
|
+
- Single / double / triple bond lines
|
|
10
|
+
- Lone pair lobes
|
|
11
|
+
- Bond angle arcs with labels
|
|
12
|
+
- Informational overlay text
|
|
13
|
+
- Multi-molecule gallery view
|
|
14
|
+
|
|
15
|
+
Migrated from legacy/molecule_visualization.py.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import numpy as np
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
from mpl_toolkits.mplot3d import Axes3D # noqa: F401
|
|
22
|
+
|
|
23
|
+
from molbuilder.core.element_properties import cpk_color, covalent_radius_pm
|
|
24
|
+
from molbuilder.bonding.vsepr import VSEPRMolecule
|
|
25
|
+
from molbuilder.visualization.theme import (
|
|
26
|
+
BG_COLOR, TEXT_COLOR, GRID_COLOR,
|
|
27
|
+
LONE_PAIR_COLOR, BOND_COLOR, ANGLE_ARC_COLOR,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ===================================================================
|
|
32
|
+
# Helpers
|
|
33
|
+
# ===================================================================
|
|
34
|
+
|
|
35
|
+
def _perpendicular_offset(pos_a, pos_b, magnitude):
|
|
36
|
+
"""Compute a perpendicular offset vector for drawing multi-bonds."""
|
|
37
|
+
bond_vec = pos_b - pos_a
|
|
38
|
+
norm = np.linalg.norm(bond_vec)
|
|
39
|
+
if norm < 1e-10:
|
|
40
|
+
return np.array([magnitude, 0.0, 0.0])
|
|
41
|
+
bond_dir = bond_vec / norm
|
|
42
|
+
# Choose a reference not parallel to bond_dir
|
|
43
|
+
ref = np.array([1.0, 0.0, 0.0])
|
|
44
|
+
if abs(np.dot(bond_dir, ref)) > 0.9:
|
|
45
|
+
ref = np.array([0.0, 1.0, 0.0])
|
|
46
|
+
perp = np.cross(bond_dir, ref)
|
|
47
|
+
perp = perp / np.linalg.norm(perp) * magnitude
|
|
48
|
+
return perp
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _slerp(va, vb, t):
|
|
52
|
+
"""Spherical linear interpolation between unit vectors."""
|
|
53
|
+
dot = np.clip(np.dot(va, vb), -1.0, 1.0)
|
|
54
|
+
omega = math.acos(dot)
|
|
55
|
+
if omega < 1e-6:
|
|
56
|
+
return va * (1.0 - t) + vb * t
|
|
57
|
+
return (math.sin((1 - t) * omega) * va + math.sin(t * omega) * vb) / math.sin(omega)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _draw_angle_arc(ax, center, vec_a, vec_b, angle_deg,
|
|
61
|
+
radius=0.3, n_points=30, label=True):
|
|
62
|
+
"""Draw a circular arc between two bond vectors to show angle."""
|
|
63
|
+
na = np.linalg.norm(vec_a)
|
|
64
|
+
nb = np.linalg.norm(vec_b)
|
|
65
|
+
if na < 1e-10 or nb < 1e-10:
|
|
66
|
+
return
|
|
67
|
+
va = vec_a / na
|
|
68
|
+
vb = vec_b / nb
|
|
69
|
+
|
|
70
|
+
arc_points = []
|
|
71
|
+
for i in range(n_points + 1):
|
|
72
|
+
t = i / n_points
|
|
73
|
+
v = _slerp(va, vb, t)
|
|
74
|
+
arc_points.append(center + radius * v)
|
|
75
|
+
arc_points = np.array(arc_points)
|
|
76
|
+
|
|
77
|
+
ax.plot(arc_points[:, 0], arc_points[:, 1], arc_points[:, 2],
|
|
78
|
+
color=ANGLE_ARC_COLOR, linewidth=0.8, alpha=0.6)
|
|
79
|
+
|
|
80
|
+
if label:
|
|
81
|
+
mid = arc_points[n_points // 2]
|
|
82
|
+
ax.text(mid[0], mid[1], mid[2], f" {angle_deg:.1f}",
|
|
83
|
+
color=ANGLE_ARC_COLOR, fontsize=7, alpha=0.8)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ===================================================================
|
|
87
|
+
# Single molecule visualisation
|
|
88
|
+
# ===================================================================
|
|
89
|
+
|
|
90
|
+
def visualize_molecule(molecule: VSEPRMolecule,
|
|
91
|
+
show_lone_pairs: bool = True,
|
|
92
|
+
show_labels: bool = True,
|
|
93
|
+
show_angles: bool = True,
|
|
94
|
+
figsize: tuple = (9, 8)):
|
|
95
|
+
"""Render a 3D ball-and-stick model of a molecule.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
molecule : VSEPRMolecule
|
|
100
|
+
show_lone_pairs: draw lone pair lobes
|
|
101
|
+
show_labels : label each atom
|
|
102
|
+
show_angles : draw bond angle arcs
|
|
103
|
+
figsize : figure size
|
|
104
|
+
"""
|
|
105
|
+
coords = molecule.coordinates
|
|
106
|
+
atom_positions = coords['atom_positions']
|
|
107
|
+
bonds = coords['bonds']
|
|
108
|
+
lp_positions = coords['lone_pair_positions']
|
|
109
|
+
central_idx = coords['central_index']
|
|
110
|
+
|
|
111
|
+
fig = plt.figure(figsize=figsize, facecolor=BG_COLOR)
|
|
112
|
+
ax = fig.add_subplot(111, projection='3d', facecolor=BG_COLOR)
|
|
113
|
+
|
|
114
|
+
# ---- Bonds ----
|
|
115
|
+
for idx_a, idx_b, order in bonds:
|
|
116
|
+
sym_a, pos_a = atom_positions[idx_a]
|
|
117
|
+
sym_b, pos_b = atom_positions[idx_b]
|
|
118
|
+
if pos_a is None or pos_b is None:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if order == 1:
|
|
122
|
+
ax.plot([pos_a[0], pos_b[0]],
|
|
123
|
+
[pos_a[1], pos_b[1]],
|
|
124
|
+
[pos_a[2], pos_b[2]],
|
|
125
|
+
color=BOND_COLOR, linewidth=2.5, zorder=3)
|
|
126
|
+
elif order == 2:
|
|
127
|
+
offset = _perpendicular_offset(pos_a, pos_b, 0.06)
|
|
128
|
+
for sign in [1, -1]:
|
|
129
|
+
ax.plot([pos_a[0] + sign*offset[0], pos_b[0] + sign*offset[0]],
|
|
130
|
+
[pos_a[1] + sign*offset[1], pos_b[1] + sign*offset[1]],
|
|
131
|
+
[pos_a[2] + sign*offset[2], pos_b[2] + sign*offset[2]],
|
|
132
|
+
color=BOND_COLOR, linewidth=2.0, zorder=3)
|
|
133
|
+
elif order == 3:
|
|
134
|
+
ax.plot([pos_a[0], pos_b[0]],
|
|
135
|
+
[pos_a[1], pos_b[1]],
|
|
136
|
+
[pos_a[2], pos_b[2]],
|
|
137
|
+
color=BOND_COLOR, linewidth=2.5, zorder=3)
|
|
138
|
+
offset = _perpendicular_offset(pos_a, pos_b, 0.07)
|
|
139
|
+
for sign in [1, -1]:
|
|
140
|
+
ax.plot([pos_a[0] + sign*offset[0], pos_b[0] + sign*offset[0]],
|
|
141
|
+
[pos_a[1] + sign*offset[1], pos_b[1] + sign*offset[1]],
|
|
142
|
+
[pos_a[2] + sign*offset[2], pos_b[2] + sign*offset[2]],
|
|
143
|
+
color=BOND_COLOR, linewidth=1.5, zorder=3)
|
|
144
|
+
|
|
145
|
+
# ---- Atoms ----
|
|
146
|
+
for i, (sym, pos) in enumerate(atom_positions):
|
|
147
|
+
if sym is None or pos is None:
|
|
148
|
+
continue
|
|
149
|
+
color = cpk_color(sym)
|
|
150
|
+
radius = covalent_radius_pm(sym)
|
|
151
|
+
size = 180 + radius * 0.8
|
|
152
|
+
if i == central_idx:
|
|
153
|
+
size *= 1.15
|
|
154
|
+
ax.scatter(pos[0], pos[1], pos[2],
|
|
155
|
+
c=color, s=size, edgecolors='white', linewidths=0.5,
|
|
156
|
+
alpha=0.92, zorder=5, depthshade=True)
|
|
157
|
+
|
|
158
|
+
# ---- Atom labels ----
|
|
159
|
+
if show_labels:
|
|
160
|
+
for i, (sym, pos) in enumerate(atom_positions):
|
|
161
|
+
if sym is None or pos is None:
|
|
162
|
+
continue
|
|
163
|
+
ax.text(pos[0] + 0.08, pos[1] + 0.08, pos[2] + 0.08,
|
|
164
|
+
sym, color=TEXT_COLOR, fontsize=11, fontweight='bold',
|
|
165
|
+
zorder=10)
|
|
166
|
+
|
|
167
|
+
# ---- Lone pairs ----
|
|
168
|
+
if show_lone_pairs and lp_positions:
|
|
169
|
+
rng = np.random.default_rng(42)
|
|
170
|
+
for atom_idx, direction in lp_positions:
|
|
171
|
+
sym, atom_pos = atom_positions[atom_idx]
|
|
172
|
+
if atom_pos is None:
|
|
173
|
+
continue
|
|
174
|
+
lobe_length = 0.45
|
|
175
|
+
t = np.linspace(0.15, lobe_length, 25)
|
|
176
|
+
lobe_pts = atom_pos[np.newaxis, :] + direction[np.newaxis, :] * t[:, np.newaxis]
|
|
177
|
+
spread = 0.03
|
|
178
|
+
lobe_pts += rng.normal(0, spread, lobe_pts.shape)
|
|
179
|
+
ax.scatter(lobe_pts[:, 0], lobe_pts[:, 1], lobe_pts[:, 2],
|
|
180
|
+
c=LONE_PAIR_COLOR, s=12, alpha=0.4, zorder=4,
|
|
181
|
+
depthshade=True)
|
|
182
|
+
|
|
183
|
+
# ---- Bond angles ----
|
|
184
|
+
if show_angles:
|
|
185
|
+
central_sym, central_pos = atom_positions[central_idx]
|
|
186
|
+
if central_pos is not None:
|
|
187
|
+
terminal_data = []
|
|
188
|
+
for idx_a, idx_b, order in bonds:
|
|
189
|
+
ti = idx_b if idx_a == central_idx else idx_a
|
|
190
|
+
_, tpos = atom_positions[ti]
|
|
191
|
+
if tpos is not None:
|
|
192
|
+
terminal_data.append(tpos)
|
|
193
|
+
|
|
194
|
+
# Draw arcs for adjacent bond pairs (limit to avoid clutter)
|
|
195
|
+
drawn = set()
|
|
196
|
+
for i in range(len(terminal_data)):
|
|
197
|
+
for j in range(i + 1, len(terminal_data)):
|
|
198
|
+
va = terminal_data[i] - central_pos
|
|
199
|
+
vb = terminal_data[j] - central_pos
|
|
200
|
+
na = np.linalg.norm(va)
|
|
201
|
+
nb = np.linalg.norm(vb)
|
|
202
|
+
if na < 1e-10 or nb < 1e-10:
|
|
203
|
+
continue
|
|
204
|
+
cos_a = np.clip(np.dot(va, vb) / (na * nb), -1, 1)
|
|
205
|
+
angle = math.degrees(math.acos(cos_a))
|
|
206
|
+
# Only draw ~90 or ~120 degree angles (skip 180)
|
|
207
|
+
angle_key = round(angle)
|
|
208
|
+
if angle_key > 170:
|
|
209
|
+
continue
|
|
210
|
+
if angle_key in drawn and len(terminal_data) > 3:
|
|
211
|
+
continue
|
|
212
|
+
drawn.add(angle_key)
|
|
213
|
+
_draw_angle_arc(ax, central_pos, va, vb, angle,
|
|
214
|
+
radius=0.3, label=True)
|
|
215
|
+
|
|
216
|
+
# ---- Axis limits ----
|
|
217
|
+
all_pos = [pos for _, pos in atom_positions if pos is not None]
|
|
218
|
+
if all_pos:
|
|
219
|
+
all_pos = np.array(all_pos)
|
|
220
|
+
max_range = np.max(np.abs(all_pos)) * 1.5
|
|
221
|
+
max_range = max(max_range, 1.0)
|
|
222
|
+
else:
|
|
223
|
+
max_range = 2.0
|
|
224
|
+
ax.set_xlim(-max_range, max_range)
|
|
225
|
+
ax.set_ylim(-max_range, max_range)
|
|
226
|
+
ax.set_zlim(-max_range, max_range)
|
|
227
|
+
|
|
228
|
+
# ---- Style ----
|
|
229
|
+
axe = molecule.axe
|
|
230
|
+
ax.set_title(
|
|
231
|
+
f"{molecule.formula} -- {axe.molecular_geometry} ({axe.axe_notation})",
|
|
232
|
+
color=TEXT_COLOR, fontsize=14, pad=10,
|
|
233
|
+
)
|
|
234
|
+
for pane in [ax.xaxis.pane, ax.yaxis.pane, ax.zaxis.pane]:
|
|
235
|
+
pane.set_facecolor(BG_COLOR)
|
|
236
|
+
pane.set_edgecolor(GRID_COLOR)
|
|
237
|
+
ax.tick_params(colors=TEXT_COLOR, labelsize=7)
|
|
238
|
+
ax.set_xlabel("x (A)", color=TEXT_COLOR, fontsize=8)
|
|
239
|
+
ax.set_ylabel("y (A)", color=TEXT_COLOR, fontsize=8)
|
|
240
|
+
ax.set_zlabel("z (A)", color=TEXT_COLOR, fontsize=8)
|
|
241
|
+
|
|
242
|
+
# Info text box
|
|
243
|
+
angles_str = ", ".join(f"{a:.0f}" for a in axe.ideal_bond_angles) if axe.ideal_bond_angles else "N/A"
|
|
244
|
+
info = (
|
|
245
|
+
f"Geometry: {axe.molecular_geometry}\n"
|
|
246
|
+
f"Hybridization: {axe.hybridization}\n"
|
|
247
|
+
f"Bond angle(s): {angles_str} deg\n"
|
|
248
|
+
f"Lone pairs: {axe.lone_pairs}"
|
|
249
|
+
)
|
|
250
|
+
ax.text2D(0.02, 0.95, info, transform=ax.transAxes,
|
|
251
|
+
color=TEXT_COLOR, fontsize=9, verticalalignment='top',
|
|
252
|
+
fontfamily='monospace',
|
|
253
|
+
bbox=dict(boxstyle='round', facecolor='#111122',
|
|
254
|
+
edgecolor=GRID_COLOR, alpha=0.85))
|
|
255
|
+
|
|
256
|
+
plt.tight_layout()
|
|
257
|
+
plt.show()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ===================================================================
|
|
261
|
+
# Gallery view
|
|
262
|
+
# ===================================================================
|
|
263
|
+
|
|
264
|
+
def _render_on_axis(ax, molecule: VSEPRMolecule):
|
|
265
|
+
"""Render a molecule onto a given 3D axis (no plt.show)."""
|
|
266
|
+
coords = molecule.coordinates
|
|
267
|
+
atom_positions = coords['atom_positions']
|
|
268
|
+
bonds = coords['bonds']
|
|
269
|
+
lp_positions = coords['lone_pair_positions']
|
|
270
|
+
central_idx = coords['central_index']
|
|
271
|
+
|
|
272
|
+
# Bonds
|
|
273
|
+
for idx_a, idx_b, order in bonds:
|
|
274
|
+
_, pos_a = atom_positions[idx_a]
|
|
275
|
+
_, pos_b = atom_positions[idx_b]
|
|
276
|
+
if pos_a is None or pos_b is None:
|
|
277
|
+
continue
|
|
278
|
+
if order >= 2:
|
|
279
|
+
offset = _perpendicular_offset(pos_a, pos_b, 0.05)
|
|
280
|
+
for sign in ([0] if order == 1 else [1, -1]):
|
|
281
|
+
ax.plot([pos_a[0]+sign*offset[0], pos_b[0]+sign*offset[0]],
|
|
282
|
+
[pos_a[1]+sign*offset[1], pos_b[1]+sign*offset[1]],
|
|
283
|
+
[pos_a[2]+sign*offset[2], pos_b[2]+sign*offset[2]],
|
|
284
|
+
color=BOND_COLOR, linewidth=1.5, zorder=3)
|
|
285
|
+
if order == 3:
|
|
286
|
+
ax.plot([pos_a[0], pos_b[0]],
|
|
287
|
+
[pos_a[1], pos_b[1]],
|
|
288
|
+
[pos_a[2], pos_b[2]],
|
|
289
|
+
color=BOND_COLOR, linewidth=1.8, zorder=3)
|
|
290
|
+
else:
|
|
291
|
+
ax.plot([pos_a[0], pos_b[0]],
|
|
292
|
+
[pos_a[1], pos_b[1]],
|
|
293
|
+
[pos_a[2], pos_b[2]],
|
|
294
|
+
color=BOND_COLOR, linewidth=1.8, zorder=3)
|
|
295
|
+
|
|
296
|
+
# Atoms
|
|
297
|
+
for i, (sym, pos) in enumerate(atom_positions):
|
|
298
|
+
if sym is None or pos is None:
|
|
299
|
+
continue
|
|
300
|
+
color = cpk_color(sym)
|
|
301
|
+
size = 100 + covalent_radius_pm(sym) * 0.4
|
|
302
|
+
ax.scatter(pos[0], pos[1], pos[2],
|
|
303
|
+
c=color, s=size, edgecolors='white', linewidths=0.3,
|
|
304
|
+
alpha=0.9, zorder=5, depthshade=True)
|
|
305
|
+
ax.text(pos[0]+0.05, pos[1]+0.05, pos[2]+0.05,
|
|
306
|
+
sym, color=TEXT_COLOR, fontsize=7, fontweight='bold', zorder=10)
|
|
307
|
+
|
|
308
|
+
# Lone pairs
|
|
309
|
+
if lp_positions:
|
|
310
|
+
rng = np.random.default_rng(42)
|
|
311
|
+
for atom_idx, direction in lp_positions:
|
|
312
|
+
_, atom_pos = atom_positions[atom_idx]
|
|
313
|
+
if atom_pos is None:
|
|
314
|
+
continue
|
|
315
|
+
t = np.linspace(0.12, 0.35, 15)
|
|
316
|
+
lobe_pts = atom_pos[np.newaxis, :] + direction[np.newaxis, :] * t[:, np.newaxis]
|
|
317
|
+
lobe_pts += rng.normal(0, 0.025, lobe_pts.shape)
|
|
318
|
+
ax.scatter(lobe_pts[:, 0], lobe_pts[:, 1], lobe_pts[:, 2],
|
|
319
|
+
c=LONE_PAIR_COLOR, s=6, alpha=0.35, zorder=4,
|
|
320
|
+
depthshade=True)
|
|
321
|
+
|
|
322
|
+
# Limits
|
|
323
|
+
all_pos = [pos for _, pos in atom_positions if pos is not None]
|
|
324
|
+
if all_pos:
|
|
325
|
+
max_r = np.max(np.abs(np.array(all_pos))) * 1.5
|
|
326
|
+
max_r = max(max_r, 0.8)
|
|
327
|
+
else:
|
|
328
|
+
max_r = 1.5
|
|
329
|
+
ax.set_xlim(-max_r, max_r)
|
|
330
|
+
ax.set_ylim(-max_r, max_r)
|
|
331
|
+
ax.set_zlim(-max_r, max_r)
|
|
332
|
+
|
|
333
|
+
axe = molecule.axe
|
|
334
|
+
ax.set_title(f"{molecule.formula}\n{axe.molecular_geometry}",
|
|
335
|
+
color=TEXT_COLOR, fontsize=9, pad=2)
|
|
336
|
+
for pane in [ax.xaxis.pane, ax.yaxis.pane, ax.zaxis.pane]:
|
|
337
|
+
pane.set_facecolor(BG_COLOR)
|
|
338
|
+
pane.set_edgecolor(GRID_COLOR)
|
|
339
|
+
ax.tick_params(colors=TEXT_COLOR, labelsize=5)
|
|
340
|
+
ax.set_xlabel("")
|
|
341
|
+
ax.set_ylabel("")
|
|
342
|
+
ax.set_zlabel("")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def visualize_gallery(molecules: list[VSEPRMolecule], cols: int = 3,
|
|
346
|
+
figsize: tuple = (16, 14)):
|
|
347
|
+
"""Show multiple molecules in a grid layout for comparison.
|
|
348
|
+
|
|
349
|
+
Parameters
|
|
350
|
+
----------
|
|
351
|
+
molecules : list of VSEPRMolecule
|
|
352
|
+
cols : columns in the grid
|
|
353
|
+
figsize : figure size
|
|
354
|
+
"""
|
|
355
|
+
n = len(molecules)
|
|
356
|
+
rows = math.ceil(n / cols)
|
|
357
|
+
|
|
358
|
+
fig = plt.figure(figsize=figsize, facecolor=BG_COLOR)
|
|
359
|
+
fig.suptitle("VSEPR Molecular Geometry Gallery",
|
|
360
|
+
color=TEXT_COLOR, fontsize=16, y=0.98)
|
|
361
|
+
|
|
362
|
+
for i, mol in enumerate(molecules):
|
|
363
|
+
ax = fig.add_subplot(rows, cols, i + 1, projection='3d',
|
|
364
|
+
facecolor=BG_COLOR)
|
|
365
|
+
_render_on_axis(ax, mol)
|
|
366
|
+
|
|
367
|
+
plt.tight_layout(rect=[0, 0, 1, 0.95])
|
|
368
|
+
plt.show()
|