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