quantui 0.5.1__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.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Molecular visualization using py3Dmol (and optional PlotlyMol).
|
|
3
|
+
|
|
4
|
+
This module provides 3D molecular visualization using py3Dmol as the primary
|
|
5
|
+
backend (stable, widely used, already installed). PlotlyMol is supported as
|
|
6
|
+
an optional alternative for users who prefer Plotly-based figures.
|
|
7
|
+
|
|
8
|
+
Author: Jonathan Schultz, NCCU
|
|
9
|
+
Created: 2026-02-17
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from typing import Literal, cast
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
Py3DmolStyle = Literal["ball+stick", "stick", "sphere", "line", "cartoon"]
|
|
20
|
+
BackendName = Literal["auto", "py3dmol", "plotlymol"]
|
|
21
|
+
|
|
22
|
+
# Check available visualization backends
|
|
23
|
+
try:
|
|
24
|
+
import py3Dmol # noqa: F401 — availability probe; views build via viz_assets.make_view
|
|
25
|
+
|
|
26
|
+
PY3DMOL_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
PY3DMOL_AVAILABLE = False
|
|
29
|
+
logger.warning("py3Dmol not available - primary visualization disabled")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from plotlymol3d import draw_3D_rep
|
|
33
|
+
from plotlymol3d import format_lighting as _plotlymol_format_lighting
|
|
34
|
+
|
|
35
|
+
PLOTLYMOL_AVAILABLE = True
|
|
36
|
+
except ImportError:
|
|
37
|
+
PLOTLYMOL_AVAILABLE = False
|
|
38
|
+
_plotlymol_format_lighting = None # type: ignore[assignment]
|
|
39
|
+
logger.info("PlotlyMol not available (optional)")
|
|
40
|
+
|
|
41
|
+
# ── Visualization style and lighting constants ────────────────────────────────
|
|
42
|
+
|
|
43
|
+
# Display-style options presented in the UI. The value is the canonical key
|
|
44
|
+
# used internally; each backend maps it to its own representation.
|
|
45
|
+
VIZ_STYLE_OPTIONS: list[tuple[str, str]] = [
|
|
46
|
+
("Ball & Stick", "ball+stick"),
|
|
47
|
+
("Stick", "stick"),
|
|
48
|
+
("Sphere (VDW)", "sphere"),
|
|
49
|
+
("Line", "line"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Named lighting presets — identical to those in the plotlyMol dash app.
|
|
53
|
+
# Only applied when the PlotlyMol backend is active.
|
|
54
|
+
LIGHTING_PRESETS: dict[str, dict] = {
|
|
55
|
+
"soft": {"ambient": 0.4, "diffuse": 0.8, "specular": 0.1, "roughness": 0.8},
|
|
56
|
+
"default": {"ambient": 0.0, "diffuse": 1.0, "specular": 0.0, "roughness": 1.0},
|
|
57
|
+
"bright": {"ambient": 0.5, "diffuse": 0.8, "specular": 0.3, "roughness": 0.5},
|
|
58
|
+
"metallic": {"ambient": 0.2, "diffuse": 0.7, "specular": 1.0, "roughness": 0.1},
|
|
59
|
+
"dramatic": {"ambient": 0.0, "diffuse": 1.0, "specular": 0.6, "roughness": 0.2},
|
|
60
|
+
}
|
|
61
|
+
LIGHTING_OPTIONS: list[tuple[str, str]] = [
|
|
62
|
+
("Soft", "soft"),
|
|
63
|
+
("Default", "default"),
|
|
64
|
+
("Bright", "bright"),
|
|
65
|
+
("Metallic", "metallic"),
|
|
66
|
+
("Dramatic", "dramatic"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
DEFAULT_STYLE: str = "ball+stick"
|
|
70
|
+
DEFAULT_LIGHTING: str = "soft"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_visualization_available() -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Check if molecular visualization is available.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if py3Dmol OR PlotlyMol is available, False otherwise.
|
|
79
|
+
"""
|
|
80
|
+
return PY3DMOL_AVAILABLE or PLOTLYMOL_AVAILABLE
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_available_backends() -> list[str]:
|
|
84
|
+
"""
|
|
85
|
+
Get list of available visualization backends.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of available backend names (e.g., ['py3dmol', 'plotlymol'])
|
|
89
|
+
"""
|
|
90
|
+
backends = []
|
|
91
|
+
if PY3DMOL_AVAILABLE:
|
|
92
|
+
backends.append("py3dmol")
|
|
93
|
+
if PLOTLYMOL_AVAILABLE:
|
|
94
|
+
backends.append("plotlymol")
|
|
95
|
+
return backends
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def molecule_to_xyz_string(molecule) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Convert QuantUI Molecule to XYZ string format.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
molecule: QuantUI Molecule object
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
XYZ format string suitable for py3Dmol or PlotlyMol
|
|
107
|
+
"""
|
|
108
|
+
from quantui.molecule import Molecule
|
|
109
|
+
|
|
110
|
+
if not isinstance(molecule, Molecule):
|
|
111
|
+
raise TypeError("Expected QuantUI Molecule object")
|
|
112
|
+
|
|
113
|
+
return molecule.to_xyz_string()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def visualize_molecule_py3dmol(
|
|
117
|
+
molecule,
|
|
118
|
+
style: Py3DmolStyle = "ball+stick",
|
|
119
|
+
width: int = 600,
|
|
120
|
+
height: int = 500,
|
|
121
|
+
bgcolor: str = "white",
|
|
122
|
+
lighting: str = "soft", # accepted for API symmetry; py3Dmol has no preset lighting
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Create interactive 3D visualization using py3Dmol.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
molecule: QuantUI Molecule object
|
|
129
|
+
style: Visualization style:
|
|
130
|
+
- "stick": Stick representation (default, good for small molecules)
|
|
131
|
+
- "sphere": Van der Waals spheres
|
|
132
|
+
- "line": Line representation
|
|
133
|
+
- "cartoon": Cartoon (for proteins)
|
|
134
|
+
width: Viewer width in pixels (default: 600)
|
|
135
|
+
height: Viewer height in pixels (default: 500)
|
|
136
|
+
bgcolor: Background color (default: "white")
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
py3Dmol.view object (call .show() in Jupyter to display)
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ImportError: If py3Dmol is not installed
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
>>> mol = Molecule(['O', 'H', 'H'], [[0,0,0], [0.757,0.587,0], [-0.757,0.587,0]])
|
|
146
|
+
>>> view = visualize_molecule_py3dmol(mol, style="stick")
|
|
147
|
+
>>> view.show() # In Jupyter
|
|
148
|
+
"""
|
|
149
|
+
if not PY3DMOL_AVAILABLE:
|
|
150
|
+
raise ImportError(
|
|
151
|
+
"py3Dmol is not installed. To enable 3D visualization:\n"
|
|
152
|
+
" pip install py3dmol"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Build a well-formed XYZ block: count line + title line + coordinates.
|
|
156
|
+
# py3Dmol is lenient about the header in most environments, but browsers
|
|
157
|
+
# running the exported HTML require the standard two-line header to parse
|
|
158
|
+
# the format correctly.
|
|
159
|
+
bare_xyz = molecule.to_xyz_string()
|
|
160
|
+
xyz_string = f"{len(molecule.atoms)}\n{molecule.get_formula()}\n{bare_xyz}"
|
|
161
|
+
|
|
162
|
+
logger.info(
|
|
163
|
+
f"Creating py3Dmol visualization for {molecule.get_formula()} "
|
|
164
|
+
f"(style={style})"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Create viewer — via the offline-safe factory so 3Dmol.js loads from the
|
|
168
|
+
# vendored bundle (the page bootstrap), never the CDN (offline classroom).
|
|
169
|
+
from quantui.viz_assets import make_view
|
|
170
|
+
|
|
171
|
+
view = make_view(width=width, height=height)
|
|
172
|
+
|
|
173
|
+
# Add molecule
|
|
174
|
+
view.addModel(xyz_string, "xyz")
|
|
175
|
+
|
|
176
|
+
# Set style — "ball+stick" requires a compound spec in py3Dmol
|
|
177
|
+
if style == "ball+stick":
|
|
178
|
+
view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
|
|
179
|
+
else:
|
|
180
|
+
view.setStyle({style: {}})
|
|
181
|
+
|
|
182
|
+
# Set background
|
|
183
|
+
view.setBackgroundColor(bgcolor)
|
|
184
|
+
|
|
185
|
+
# Zoom to fit
|
|
186
|
+
view.zoomTo()
|
|
187
|
+
|
|
188
|
+
return view
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _validate_py3dmol_style(style: str) -> Py3DmolStyle:
|
|
192
|
+
valid_styles: tuple[Py3DmolStyle, ...] = (
|
|
193
|
+
"ball+stick",
|
|
194
|
+
"stick",
|
|
195
|
+
"sphere",
|
|
196
|
+
"line",
|
|
197
|
+
"cartoon",
|
|
198
|
+
)
|
|
199
|
+
if style not in valid_styles:
|
|
200
|
+
raise ValueError(f"style must be one of {list(valid_styles)}, got '{style}'")
|
|
201
|
+
return cast(Py3DmolStyle, style)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def visualize_molecule_plotlymol(
|
|
205
|
+
molecule,
|
|
206
|
+
mode: str = "ball+stick",
|
|
207
|
+
resolution: int = 32,
|
|
208
|
+
width: int = 600,
|
|
209
|
+
height: int = 500,
|
|
210
|
+
bgcolor: str = "#ffffff",
|
|
211
|
+
lighting: str = "soft",
|
|
212
|
+
):
|
|
213
|
+
"""
|
|
214
|
+
Create interactive 3D visualization using PlotlyMol (optional backend).
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
molecule: QuantUI Molecule object
|
|
218
|
+
mode: Visualization mode - one of:
|
|
219
|
+
- "ball+stick": Full-size atoms with bonds (default)
|
|
220
|
+
- "stick": Small atoms with bonds
|
|
221
|
+
- "vdw": Van der Waals spheres only (no bonds)
|
|
222
|
+
resolution: Sphere tessellation resolution (16-64, default: 32)
|
|
223
|
+
width: Figure width in pixels (default: 600)
|
|
224
|
+
height: Figure height in pixels (default: 500)
|
|
225
|
+
bgcolor: Background color as hex string or name (default: "#ffffff")
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
plotly.graph_objects.Figure object
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ImportError: If PlotlyMol is not installed
|
|
232
|
+
"""
|
|
233
|
+
if not PLOTLYMOL_AVAILABLE:
|
|
234
|
+
raise ImportError(
|
|
235
|
+
"PlotlyMol is not installed. To enable PlotlyMol visualization:\n"
|
|
236
|
+
" pip install plotlymol"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Validate mode
|
|
240
|
+
valid_modes = ["ball+stick", "stick", "vdw"]
|
|
241
|
+
if mode not in valid_modes:
|
|
242
|
+
raise ValueError(f"mode must be one of {valid_modes}, got '{mode}'")
|
|
243
|
+
|
|
244
|
+
# Convert to XYZ string
|
|
245
|
+
xyz_string = molecule_to_xyz_string(molecule)
|
|
246
|
+
|
|
247
|
+
# Get charge for RDKit processing
|
|
248
|
+
charge = molecule.charge
|
|
249
|
+
|
|
250
|
+
logger.info(
|
|
251
|
+
f"Creating PlotlyMol visualization for {molecule.get_formula()} "
|
|
252
|
+
f"(mode={mode}, resolution={resolution})"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# draw_3D_rep takes a file path, not an in-memory string
|
|
256
|
+
full_xyz = f"{len(molecule.atoms)}\n\n{xyz_string}\n"
|
|
257
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
258
|
+
mode="w", suffix=".xyz", delete=False, encoding="utf-8"
|
|
259
|
+
)
|
|
260
|
+
try:
|
|
261
|
+
tmp.write(full_xyz)
|
|
262
|
+
tmp.close()
|
|
263
|
+
fig = draw_3D_rep(
|
|
264
|
+
xyzfile=tmp.name,
|
|
265
|
+
charge=charge,
|
|
266
|
+
mode=mode,
|
|
267
|
+
resolution=resolution,
|
|
268
|
+
)
|
|
269
|
+
if _plotlymol_format_lighting is not None:
|
|
270
|
+
preset = LIGHTING_PRESETS.get(lighting, LIGHTING_PRESETS["soft"])
|
|
271
|
+
fig = _plotlymol_format_lighting(fig, **preset)
|
|
272
|
+
finally:
|
|
273
|
+
os.unlink(tmp.name)
|
|
274
|
+
|
|
275
|
+
fig.update_layout(
|
|
276
|
+
width=width,
|
|
277
|
+
height=height,
|
|
278
|
+
title=f"{molecule.get_formula()} - {mode.replace('+', ' & ').title()}",
|
|
279
|
+
paper_bgcolor=bgcolor,
|
|
280
|
+
scene=dict(bgcolor=bgcolor),
|
|
281
|
+
)
|
|
282
|
+
return fig
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def visualize_molecule(
|
|
286
|
+
molecule,
|
|
287
|
+
backend: BackendName = "auto",
|
|
288
|
+
style: str = "ball+stick",
|
|
289
|
+
width: int = 600,
|
|
290
|
+
height: int = 500,
|
|
291
|
+
bgcolor: str = "white",
|
|
292
|
+
lighting: str = "soft",
|
|
293
|
+
**kwargs,
|
|
294
|
+
):
|
|
295
|
+
"""
|
|
296
|
+
Create interactive 3D visualization (backend-agnostic).
|
|
297
|
+
|
|
298
|
+
This is the main visualization function. It automatically selects the
|
|
299
|
+
best available backend or uses the one specified.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
molecule: QuantUI Molecule object
|
|
303
|
+
backend: Visualization backend:
|
|
304
|
+
- "auto": Use py3Dmol if available, else PlotlyMol (default)
|
|
305
|
+
- "py3dmol": Use py3Dmol (recommended, stable)
|
|
306
|
+
- "plotlymol": Use PlotlyMol (optional, Plotly-based)
|
|
307
|
+
style: Visualization style (backend-dependent):
|
|
308
|
+
- py3Dmol: "stick", "sphere", "line", "cartoon"
|
|
309
|
+
- PlotlyMol: "ball+stick", "stick", "vdw"
|
|
310
|
+
width: Viewer/figure width in pixels (default: 600)
|
|
311
|
+
height: Viewer/figure height in pixels (default: 500)
|
|
312
|
+
bgcolor: Background color (default: "white")
|
|
313
|
+
**kwargs: Additional backend-specific arguments
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
py3Dmol.view or plotly Figure depending on backend
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
ImportError: If no visualization backend is available
|
|
320
|
+
ValueError: If specified backend is not available
|
|
321
|
+
|
|
322
|
+
Example:
|
|
323
|
+
>>> mol = Molecule(['H', 'H'], [[0, 0, 0], [0, 0, 0.74]])
|
|
324
|
+
>>> # Use default backend (py3Dmol)
|
|
325
|
+
>>> view = visualize_molecule(mol)
|
|
326
|
+
>>> view.show() # In Jupyter
|
|
327
|
+
"""
|
|
328
|
+
# Determine backend
|
|
329
|
+
if backend == "auto":
|
|
330
|
+
if PLOTLYMOL_AVAILABLE:
|
|
331
|
+
backend = "plotlymol"
|
|
332
|
+
elif PY3DMOL_AVAILABLE:
|
|
333
|
+
backend = "py3dmol"
|
|
334
|
+
else:
|
|
335
|
+
raise ImportError(
|
|
336
|
+
"No visualization backend available. Install one of:\n"
|
|
337
|
+
" pip install py3dmol (recommended)\n"
|
|
338
|
+
" pip install plotlymol"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Use selected backend
|
|
342
|
+
if backend == "py3dmol":
|
|
343
|
+
py3dmol_style = _validate_py3dmol_style(style)
|
|
344
|
+
return visualize_molecule_py3dmol(
|
|
345
|
+
molecule,
|
|
346
|
+
style=py3dmol_style,
|
|
347
|
+
width=width,
|
|
348
|
+
height=height,
|
|
349
|
+
bgcolor=bgcolor,
|
|
350
|
+
lighting=lighting,
|
|
351
|
+
)
|
|
352
|
+
elif backend == "plotlymol":
|
|
353
|
+
# Map UI style keys to PlotlyMol mode names
|
|
354
|
+
mode_map = {
|
|
355
|
+
"ball+stick": "ball+stick",
|
|
356
|
+
"stick": "stick",
|
|
357
|
+
"sphere": "vdw",
|
|
358
|
+
"line": "stick", # plotlyMol has no line mode; use stick
|
|
359
|
+
}
|
|
360
|
+
mode = mode_map.get(style, "ball+stick")
|
|
361
|
+
return visualize_molecule_plotlymol(
|
|
362
|
+
molecule,
|
|
363
|
+
mode=mode,
|
|
364
|
+
width=width,
|
|
365
|
+
height=height,
|
|
366
|
+
bgcolor=bgcolor,
|
|
367
|
+
lighting=lighting,
|
|
368
|
+
**kwargs,
|
|
369
|
+
)
|
|
370
|
+
else:
|
|
371
|
+
raise ValueError(f"Unknown backend: {backend}")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _info_box_html(molecule, backend: str) -> str:
|
|
375
|
+
"""Build the info-box HTML fragment shown above the 3D viewer."""
|
|
376
|
+
backends = get_available_backends()
|
|
377
|
+
backend_str = ", ".join(backends)
|
|
378
|
+
selected = backend if backend != "auto" else (backends[0] if backends else "")
|
|
379
|
+
return (
|
|
380
|
+
'<div style="background-color: #f0f8ff; padding: 10px;'
|
|
381
|
+
" border-radius: 5px; margin-bottom: 10px;"
|
|
382
|
+
' border-left: 4px solid #4a90e2;">'
|
|
383
|
+
"<strong>📊 Molecule Information</strong><br>"
|
|
384
|
+
f"<strong>Formula:</strong> {molecule.get_formula()} | "
|
|
385
|
+
f"<strong>Atoms:</strong> {len(molecule.atoms)} | "
|
|
386
|
+
f"<strong>Electrons:</strong> {molecule.get_electron_count()} | "
|
|
387
|
+
f"<strong>Charge:</strong> {molecule.charge} | "
|
|
388
|
+
f"<strong>Multiplicity:</strong> {molecule.multiplicity}<br>"
|
|
389
|
+
f'<small style="color: #666;">Using: {selected} '
|
|
390
|
+
f"(available: {backend_str})</small>"
|
|
391
|
+
"</div>"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _unavailable_html(molecule) -> str:
|
|
396
|
+
"""HTML fallback when no 3D visualization backend is installed."""
|
|
397
|
+
return (
|
|
398
|
+
'<div style="padding:10px;font-family:sans-serif;color:#444;">'
|
|
399
|
+
"<p>⚠️ 3D visualization not available.</p>"
|
|
400
|
+
"<p>To enable visualization, install one of:</p>"
|
|
401
|
+
"<ul><li><code>pip install py3dmol</code> (recommended)</li>"
|
|
402
|
+
"<li><code>pip install plotlymol</code></li></ul>"
|
|
403
|
+
"<p><strong>Molecule Information</strong><br>"
|
|
404
|
+
f"Formula: {molecule.get_formula()}<br>"
|
|
405
|
+
f"Atoms: {len(molecule.atoms)}<br>"
|
|
406
|
+
f"Electrons: {molecule.get_electron_count()}<br>"
|
|
407
|
+
f"Charge: {molecule.charge}<br>"
|
|
408
|
+
f"Multiplicity: {molecule.multiplicity}</p>"
|
|
409
|
+
f"<pre>{molecule.to_xyz_string()}</pre>"
|
|
410
|
+
"</div>"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def render_molecule_html(
|
|
415
|
+
molecule,
|
|
416
|
+
backend: Literal["auto", "py3dmol", "plotlymol"] = "auto",
|
|
417
|
+
style: str = "ball+stick",
|
|
418
|
+
show_info: bool = True,
|
|
419
|
+
width: int = 600,
|
|
420
|
+
height: int = 500,
|
|
421
|
+
bgcolor: str = "#ffffff",
|
|
422
|
+
lighting: str = "soft",
|
|
423
|
+
) -> str:
|
|
424
|
+
"""Return self-contained HTML for the molecule viewer (no display side-effects).
|
|
425
|
+
|
|
426
|
+
Mirrors :func:`display_molecule` but emits a single HTML string so callers
|
|
427
|
+
can route through an atomic ``Output.outputs`` swap (Rule 6 in
|
|
428
|
+
``reflections/01-voila-rendering-and-display.md``) rather than
|
|
429
|
+
``with output: display(viz)`` — the latter is a known root-cause
|
|
430
|
+
family for trajectory and Analysis-tab rendering regressions. Errors are
|
|
431
|
+
caught and returned as inline HTML so the caller sees a
|
|
432
|
+
visible failure message in the viewer slot instead of a blank 🙁 panel.
|
|
433
|
+
"""
|
|
434
|
+
if not is_visualization_available():
|
|
435
|
+
return _unavailable_html(molecule)
|
|
436
|
+
|
|
437
|
+
parts: list[str] = []
|
|
438
|
+
if show_info:
|
|
439
|
+
parts.append(_info_box_html(molecule, backend))
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
viz = visualize_molecule(
|
|
443
|
+
molecule,
|
|
444
|
+
backend=backend,
|
|
445
|
+
style=style,
|
|
446
|
+
width=width,
|
|
447
|
+
height=height,
|
|
448
|
+
bgcolor=bgcolor,
|
|
449
|
+
lighting=lighting,
|
|
450
|
+
)
|
|
451
|
+
make_html = getattr(viz, "_make_html", None)
|
|
452
|
+
if callable(make_html):
|
|
453
|
+
parts.append(viz._make_html())
|
|
454
|
+
else:
|
|
455
|
+
import plotly.io as _pio
|
|
456
|
+
|
|
457
|
+
parts.append(
|
|
458
|
+
_pio.to_html(
|
|
459
|
+
viz,
|
|
460
|
+
full_html=False,
|
|
461
|
+
include_plotlyjs="require",
|
|
462
|
+
config={"responsive": True},
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
logger.info(f"Rendered HTML for {molecule.get_formula()}")
|
|
466
|
+
except Exception as e:
|
|
467
|
+
logger.error(f"Render failed for {molecule.get_formula()}: {e}")
|
|
468
|
+
parts.append(
|
|
469
|
+
'<div style="color:#b91c1c;padding:8px;">'
|
|
470
|
+
f"❌ Visualization failed: {e}</div>"
|
|
471
|
+
)
|
|
472
|
+
return "\n".join(parts)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def display_molecule(
|
|
476
|
+
molecule,
|
|
477
|
+
backend: Literal["auto", "py3dmol", "plotlymol"] = "auto",
|
|
478
|
+
style: str = "ball+stick",
|
|
479
|
+
show_info: bool = True,
|
|
480
|
+
width: int = 600,
|
|
481
|
+
height: int = 500,
|
|
482
|
+
bgcolor: str = "#ffffff",
|
|
483
|
+
lighting: str = "soft",
|
|
484
|
+
):
|
|
485
|
+
"""
|
|
486
|
+
Display molecule in Jupyter notebook with optional info box.
|
|
487
|
+
|
|
488
|
+
This is the main function for notebook integration. It handles all
|
|
489
|
+
available backends and provides a consistent interface.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
molecule: QuantUI Molecule object
|
|
493
|
+
backend: Visualization backend ("auto", "py3dmol", "plotlymol")
|
|
494
|
+
style: Visualization style (backend-dependent)
|
|
495
|
+
show_info: Whether to show molecular info box
|
|
496
|
+
width: Viewer/figure width in pixels
|
|
497
|
+
height: Viewer/figure height in pixels
|
|
498
|
+
|
|
499
|
+
Example:
|
|
500
|
+
>>> # In Jupyter notebook
|
|
501
|
+
>>> mol = Molecule(['H', 'H'], [[0, 0, 0], [0, 0, 0.74]])
|
|
502
|
+
>>> display_molecule(mol) # Uses py3Dmol by default
|
|
503
|
+
"""
|
|
504
|
+
from IPython.display import HTML, display
|
|
505
|
+
|
|
506
|
+
if not is_visualization_available():
|
|
507
|
+
# Fallback: show text representation
|
|
508
|
+
print("⚠️ 3D visualization not available")
|
|
509
|
+
print("\nTo enable visualization, install one of:")
|
|
510
|
+
print(" pip install py3dmol (recommended)")
|
|
511
|
+
print(" pip install plotlymol")
|
|
512
|
+
print("\nMolecule Information:")
|
|
513
|
+
print(f" Formula: {molecule.get_formula()}")
|
|
514
|
+
print(f" Atoms: {len(molecule.atoms)}")
|
|
515
|
+
print(f" Electrons: {molecule.get_electron_count()}")
|
|
516
|
+
print(f" Charge: {molecule.charge}")
|
|
517
|
+
print(f" Multiplicity: {molecule.multiplicity}")
|
|
518
|
+
print("\nXYZ Coordinates:")
|
|
519
|
+
print(molecule.to_xyz_string())
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
# Show info box if requested
|
|
523
|
+
if show_info:
|
|
524
|
+
backends = get_available_backends()
|
|
525
|
+
backend_str = ", ".join(backends)
|
|
526
|
+
selected = backend if backend != "auto" else backends[0]
|
|
527
|
+
|
|
528
|
+
info_html = f"""
|
|
529
|
+
<div style="background-color: #f0f8ff; padding: 10px; border-radius: 5px;
|
|
530
|
+
margin-bottom: 10px; border-left: 4px solid #4a90e2;">
|
|
531
|
+
<strong>📊 Molecule Information</strong><br>
|
|
532
|
+
<strong>Formula:</strong> {molecule.get_formula()} |
|
|
533
|
+
<strong>Atoms:</strong> {len(molecule.atoms)} |
|
|
534
|
+
<strong>Electrons:</strong> {molecule.get_electron_count()} |
|
|
535
|
+
<strong>Charge:</strong> {molecule.charge} |
|
|
536
|
+
<strong>Multiplicity:</strong> {molecule.multiplicity}<br>
|
|
537
|
+
<small style="color: #666;">Using: {selected} (available: {backend_str})</small>
|
|
538
|
+
</div>
|
|
539
|
+
"""
|
|
540
|
+
display(HTML(info_html))
|
|
541
|
+
|
|
542
|
+
# Create and display visualization
|
|
543
|
+
try:
|
|
544
|
+
viz = visualize_molecule(
|
|
545
|
+
molecule,
|
|
546
|
+
backend=backend,
|
|
547
|
+
style=style,
|
|
548
|
+
width=width,
|
|
549
|
+
height=height,
|
|
550
|
+
bgcolor=bgcolor,
|
|
551
|
+
lighting=lighting,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# display(viz) triggers py3Dmol's _repr_html_() method, which embeds
|
|
555
|
+
# the viewer as self-contained HTML. This works in both JupyterLab
|
|
556
|
+
# and classic Notebook. viz.show() uses IPython.display.Javascript
|
|
557
|
+
# which is blocked by JupyterLab's content-security-policy and
|
|
558
|
+
# returns None (causing "None" to appear in cell output).
|
|
559
|
+
display(viz)
|
|
560
|
+
|
|
561
|
+
logger.info(f"Successfully displayed {molecule.get_formula()}")
|
|
562
|
+
except Exception as e:
|
|
563
|
+
print(f"❌ Visualization failed: {e}")
|
|
564
|
+
logger.error(f"Display failed for {molecule.get_formula()}: {e}")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def get_installation_message() -> str:
|
|
568
|
+
"""
|
|
569
|
+
Get installation instructions for visualization backends.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Formatted string with installation instructions
|
|
573
|
+
"""
|
|
574
|
+
return """
|
|
575
|
+
To enable 3D molecular visualization:
|
|
576
|
+
|
|
577
|
+
Option 1 (Recommended): py3Dmol
|
|
578
|
+
pip install py3dmol
|
|
579
|
+
|
|
580
|
+
Option 2 (Optional): PlotlyMol
|
|
581
|
+
conda install -c conda-forge rdkit plotly kaleido
|
|
582
|
+
pip install plotlymol
|
|
583
|
+
|
|
584
|
+
For most users, py3Dmol is sufficient and more stable.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# Module-level check and logging
|
|
589
|
+
available = get_available_backends()
|
|
590
|
+
if available:
|
|
591
|
+
logger.info(f"Visualization backends available: {', '.join(available)}")
|
|
592
|
+
else:
|
|
593
|
+
logger.warning("No visualization backends available")
|
quantui/viz_assets.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Offline-safe py3Dmol asset loading.
|
|
2
|
+
|
|
3
|
+
py3Dmol's ``view()`` constructor defaults to
|
|
4
|
+
``js='https://cdn.jsdelivr.net/npm/3dmol@2.5.4/build/3Dmol-min.js'`` and its
|
|
5
|
+
``_make_html()`` emits a ``loadScriptAsync('<that URL>')`` call. On any host
|
|
6
|
+
with no network (offline classroom — QuantUI's primary target) or a restrictive
|
|
7
|
+
CSP, that fetch fails silently and the viewer renders blank. This is the
|
|
8
|
+
py3Dmol analogue of the Plotly CDN trap in
|
|
9
|
+
``reflections/01-voila-rendering-and-display.md`` Rule 1.
|
|
10
|
+
|
|
11
|
+
Approach (no new dependency)
|
|
12
|
+
----------------------------
|
|
13
|
+
The 3Dmol.js bundle is vendored as package data (``data/js/3Dmol-min.js``, the
|
|
14
|
+
exact 2.5.4 build py3Dmol targets). :func:`make_view` builds every viewer with
|
|
15
|
+
``js=<data: URI of the vendored bytes>`` instead of the CDN URL. This reuses
|
|
16
|
+
**py3Dmol's own per-view loader verbatim** — only the source URL changes from a
|
|
17
|
+
remote CDN to a local ``data:`` URI — so the viewer loads 3Dmol.js offline with
|
|
18
|
+
no network.
|
|
19
|
+
|
|
20
|
+
Why per-view and NOT a one-time page bootstrap: an earlier version injected the
|
|
21
|
+
loader once at app startup (the first display output). Running py3Dmol's
|
|
22
|
+
``exports``/``module``-juggling loader during Voilà's own RequireJS/AMD
|
|
23
|
+
bootstrap polluted the global module system at the worst moment and broke widget
|
|
24
|
+
startup offline. The per-view approach runs the identical loader **after** the
|
|
25
|
+
page is up (when a viewer renders) — exactly when py3Dmol normally runs it — so
|
|
26
|
+
it never interferes with startup. py3Dmol's ``$3Dmolpromise`` global guard means
|
|
27
|
+
only the first viewer on a page actually loads 3Dmol; later views reuse it.
|
|
28
|
+
|
|
29
|
+
Trade-off: the (cached, ~0.7 MB base64) data: URI rides in each viewer's HTML
|
|
30
|
+
payload. Fine for the molecule preview / isosurface / result viewer; heavier for
|
|
31
|
+
rapid trajectory/vib frame swaps (the bytes ship per payload even though only
|
|
32
|
+
the first triggers a load). Correctness and offline support take priority over
|
|
33
|
+
that payload size; optimizing the rapid-swap path is a possible follow-up.
|
|
34
|
+
|
|
35
|
+
Author: Jonathan Schultz, NCCU
|
|
36
|
+
Created: 2026-06-15
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import base64
|
|
42
|
+
import logging
|
|
43
|
+
from functools import lru_cache
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Vendored 3Dmol.js — the exact build py3Dmol 2.x's constructor default points
|
|
49
|
+
# at, so the API the py3Dmol-generated JS calls (createViewer, addModel,
|
|
50
|
+
# addVolumetricData, addModelsAsFrames, animate, ...) is guaranteed present.
|
|
51
|
+
# Provenance + license: data/js/PROVENANCE.md, data/js/3Dmol-min.js.LICENSE.txt.
|
|
52
|
+
THREEDMOL_VERSION = "2.5.4"
|
|
53
|
+
_JS_PATH = Path(__file__).parent / "data" / "js" / "3Dmol-min.js"
|
|
54
|
+
|
|
55
|
+
# The CDN URL we replace — kept so a test can assert it never appears in any
|
|
56
|
+
# emitted HTML.
|
|
57
|
+
CDN_URL = "https://cdn.jsdelivr.net/npm/3dmol@2.5.4/build/3Dmol-min.js"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@lru_cache(maxsize=1)
|
|
61
|
+
def _js_data_uri() -> str:
|
|
62
|
+
"""Return the vendored 3Dmol.js as a base64 ``data:`` URI (cached).
|
|
63
|
+
|
|
64
|
+
Empty string if the bundle is missing, in which case :func:`make_view`
|
|
65
|
+
falls back to py3Dmol's default (CDN) ``js`` rather than handing the viewer
|
|
66
|
+
an unusable source.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
raw = _JS_PATH.read_bytes()
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
logger.warning("Vendored 3Dmol.js unreadable (%s); falling back to CDN", exc)
|
|
72
|
+
return ""
|
|
73
|
+
b64 = base64.b64encode(raw).decode("ascii")
|
|
74
|
+
return f"data:text/javascript;base64,{b64}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_view(**kwargs):
|
|
78
|
+
"""Build a ``py3Dmol.view`` that loads 3Dmol.js offline (no CDN).
|
|
79
|
+
|
|
80
|
+
Identical to ``py3Dmol.view(**kwargs)`` except ``js`` defaults to a ``data:``
|
|
81
|
+
URI of the vendored 3Dmol.js, so the viewer never reaches the network. Use
|
|
82
|
+
this in place of ``py3Dmol.view(...)`` everywhere in the app. If the bundle
|
|
83
|
+
is missing we leave py3Dmol's default ``js`` (CDN) in place.
|
|
84
|
+
"""
|
|
85
|
+
import py3Dmol
|
|
86
|
+
|
|
87
|
+
data_uri = _js_data_uri()
|
|
88
|
+
if data_uri and "js" not in kwargs:
|
|
89
|
+
kwargs["js"] = data_uri
|
|
90
|
+
return py3Dmol.view(**kwargs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def standalone_html(view_html: str) -> str:
|
|
94
|
+
"""Return viewer HTML suitable for a standalone (exported) file.
|
|
95
|
+
|
|
96
|
+
Views built via :func:`make_view` already embed the vendored 3Dmol.js
|
|
97
|
+
loader (``js=<data: URI>``), so an exported file is self-contained and plays
|
|
98
|
+
offline as-is. Kept as a no-op pass-through for call-site clarity / API
|
|
99
|
+
stability.
|
|
100
|
+
"""
|
|
101
|
+
return view_html
|