diff --git a/.gitignore b/.gitignore index 353d06f..20f1cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ nul /.claude/ /temp - untracked/ /logs +.claudeignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f34ec..fba6c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.4.0] - 2026-06-18 + +Interactive-visualization and offline-readiness release. Adds molecular-orbital +isosurfaces and an interactive pre-optimization preview, reworks the 3D viewers +to preserve camera orientation across frames, makes all 3D rendering work +offline, and substantially speeds up startup. + +### Added + +- **Molecular-orbital isosurfaces (py3Dmol)** — interactive HOMO / LUMO / MO + isosurface viewer rendered with py3Dmol; the downsampled Plotly path remains a + fallback. +- **Interactive classical pre-optimization** — a **Preview** button relaxes the + geometry with a fast bonded force field (RDKit MMFF94 → UFF) and animates the + relaxation in place; **Keep this geometry** adopts it as the active structure + or **Revert** discards it. Stepper controls (play/pause, prev/next, scrub + slider, and an input ⇄ relaxed flip) let you compare geometries, and the + captured trajectory is sampled at even RMSD spacing for smooth playback. +- **Cancel button** — stop a running calculation cooperatively at the next SCF + cycle / optimization step. +- **Live vibrational-animation framerate** — the Vib fps setting updates the + running animation immediately. + +### Changed + +- **Single persistent 3D viewers** — the trajectory and vibrational-mode viewers + now load all frames into one py3Dmol viewer and switch frames/modes + client-side, so the camera (rotation/zoom) is preserved across steps and modes + and there is no per-frame rebuild or flicker. +- **Pre-optimization is Preview-only** — the silent "classical pre-optimize" + checkbox is gone; pre-optimization happens only through the transparent + Preview → Keep/Revert flow, so nothing relaxes the geometry invisibly. (The + separate QM "geometry optimization before calculation" option is unchanged.) +- **Offline-first 3D rendering** — 3Dmol.js is vendored and loaded per-view from + a local `data:` URI instead of a CDN, so every 3D view works with no network + (the build fails if the vendored asset is missing). Native launchers tolerate + offline `pip install`. +- **Faster startup** — GPU detection and History/Compare population are deferred + off the synchronous construction path, so the UI paints in ~1 s instead of + ~15 s; the GPU status badge and dropdowns fill in shortly after. +- **Clear** of the live calculation log is disabled while a calculation runs. + +### Fixed + +- **GPU-offloaded result extraction** — HOMO–LUMO gap, dipole moment, and + Mulliken charges are now reported for GPU runs. CuPy arrays are copied to host + before extraction, and Mulliken falls back to the CPU object (gpu4pyscf does + not implement population analysis on the GPU). +- **Vibrational animation glitchiness** — stacked animation loops (a new loop + started on every mode switch) made playback jittery and too fast and ignored + the framerate setting; exactly one loop now runs. +- **Cancel status** no longer sticks on "Cancelling…" after a calculation is + cancelled. +- **Stale run status** — "Pre-optimized geometry accepted." is cleared when a new + molecule is loaded or a preview is reverted. +- Structure provenance is reported and the input viewer is persisted across + reloads. + ## [0.3.0] - 2026-06-11 Structure-sourcing release. Repairs the external-database structure search and diff --git a/README.md b/README.md index 450bc20..20d9bb0 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,22 @@ research and classroom use. ## What it does -- **Molecule input** — paste XYZ coordinates, draw from a 20+ preset library, - or search PubChem by name or SMILES +- **Molecule input** — paste XYZ coordinates, browse an indexed three-tier + bundled library (20 presets + 156 curated molecules + ~1,900 QM9 structures, + searchable by name/formula), or run a structure search by name, SMILES, + InChI, PubChem CID, InChIKey, or CAS number (PubChem → NCI CACTUS → offline + bundled-library fallback; SMILES/InChI resolve locally with no network) +- **Offline-first** — runs with no internet: the bundled molecule library and + the 3D viewer's JavaScript (3Dmol.js) are vendored, so structure lookup and + every 3D view work in an air-gapped classroom. (Network is used only for the + optional live PubChem/CACTUS search.) - **3D visualization** — interactive py3Dmol viewer (py3Dmol-first; optional plotlymol3d fallback for non-trajectory tasks). A capability-aware backend router picks the right renderer per task, and a Status-tab toggle persists your default-backend preference between sessions -- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, NMR - shielding, TD-DFT UV-Vis, and 1D PES scans via PySCF, running in your - Python kernel (no batch submission) +- **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, CCSD, + CCSD(T), NMR shielding, TD-DFT UV-Vis, and 1D PES scans via PySCF, running + in your Python kernel (no batch submission) - **Implicit solvent** — PCM solvation (Water, Ethanol, THF, DMSO, Acetonitrile) via a single checkbox - **Rich results** — total energy, HOMO-LUMO gap, Mulliken charges, dipole @@ -320,6 +327,8 @@ Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): | HSE06 | DFT screened hybrid | Band gaps, large molecules | | PBE-D3 | DFT GGA + dispersion | Van der Waals complexes, stacking | | MP2 | Post-HF | Accurate energetics for small molecules (O(N⁵)) | +| CCSD | Post-HF coupled cluster | High-accuracy small-molecule energies (O(N⁶)) | +| CCSD(T) | Post-HF coupled cluster | Benchmark "gold standard" energies (O(N⁷); CPU only) | ### Calculation types @@ -378,10 +387,14 @@ quantui/ Main package optimizer.py QM geometry optimization with trajectory visualization_py3dmol.py 3D viewer (py3Dmol-first; plotlymol fallback) viz_backend_router.py Capability-aware backend router (pure function) + viz_assets.py Offline-safe 3Dmol.js loading (vendored, no CDN) user_settings.py Persistent user preferences (~/.quantui/settings.json) vib_cache.py On-disk cache of rendered vib-mode HTML orbital_visualization.py Orbital energy diagrams + cube-file viewer - pubchem.py PubChem molecule search + pubchem.py Structure search client (PubChem + RDKit) + cactus.py NCI CACTUS resolver (fallback structure source) + structure_providers.py Unified resolver chain with offline fallback + molecule_library.py Indexed 3-tier bundled molecule library comparison.py Side-by-side result tables results_storage.py Timestamped result persistence (schema v2) calc_log.py Performance + event logging, time estimation @@ -389,11 +402,12 @@ quantui/ Main package benchmarks.py Timing calibration benchmark suite config.py Methods, basis sets, solvent/NMR options, presets ase_bridge.py ASE structure I/O - preopt.py LJ force-field pre-optimization + preopt.py RDKit MMFF94/UFF force-field pre-optimization + data/ Bundled library (SQLite + manifests) + vendored 3Dmol.js notebooks/ molecule_computations.ipynb Main user-facing interface (3-cell launcher) tutorials/ Step-by-step guided notebooks (01–05) -tests/ pytest test suite (~1000 tests) +tests/ pytest test suite (~1500 tests; run in parallel via pytest-xdist) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config diff --git a/apptainer/README.md b/apptainer/README.md index 262f624..dcdeadd 100644 --- a/apptainer/README.md +++ b/apptainer/README.md @@ -1,10 +1,17 @@ # QuantUI — Apptainer Container -The Apptainer container packages Python, PySCF, ASE, py3Dmol, and Voilà +The Apptainer container packages Python, PySCF, RDKit, ASE, py3Dmol, and Voilà into a single portable `.sif` file. It is the **recommended path for Windows users** (via WSL) and for anyone who wants a zero-installation experience — students copy one file and run it. +**Runs fully offline.** The container bundles everything it needs — the +3-tier molecule library and the 3D viewer's JavaScript (3Dmol.js) are vendored, +so structure lookup and every 3D view (molecules, trajectories, vibrations, +orbital isosurfaces) work with **no internet connection**. Network is only ever +used for the optional live PubChem/CACTUS structure search. Ideal for an +air-gapped or restricted-network classroom. + --- ## Contents @@ -85,8 +92,8 @@ bash apptainer/build.sh --clean --test bash apptainer/build.sh --fakeroot ``` -Build time: **~6 minutes** on a modern laptop with a good internet connection. -Final image size: **~4–5 GB**. +Build time: **~20–40 minutes** (dominated by the conda solve + PySCF download) +on a modern laptop with a good internet connection. Final image size: **~4–5 GB**. The script must be run from the **repo root** (not from `apptainer/`) because the `.def` file copies the entire repo root into the container with @@ -247,6 +254,14 @@ B3LYP/STO-3G: -75.312587 Ha converged: True | `PBE` | DFT GGA | Large molecules, speed-critical | | `PBE0` | DFT hybrid | Charge transfer, band gaps | | `M06-2X` | DFT meta-hybrid | Reaction barriers, thermochemistry | +| `wB97X-D`, `CAM-B3LYP` | DFT range-separated | Non-covalent / charge-transfer / UV-Vis | +| `M06-L`, `HSE06`, `PBE-D3` | DFT (meta-GGA / screened / dispersion) | Large systems, band gaps, vdW complexes | +| `MP2` | Post-HF | Accurate small-molecule energetics (O(N⁵)) | +| `CCSD`, `CCSD(T)` | Post-HF coupled cluster | Benchmark-quality energies (O(N⁶)/O(N⁷); (T) is CPU-only) | + +Six calculation types run over these: Single Point, Geometry Opt, Frequency +(+ thermochemistry / IR), UV-Vis (TD-DFT), NMR shielding, and 1D PES scan; PCM +implicit solvent (Water, Ethanol, THF, DMSO, Acetonitrile) is a single checkbox. ### Basis sets @@ -377,12 +392,14 @@ sudo apt-get install -y apptainer | Layer | Contents | | --- | --- | | Base | `continuumio/miniconda3:latest` (Debian + conda) | -| conda-forge | jupyter, jupyterlab, ipywidgets, pyscf, numpy, scipy, matplotlib, plotly, h5py | +| conda-forge | jupyter, jupyterlab, ipywidgets, notebook, pyscf, numpy, scipy, matplotlib, plotly, h5py, rdkit | | pip | voila, ase, py3dmol, requests | -| QuantUI | installed from `/opt/quantui` (the repo root, copied at build time) | +| QuantUI | installed from `/opt/quantui` (the repo root, copied at build time) — bundles the molecule library + vendored 3Dmol.js for offline use | -The `.git` directory and `__pycache__` folders are removed during build to -keep the image lean. +The `.git` directory, `__pycache__` folders, and internal dev files are removed +during build to keep the image lean. A build-time check asserts the vendored +3Dmol.js is present and the viewer is CDN-free, so a broken offline build fails +fast instead of shipping blank 3D views. --- @@ -392,12 +409,12 @@ Edit `%labels` in `quantui.def` to bump the version string, then rebuild: ```singularity %labels - Version "0.2.0" + Version "0.3.0" ``` Tag the git commit and push so the version is traceable: ```bash -git tag v0.2.0 -git push origin v0.2.0 +git tag v0.3.0 +git push origin v0.3.0 ``` diff --git a/apptainer/quantui.def b/apptainer/quantui.def index f37036a..b67cc96 100644 --- a/apptainer/quantui.def +++ b/apptainer/quantui.def @@ -21,7 +21,7 @@ FROM: continuumio/miniconda3:latest %labels Maintainer "Jonathan Schultz" Purpose "Local teaching interface for quantum chemistry calculations" - Version "0.1.0" + Version "0.4.0" %environment export PATH="/opt/conda/bin:${PATH}" @@ -72,6 +72,11 @@ FROM: continuumio/miniconda3:latest python -c "import pyscf; print('PySCF OK')" python -c "import rdkit; print('RDKit OK')" python -c "import py3Dmol; print('py3Dmol OK')" + # Offline 3D: the vendored 3Dmol.js must be bundled and make_view must emit a + # CDN-free viewer that loads it from a local data: URI, or every 3D view + # blanks with no network (the whole point of the offline build). Fail the + # build here rather than ship blank viewers. + python -c "from quantui.viz_assets import _JS_PATH, _js_data_uri, make_view; assert _JS_PATH.exists() and _JS_PATH.stat().st_size > 100000, 'vendored 3Dmol.js missing/too small'; assert _js_data_uri().startswith('data:text/javascript;base64,'); v = make_view(width=80, height=80); v.addModel('1\nH\nH 0 0 0', 'xyz'); h = v._make_html(); assert 'jsdelivr' not in h and 'data:text/javascript;base64,' in h, 'viewer not loading vendored 3Dmol offline'; print('offline 3Dmol OK')" python -c "import ase; print('ASE OK')" python -c "from quantui import optimize_geometry; print('optimize_geometry OK')" python -c "from quantui import run_freq_calc, run_tddft_calc; print('freq/tddft OK')" diff --git a/docs/CLI.md b/docs/CLI.md index a835585..1f8fb10 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -66,7 +66,7 @@ quantui log tail -n 200 | grep -i error | tail -5 ``` 2026-05-25T13:55:22.421910+00:00 viz_route_decision task=molecule_preview pref=auto chosen=py3dmol reason=auto -> task primary (py3dmol) -2026-05-25T13:55:22.470028+00:00 startup QuantUI 0.2.0 started +2026-05-25T13:55:22.470028+00:00 startup QuantUI 0.3.0 started (viz backend pref=auto) 2026-05-25T14:08:14.102544+00:00 calc_done B3LYP/STO-3G on H2O elapsed_s=1.2 converged=True gpu_used=True gpu_name=NVIDIA GeForce RTX 4050 Laptop GPU ``` diff --git a/docs/index.html b/docs/index.html index 9dec9d0..29ee797 100644 --- a/docs/index.html +++ b/docs/index.html @@ -339,7 +339,7 @@
" ), app.perf_estimate_html, - widgets.HBox([app.run_btn, app.run_status]), + widgets.HBox([app.run_btn, app.cancel_btn, app.run_status]), widgets.HBox( [ widgets.HTML( @@ -1364,10 +1438,16 @@ def _plot_export_row(prefix: str) -> widgets.HBox: layout=layout_fn(align_items="center", margin="6px 0 0 0"), ) + # Hidden sink for Python→JS calls that switch the single-viewer's mode + # client-side (window.__quantuiVibSetMode). Kept in the DOM (not display:none) + # so the injected Javascript executes; empty/cleared between calls so it + # takes no visible space. See app_visualization._vib_bridge_set_mode. + app._vib_js_bridge = widgets.Output(layout=layout_fn(margin="0", padding="0")) + app.vib_accordion = widgets.Accordion( children=[ widgets.VBox( - [vib_mode_row, app.vib_output, vib_export_row], + [vib_mode_row, app.vib_output, vib_export_row, app._vib_js_bridge], layout=layout_fn(padding="8px"), ) ], @@ -1869,7 +1949,9 @@ def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> app.advanced_accordion.set_title(0, "Export") app.advanced_accordion.selected_index = None - app._populate_compare_list() + # Deferred to a background startup task (loads every saved result) — see + # app._start_deferred_startup_tasks. Placeholder until then. + app.compare_select.options = [("⏳ loading saved results…", "")] def build_output_tab(app: Any, *, layout_fn: Any) -> None: diff --git a/quantui/app_formatters.py b/quantui/app_formatters.py index 292cda8..431ecb9 100644 --- a/quantui/app_formatters.py +++ b/quantui/app_formatters.py @@ -6,6 +6,79 @@ from typing import Any, Optional +def _result_extra_rows(get: Any) -> str: + """Build the shared 'extra' result-card rows from an accessor. + + ``get(key, default=None)`` reads a field from either a result object + (``getattr``) or a saved ``result.json`` dict (``dict.get``). Used by BOTH + :func:`format_result` (live) and :func:`format_past_result` (history) so the + two cards can never drift again — the M-CLEAN regression where the compute + device / dipole / Mulliken rows existed only on the live card. Rows: + post-HF correlation breakdown (MP2 / CCSD / (T)), solvent, compute device + (always shown), dipole moment, Mulliken charges. + """ + + def _num(label: str, value: str) -> str: + return ( + f'Frame render failed: {message}
' - ) - - # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list) + # Support both OptimizationResult (.trajectory) and PESScanResult + # (.coordinates_list). traj = getattr(opt_result, "trajectory", None) or getattr( opt_result, "coordinates_list", [] ) @@ -307,265 +291,55 @@ def _show_frame_error(message: str) -> None: except ImportError: pass - # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) --- + # --- Pre-build XYZ blocks (reused by the viewer and the export) --- charge = traj[0].charge xyzblocks = [ f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj ] - frame_w, frame_h, frame_res = 460, 340, 8 - - # --- Attempt fast-path: bond perception once on frame 0 --- - ref_mol = None - plotlymol_fast = False + formula = traj[0].get_formula() try: - from plotlymol3d import ( - draw_3D_mol as _draw_3D_mol, - ) - from plotlymol3d import ( - format_figure as _fmt_fig, - ) - from plotlymol3d import ( - format_lighting as _fmt_light, - ) - from plotlymol3d import ( - make_subplots as _make_subplots, - ) - from plotlymol3d import ( - xyzblock_to_rdkitmol as _xyz_to_rdkit, - ) - from rdkit import Chem as _Chem - - from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP - - ref_mol = _xyz_to_rdkit(xyzblocks[0], charge=charge) - plotlymol_fast = ref_mol is not None + bgcolor = app._plotly_theme_colors()["scene_bgcolor"] except Exception: - pass - - def _build_fig_fast(idx: int): - """Reuse frame-0 bond topology; only swap in new atom positions.""" - mol_xyz = _Chem.MolFromXYZBlock(xyzblocks[idx] + "\n") - if mol_xyz is None: - return None - rw = _Chem.RWMol(ref_mol) - conf_src = mol_xyz.GetConformer() - conf_dst = rw.GetConformer() - for atom_idx in range(rw.GetNumAtoms()): - conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx)) - fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]]) - _draw_3D_mol(fig, rw.GetMol(), frame_res, "ball+stick") - fig = _fmt_fig(fig) - fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"])) - scene_bg = app._plotly_theme_colors()["scene_bgcolor"] - fig.update_layout( - width=frame_w, - height=frame_h, - paper_bgcolor="white", - scene=dict(bgcolor=scene_bg), - margin=dict(l=0, r=0, t=0, b=0), - ) - return fig - - def _try_py3dmol(idx: int): - """Build frame idx with py3Dmol. Returns (kind, obj) or None.""" - try: - import py3Dmol as _p3d + bgcolor = "white" - view = _p3d.view(width=frame_w, height=frame_h) - view.addModel(xyzblocks[idx], "xyz") - view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) - view.setBackgroundColor( - "white" if app.theme_btn.value == "Light" else "#1e1e1e" - ) - view.zoomTo() - return ("py3dmol", view) - except Exception: - return None - - def _try_plotlymol(idx: int): - """Build frame idx with plotlymol3d. Tries fast bond-cached path - first, falls back to slow path. Returns (kind, obj) or None.""" - if plotlymol_fast: - try: - fig = _build_fig_fast(idx) - if fig is not None: - return ("plotly", fig) - except Exception: - pass - try: - from quantui.visualization_py3dmol import visualize_molecule_plotlymol - - fig = visualize_molecule_plotlymol( - traj[idx], - mode="ball+stick", - resolution=frame_res, - width=frame_w, - height=frame_h, - ) - scene_bg = app._plotly_theme_colors()["scene_bgcolor"] - fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg)) - return ("plotly", fig) - except ImportError: - return None - - def _build_fig(idx: int): - """Return (kind, obj) for frame idx. Trajectory frame rendering is - py3Dmol-only per the routing policy: plotlymol is blocked from - real-time trajectory use to avoid its RequireJS flicker pattern. - If py3Dmol is unavailable on this host, returns an error frame - rather than silently falling back to plotlymol.""" - from quantui.viz_backend_router import VizBackend as _VB - from quantui.viz_backend_router import VizTask as _VT + if _is_stale(): + return - chosen = app._resolve_backend(_VT.TRAJECTORY_FRAME) - if chosen != _VB.PY3DMOL: - return ( - "error", - "Trajectory rendering requires py3Dmol (plotlymol blocked " - "for real-time use to avoid flicker). py3Dmol is unavailable.", - ) - with _viz_render_event( - app, task="trajectory_frame", backend="py3dmol", idx=idx - ): - result = _try_py3dmol(idx) - if result is not None: - return result - return ("error", "py3Dmol failed to build trajectory frame") - - frame_cache: dict[int, Any] = {} - - # --- Carousel controls --- - step_slider = widgets.IntSlider( - value=0, - min=0, - max=n - 1, - description="Step:", - continuous_update=False, - style={"description_width": "40px"}, - layout=layout_fn(width="360px"), - ) - step_info = widgets.HTML(value=app._traj_step_html(0, traj, energies, rel_e)) - # Fixed height (not just min_height) so the container box never resizes - # between frame swaps — eliminates the layout flash / page-scroll jump - # the user reported on each arrow/slider click. - frame_out = widgets.Output( - layout=layout_fn(height=f"{frame_h}px", width=f"{frame_w}px") - ) - cache_label = widgets.HTML( - value=f'' - f"Pre-rendering frames… 0 / {n}" + # --- Single-viewer trajectory stepper (all frames preloaded) --- + viewer_output = widgets.Output( + layout=layout_fn( + height="410px", width="100%", max_width="500px", overflow="hidden" + ) ) - - def _display_frame(idx: int) -> None: - if _is_stale(): - return - kind, obj = frame_cache[idx] - try: - from quantui import calc_log as _clog_df - - _clog_df.log_event("traj_frame_display", f"idx={idx} kind={kind}") - except Exception: - pass - if kind == "error": - _swap_frame_out( - f'' - f"Frame render failed: {obj}
" - ) - return - if kind == "plotly": - # Render via Plotly HTML serialization. The atomic outputs swap - # avoids the brief empty state between clear+append, eliminating - # the layout-flash visible on rapid frame switches. - import plotly.io as _pio - - _swap_frame_out( - _pio.to_html( - obj, - full_html=False, - include_plotlyjs="require", - config={"responsive": True}, - ) + try: + with _viz_render_event(app, task="trajectory", backend="py3dmol", n_frames=n): + html = build_trajectory_viewer_html( + xyzblocks, + formula=formula, + energies=list(energies) if energies else None, + rel_e=rel_e or None, + bgcolor=bgcolor, ) - return - # py3Dmol view object — convert to its HTML repr and atomic-swap. - make_html = getattr(obj, "_make_html", None) - if callable(make_html): - try: - _swap_frame_out(obj._make_html()) - return - except Exception as exc: - _swap_frame_out( - f'' - f"py3Dmol render failed: {exc}
" - ) - return - _swap_frame_out( - '' - "Frame object missing HTML representation
" - ) - - def _update_frame(change: dict[str, Any]) -> None: - if _is_stale(): - return - idx = change["new"] - step_info.value = app._traj_step_html(idx, traj, energies, rel_e) - if idx in frame_cache: - _display_frame(idx) - return - _swap_frame_out( - 'Rendering…
' + app._set_html_output(viewer_output, html) + except Exception as exc: # noqa: BLE001 — surface inline, never crash the tab + viewer_output.outputs = ( + { + "output_type": "display_data", + "data": { + "text/html": ( + '' + f"Trajectory viewer failed: {exc}
" + ) + }, + "metadata": {}, + }, ) - def _on_demand() -> None: - try: - frame_cache[idx] = _build_fig(idx) - app._queue_main_thread_callback(_display_frame, idx) - except Exception as exc: - if _is_stale(): - return - app._queue_main_thread_callback(_show_frame_error, str(exc)) - - threading.Thread(target=_on_demand, daemon=True).start() - - step_slider.observe(app._safe_cb(_update_frame), names="value") - - # --- Prev/next arrow buttons for one-step navigation --- - prev_btn = widgets.Button( - icon="arrow-left", - tooltip="Previous frame", - layout=layout_fn(width="40px", margin="0 4px 0 0"), - disabled=True, # starts at frame 0 - ) - next_btn = widgets.Button( - icon="arrow-right", - tooltip="Next frame", - layout=layout_fn(width="40px", margin="0 8px 0 4px"), - disabled=(n <= 1), - ) - - def _on_prev_clicked(_btn) -> None: - if step_slider.value > 0: - step_slider.value -= 1 - - def _on_next_clicked(_btn) -> None: - if step_slider.value < n - 1: - step_slider.value += 1 - - prev_btn.on_click(_on_prev_clicked) - next_btn.on_click(_on_next_clicked) - - def _update_nav_buttons(change: dict[str, Any]) -> None: - idx = change["new"] - prev_btn.disabled = idx <= 0 - next_btn.disabled = idx >= n - 1 - - step_slider.observe(app._safe_cb(_update_nav_buttons), names="value") - - # --- Export button --- + # --- Export button (standalone HTML animation; plotlymol3d) --- export_btn = widgets.Button( description="Export Animation", icon="download", - layout=layout_fn(width="160px", margin="0 0 0 12px"), + layout=layout_fn(width="160px"), tooltip="Generate a standalone HTML animation file (may take a minute)", ) export_status = widgets.HTML() @@ -619,48 +393,11 @@ def _do_export() -> None: export_btn.on_click(_on_export) - # --- Assemble layout --- - header = widgets.HBox( - [prev_btn, step_slider, next_btn, export_btn], - layout=layout_fn(align_items="center", margin="4px 0"), - ) - panel = widgets.VBox([header, step_info, cache_label, frame_out, export_status]) - - # Build and render frame 0 SYNCHRONOUSLY on the main thread before - # displaying the panel, so the Output widget arrives at the browser with - # frame 0 already in its outputs list. This avoids the io_loop-callback - # latency that left frame 0 invisible until the first slider click. - if _is_stale(): - return - try: - frame_cache[0] = _build_fig(0) - _display_frame(0) - sync_frame0_ok = True - except Exception as _f0_exc: - sync_frame0_ok = False - try: - from quantui import calc_log as _clog_f0 - - _clog_f0.log_event( - "traj_frame0_sync_error", - f"{type(_f0_exc).__name__}: {_f0_exc}"[:300], - ) - except Exception: - pass - _swap_frame_out( - '' - "Rendering frame 0…
" - ) - - # Display panel. + # --- Assemble: energy chart (HTML in an Output so RequireJS runs) + + # viewer + export row, set atomically as traj_output's children. --- if _is_stale(): return - # Build the energy figure as HTML inside an Output widget so RequireJS - # / Plotly scripts execute, and put the panel widget directly as a - # sibling child of traj_output. Setting traj_output.children atomically - # avoids the deferred-display-via-Output issue that was emptying the - # accordion in BUG-FRESH-TRAJ. - new_children = [] + new_children: list[Any] = [] if has_plotly and rel_e: import plotly.io as _pio_e @@ -679,70 +416,22 @@ def _do_export() -> None: }, ) new_children.append(energy_holder) - new_children.append(panel) + new_children.append(viewer_output) + new_children.append( + widgets.HBox( + [export_btn, export_status], + layout=layout_fn(align_items="center", margin="4px 0"), + ) + ) app.traj_output.children = tuple(new_children) + try: from quantui import calc_log as _clog_sp - _clog_sp.log_event( - "traj_show_panel", - f"n={n} plotlymol_fast={plotlymol_fast} " - f"sync_frame0_ok={sync_frame0_ok} " - f"traj_children_n={len(getattr(app.traj_output, 'children', ()))}", - ) + _clog_sp.log_event("traj_show_panel", f"n={n} single_viewer=1") except Exception: pass - def _prerender_all() -> None: - """Render remaining frames in a background thread (frame 0 already - built+displayed synchronously above when sync_frame0_ok).""" - if _is_stale(): - return - try: - if 0 not in frame_cache: - frame_cache[0] = _build_fig(0) - app._queue_main_thread_callback(_display_frame, 0) - app._queue_main_thread_callback( - _set_cache_label, - f'' - f"Pre-rendering frames… 1 / {n}", - ) - if n > 1: - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: - futures = {pool.submit(_build_fig, i): i for i in range(1, n)} - done = 1 - for fut in concurrent.futures.as_completed(futures): - if _is_stale(): - return - i = futures[fut] - try: - frame_cache[i] = fut.result() - except Exception: - pass - done += 1 - app._queue_main_thread_callback( - _set_cache_label, - f'' - f"Pre-rendering frames… {done} / {n}", - ) - except Exception: - pass - app._queue_main_thread_callback( - _set_cache_label, - f'' - f"✓ All {n} frames ready", - ) - try: - from quantui import calc_log as _clog_pre - - _clog_pre.log_event( - "traj_prerender_complete", f"n={n} cached={len(frame_cache)}" - ) - except Exception: - pass - - threading.Thread(target=_prerender_all, daemon=True).start() - def traj_step_html( app: Any, step: int, traj: list[Any], energies: list[Any], rel_e: list[Any] @@ -782,14 +471,14 @@ def render_traj_frame(app: Any, molecule: Any, output_widget: Any) -> None: # Fallback: py3Dmol try: - import py3Dmol as _p3d + from quantui.viz_assets import make_view xyz = ( f"{len(molecule.atoms)}\n" f"{molecule.get_formula()}\n" f"{molecule.to_xyz_string()}" ) - view = _p3d.view(width=460, height=340) + view = make_view(width=460, height=340) view.addModel(xyz, "xyz") view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) view.setBackgroundColor("white") @@ -933,14 +622,33 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: if not options: return False - app.vib_mode_dd.options = options - app.vib_mode_dd.value = options[0][1] - app._last_vib_data = vib_data # may be None — plotlymol3d optional app._last_vib_molecule = molecule app._last_vib_freq_result = freq_result first_label, first_mode = options[0] + + # Decide the render path BEFORE assigning vib_mode_dd.value (which fires + # on_vib_mode_changed): set the single-viewer flag first so that observer + # takes the client-side-switch branch instead of spawning a redundant + # legacy per-mode render. The preferred path is ONE persistent py3Dmol + # viewer holding every mode, with client-side mode switching, so the camera + # (rotation/zoom) is preserved across modes (matches pre-opt/trajectory). + # Falls back to the legacy renderer when py3Dmol isn't selected or + # displacements are unavailable (e.g. some history replays). + use_single = _vib_single_viewer_supported(app, freq_result) + app._vib_single_viewer_active = use_single + + app.vib_mode_dd.options = options + app.vib_mode_dd.value = first_mode # fires on_vib_mode_changed + + if use_single: + if _render_vib_single_viewer( + app, freq_result, molecule, first_mode, [m for _, m in options] + ): + return True + app._vib_single_viewer_active = False # build failed → legacy fallback + # Cache-hit fast path: on history replay the cached HTML for the first # mode is on disk, so swap it in synchronously without a placeholder. # ``reset_camera=True`` clears any stale camera matrix from a previous @@ -1472,7 +1180,9 @@ def _is_stale() -> bool: from quantui.orbital_visualization import ( generate_cube_from_arrays, plot_cube_isosurface, + render_orbital_isosurface_py3dmol, ) + from quantui.viz_backend_router import VizTask as _VT result_dir = getattr(app, "_last_result_dir", None) if not isinstance(result_dir, Path): @@ -1495,26 +1205,43 @@ def _is_stale() -> bool: cube_path = cube_dir / f"{safe_formula}_{safe_orb}_{ts}.cube" generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path) - is_dark = app.theme_btn.value == "Dark" - axis_color = "#dbeafe" if is_dark else "#1f2937" - bond_color = "#cbd5e1" if is_dark else "#4b5563" - title_color = app._plotly_theme_colors()["font_color"] - fig = plot_cube_isosurface( - cube_path, - title=f"{orbital_label} Isosurface", - show_molecule=True, - show_grid=False, - scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"], - axis_color=axis_color, - title_color=title_color, - bond_color=bond_color, - ) - html_str = _pio.to_html( - fig, - include_plotlyjs="require", - full_html=False, - config={"responsive": True}, - ) + scene_bgcolor = app._plotly_theme_colors()["scene_bgcolor"] + + # Route the render: py3Dmol does native, full-resolution in-browser + # isosurfacing (primary); the Plotly path is the fallback (downsampled). + # Both consume the same full-resolution cube on disk. Plotly is the + # universal fallback whenever py3Dmol is not the chosen backend. + chosen = app._resolve_backend(_VT.ORBITAL_ISOSURFACE) + use_py3dmol = str(chosen) == "py3dmol" + backend_label = "py3dmol" if use_py3dmol else "plotlymol" + + with _viz_render_event(app, task=_VT.ORBITAL_ISOSURFACE, backend=backend_label): + if use_py3dmol: + html_str = render_orbital_isosurface_py3dmol( + cube_path, + bgcolor=scene_bgcolor, + ) + else: + is_dark = app.theme_btn.value == "Dark" + axis_color = "#dbeafe" if is_dark else "#1f2937" + bond_color = "#cbd5e1" if is_dark else "#4b5563" + title_color = app._plotly_theme_colors()["font_color"] + fig = plot_cube_isosurface( + cube_path, + title=f"{orbital_label} Isosurface", + show_molecule=True, + show_grid=False, + scene_bgcolor=scene_bgcolor, + axis_color=axis_color, + title_color=title_color, + bond_color=bond_color, + ) + html_str = _pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ) except Exception as exc: if _is_stale(): return @@ -1800,7 +1527,7 @@ def _render_vib_mode_py3dmol( fps = max(1, int(fps)) try: - import py3Dmol + import py3Dmol # noqa: F401 — probe for a friendly error; make_view imports it except ImportError as exc: if not _is_vib_stale(app, render_token): _vib_err(app, f"py3Dmol unavailable: {exc}") @@ -1889,8 +1616,10 @@ def _render_vib_mode_py3dmol( xyz_string = "\n".join(xyz_lines) + "\n" try: + from quantui.viz_assets import make_view + interval_ms = max(1, int(round(1000.0 / fps))) - view = py3Dmol.view(width=460, height=420) + view = make_view(width=460, height=420) view.addModelsAsFrames(xyz_string, "xyz") view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e" @@ -2084,6 +1813,13 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: if molecule is None or freq_result is None: return + # Single-viewer path: switch modes client-side on the one persistent viewer + # (camera preserved, no rebuild). Export + prev/next still drive vib_mode_dd, + # so they keep working unchanged. + if getattr(app, "_vib_single_viewer_active", False): + _vib_bridge_set_mode(app, mode_number) + return + # Cache-hit fast path: swap cached HTML synchronously, no placeholder, # no thread. Bumps the render token internally to invalidate any # in-flight render. @@ -2112,6 +1848,510 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: ).start() +_STEPPER_BTN_STYLE = ( + "padding:2px 9px;border:1px solid #cbd5e1;border-radius:4px;" + "background:#f8fafc;color:#334155;cursor:pointer;font-size:13px;line-height:1.4;" +) + +# Shared single-viewer stepper logic. Drives ``viewer.setFrame()`` on a viewer +# whose frames are ALL already loaded client-side via ``addModelsAsFrames`` — so +# navigation never rebuilds the viewer, the camera (rotation/zoom) stays put +# across frames, and there is no per-frame HTML/network round-trip. Play/pause is +# a self-managed setInterval (not 3Dmol's animate(), so manual stepping and play +# never fight). Tokens are substituted in :func:`_frame_stepper_controls`. +_STEPPER_JS = """ +(function(){ + var UID="__UID__", N=__N__, IV=__IV__, LOOP=__LOOP__; + var AB_START=__AB_START__, AB_OTHER=__AB_OTHER__; + __EXTRA__ + function g(p){return document.getElementById(p+UID);} + var slider=g("st_slider_"), lbl=g("st_lbl_"), prevB=g("st_prev_"), + nextB=g("st_next_"), playB=g("st_play_"), abB=g("st_ab_"); + var cur=__START__, timer=null; + function vw(){return window["viewer_"+UID];} + function label(i){ __LABEL_BODY__ } + function draw(i){ + i=Math.max(0,Math.min(N-1,i)); cur=i; + var v=vw(); + if(v){ try{ var p=v.setFrame(i); + if(p&&p.then){ p.then(function(){v.render();}); } else { v.render(); } + }catch(e){ try{ v.render(); }catch(_){} } } + if(slider) slider.value=i; + if(lbl) lbl.innerHTML=label(i); + if(prevB) prevB.disabled=(i<=0); + if(nextB) nextB.disabled=(i>=N-1); + if(abB) abB.innerHTML=(i===0)?AB_START:AB_OTHER; + } + function stop(){ if(timer){clearInterval(timer);timer=null;} + if(playB) playB.innerHTML="\\u25b6 Play"; } + function play(){ if(N<=1) return; + if(cur>=N-1) draw(0); // at the end → replay from the first frame + if(playB) playB.innerHTML="\\u23f8 Pause"; + timer=setInterval(function(){ + if(cur>=N-1){ if(LOOP){ draw(0); return; } stop(); return; } + draw(cur+1); + }, IV); } + if(prevB) prevB.onclick=function(){stop();draw(cur-1);}; + if(nextB) nextB.onclick=function(){stop();draw(cur+1);}; + if(playB) playB.onclick=function(){ timer?stop():play(); }; + if(abB) abB.onclick =function(){ stop();draw(cur===0?N-1:0); }; + if(slider)slider.oninput=function(){ stop();draw(parseInt(slider.value,10)); }; + var t=0, poll=setInterval(function(){ t++; + if(vw()){ clearInterval(poll); draw(cur); } + else if(t>200){ clearInterval(poll); + if(lbl) lbl.innerHTML="3D viewer failed to load"; } + },50); +})(); +""" + + +def _frame_stepper_controls( + uid: str, + n: int, + interval_ms: int, + *, + label_js: str, + initial_label: str, + loop: bool, + ab_at_start: str | None = None, + ab_other: str | None = None, + scrub_title: str = "Scrub frames", + start_index: int | None = None, + extra_decls: str = "", +) -> str: + """Build in-HTML stepper controls (prev/play/next, scrub slider, optional + A/B flip, live label) for a single multi-frame py3Dmol viewer. + + Element ids are namespaced with the viewer's ``uid`` so multiple viewers on + one page never collide. The script polls for the global ``viewer_BOND"==a[u++]){b=!0;break}if(b&&c)for(d=0;d =p?(E=F[a[g]-p],L=S[a[g]-p]):(E=96,L=0),h=1< a){if(e.match_start=t,a=r,r>=o)break;m=h[n+a-1],g=h[n+a]}}}while((t=f[t&u])>l&&0!==--s);return a<=e.lookahead?a:e.lookahead}function C(e){var t,i,r,n,a,o=e.w_size;do{if(n=e.window_size-e.lookahead-e.strstart,e.strstart>=o+(o-d)){s.arraySet(e.window,e.window,o,o,0),e.match_start-=o,e.strstart-=o,e.block_start-=o,t=i=e.hash_size;do{r=e.head[--t],e.head[t]=r>=o?r-o:0}while(--i);t=i=o;do{r=e.prev[--t],e.prev[t]=r>=o?r-o:0}while(--i);n+=o}if(0===e.strm.avail_in)break;if(i=w(e.strm,e.window,e.strstart+e.lookahead,n),e.lookahead+=i,e.lookahead+e.insert>=3)for(a=e.strstart-e.insert,e.ins_h=e.window[a],e.ins_h=(e.ins_h<0&&(this.molObj=null)}setColorByElement(e,t){if(null===this.molObj||!GLModel.sameObj(t,this.lastColors)){this.lastColors=t;var i=this.selectedAtoms(e,i);i.length>0&&(this.molObj=null);for(var r=0;r1e6&&(this.scaleFactor=this.defaultScaleFactor/2);let r=1/this.scaleFactor*5.5;this.pminx=e[0][0],this.pmaxx=e[1][0],this.pminy=e[0][1],this.pmaxy=e[1][1],this.pminz=e[0][2],this.pmaxz=e[1][2],t?(this.pminx-=this.probeRadius+r,this.pminy-=this.probeRadius+r,this.pminz-=this.probeRadius+r,this.pmaxx+=this.probeRadius+r,this.pmaxy+=this.probeRadius+r,this.pmaxz+=this.probeRadius+r):(this.pminx-=r,this.pminy-=r,this.pminz-=r,this.pmaxx+=r,this.pmaxy+=r,this.pmaxz+=r),this.pminx=Math.floor(this.pminx*this.scaleFactor)/this.scaleFactor,this.pminy=Math.floor(this.pminy*this.scaleFactor)/this.scaleFactor,this.pminz=Math.floor(this.pminz*this.scaleFactor)/this.scaleFactor,this.pmaxx=Math.ceil(this.pmaxx*this.scaleFactor)/this.scaleFactor,this.pmaxy=Math.ceil(this.pmaxy*this.scaleFactor)/this.scaleFactor,this.pmaxz=Math.ceil(this.pmaxz*this.scaleFactor)/this.scaleFactor,this.ptranx=-this.pminx,this.ptrany=-this.pminy,this.ptranz=-this.pminz,this.pLength=Math.ceil(this.scaleFactor*(this.pmaxx-this.pminx))+1,this.pWidth=Math.ceil(this.scaleFactor*(this.pmaxy-this.pminy))+1,this.pHeight=Math.ceil(this.scaleFactor*(this.pmaxz-this.pminz))+1,this.boundingatom(t),this.cutRadius=this.probeRadius*this.scaleFactor,this.vpBits=new Uint8Array(this.pLength*this.pWidth*this.pHeight),this.vpDistance=new Float64Array(this.pLength*this.pWidth*this.pHeight),this.vpAtomID=new Int32Array(this.pLength*this.pWidth*this.pHeight)}boundingatom(e){let t={};for(const i in this.vdwRadii){let r=this.vdwRadii[i];t[i]=e?(r+this.probeRadius)*this.scaleFactor+.5:r*this.scaleFactor+.5;let s=t[i]*t[i];this.widxz[i]=Math.floor(t[i])+1,this.depty[i]=new Int32Array(this.widxz[i]*this.widxz[i]);let n=0;for(let e=0;eA?A=t.coords[e].z:t.coords[e].z(window.innerHeight||document.documentElement.clientHeight)||t.left>(window.innerWidth||document.documentElement.clientWidth))};if(t(this.container)){let e=0;for(let i of document.getElementsByTagName("canvas"))if(t(i)&&null!=i._3dmol_viewer&&(i._3dmol_viewer.resize(),e+=1,e>=Be))break}}initContainer(e){this.container=e,this.WIDTH=this.getWidth(),this.HEIGHT=this.getHeight(),this.ASPECT=this.renderer.getAspect(this.WIDTH,this.HEIGHT),this.renderer.setSize(this.WIDTH,this.HEIGHT),this.container.append(this.renderer.domElement),this.glDOM=this.renderer.domElement,this.glDOM._3dmol_viewer=this,this.glDOM.addEventListener("webglcontextlost",this._handleLostContext.bind(this)),this.nomouse||(this.glDOM.addEventListener("mousedown",this._handleMouseDown.bind(this),{passive:!1}),this.glDOM.addEventListener("touchstart",this._handleMouseDown.bind(this),{passive:!1}),this.glDOM.addEventListener("wheel",this._handleMouseScroll.bind(this),{passive:!1}),this.glDOM.addEventListener("mousemove",this._handleMouseMove.bind(this),{passive:!1}),this.glDOM.addEventListener("touchmove",this._handleMouseMove.bind(this),{passive:!1}),this.glDOM.addEventListener("contextmenu",this._handleContextMenu.bind(this),{passive:!1}))}decAnim(){this.animated--,this.animated<0&&(this.animated=0)}incAnim(){this.animated++}nextSurfID(){var e=0;for(let i in this.surfaces)if(this.surfaces.hasOwnProperty(i)){var t=parseInt(i);isNaN(t)||t>e&&(e=t)}return e+1}setSlabAndFog(){let e=this.camera.position.z-this.rotationGroup.position.z;e<1&&(e=1),this.camera.near=e+this.slabNear,!this.camera.ortho&&this.camera.near<1&&(this.camera.near=1),this.camera.far=e+this.slabFar,this.camera.near+1>this.camera.far&&(this.camera.far=this.camera.near+1),this.camera.fov=this.fov,this.camera.right=e*Math.tan(Math.PI/180*this.fov),this.camera.left=-this.camera.right,this.camera.top=this.camera.right/this.ASPECT,this.camera.bottom=-this.camera.top,this.camera.updateProjectionMatrix(),this.scene.fog.near=this.camera.near+this.fogStart*(this.camera.far-this.camera.near),this.scene.fog.far=this.camera.near+this.fogEnd*(this.camera.far-this.camera.near),this.config.disableFog&&(this.scene.fog.near=this.scene.fog.far)}show(e){if(this.renderer.setViewport(),this.scene&&(this.setSlabAndFog(),this.renderer.render(this.scene,this.camera),this.viewChangeCallback&&this.viewChangeCallback(this._viewer.getView()),!e&&this.linkedViewers.length>0))for(var t=this._viewer.getView(),i=0;i0)for(let t=0,i=d.length;t1||1==y.length&&!y[0].isIdentity()){_=!0;break}}var b=function(e,i,r){var n;m=s?GLViewer.shallowCopy(u.getAtomsFromSel(s)):r;var a=(0,d.getExtent)(r,!0);if(t.map&&t.map.prop){var l=t.map.prop;let e=(0,c.getGradient)(t.map.scheme||t.map.gradient||new c.Gradient.RWB),i=e.range();i||(i=(0,d.getPropertyRange)(r,l)),t.colorscheme={prop:l,gradient:e}}for(let e=0,r=i.length;e{let t;for(k(e,e.dyn_ltree,e.l_desc.max_code),k(e,e.dyn_dtree,e.d_desc.max_code),O(e,e.bl_desc),t=18;t>=3&&0===e.bl_tree[2*d[t]+1];t--);return e.opt_len+=3*(t+1)+5+5+4,t})(e),n=e.opt_len+3+7>>>3,a=e.static_len+3+7>>>3,a<=n&&(n=a)):n=a=i+5,i+4<=n&&-1!==t?U(e,t,i,r):4===e.strategy||a===n?(S(e,2+(r?1:0),3),D(e,u,f)):(S(e,4+(r?1:0),3),((e,t,i,r)=>{let s;for(S(e,t-257,5),S(e,i-1,5),S(e,r-4,4),s=0;sr+e.strm.avail_in&&(i=r+e.strm.avail_in),i>s&&(i=s),i>3])>>7-(7&S)&1,l[A*o+(C>>3)]|=z<<7-(3&C)),2==n&&(z=(z=t[S>>3])>>6-(7&S)&3,l[A*o+(C>>2)]|=z<<6-((3&C)<<1)),4==n&&(z=(z=t[S>>3])>>4-(7&S)&15,l[A*o+(C>>1)]|=z<<4-((1&C)<<2)),n>=8)for(var M=A*o+C*a,T=0;T>3)+T];S+=n,C+=g}w++,A+=m}v*_!=0&&(h+=_*(1+x)),p+=1}return l},e.decode._getBPP=function(e){return[1,null,3,1,2,null,4][e.ctype]*e.depth},e.decode._filterZero=function(t,i,r,s,n){var a=e.decode._getBPP(i),o=Math.ceil(s*a/8),l=e.decode._paeth;a=Math.ceil(a/8);for(var h=0;h>1)&255;for(c=n;c>1)&255}if(4==a){for(c=0;c>>8;return t},crc:function(t,i,r){return 4294967295^e.crc.update(4294967295,t,i,r)}},e.quantize=function(t,i,r){for(var s=[],n=0,a=0;a