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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. 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