quantui 0.5.2__tar.gz → 0.6.0__tar.gz
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-0.5.2 → quantui-0.6.0}/CHANGELOG.md +57 -1
- {quantui-0.5.2/quantui.egg-info → quantui-0.6.0}/PKG-INFO +1 -1
- {quantui-0.5.2 → quantui-0.6.0}/pyproject.toml +1 -1
- {quantui-0.5.2 → quantui-0.6.0}/quantui/__init__.py +1 -1
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app.py +91 -4
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_builders.py +174 -1
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_exports.py +124 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_visualization.py +385 -106
- {quantui-0.5.2 → quantui-0.6.0}/quantui/orbital_visualization.py +456 -47
- {quantui-0.5.2 → quantui-0.6.0}/quantui/user_settings.py +20 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/viz_assets.py +21 -6
- {quantui-0.5.2 → quantui-0.6.0}/quantui/viz_backend_router.py +34 -8
- {quantui-0.5.2 → quantui-0.6.0/quantui.egg-info}/PKG-INFO +1 -1
- {quantui-0.5.2 → quantui-0.6.0}/LICENSE +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/MANIFEST.in +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/README.md +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/SECURITY.md +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/analytics.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_analysis.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_formatters.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_history.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/app_runflow.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/ase_bridge.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/benchmarks.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/c_stderr.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/cactus.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/calc_log.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/calculator.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/cancellation.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/cli.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/comparison.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/config.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/data/js/3Dmol-min.js +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/data/library/library.sqlite +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/data/manifests/bulk_qm9.json +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/data/manifests/curated.json +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/data/manifests/presets.json +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/descriptor_cards.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/freq_calc.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/freq_ir_workers.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/gpu_offload.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/help_content.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/ir_plot.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/issue_tracker.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/live_log.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/log_utils.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/molecule.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/molecule_library.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/nmr_calc.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/optimizer.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/pes_scan.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/preopt.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/progress.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/pubchem.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/reorganization_energy.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/results_storage.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/security.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/session_calc.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/structure_providers.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/tddft_calc.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/theme.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/utils.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/vib_cache.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui/visualization_py3dmol.py +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/SOURCES.txt +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/dependency_links.txt +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/entry_points.txt +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/requires.txt +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/top_level.txt +0 -0
- {quantui-0.5.2 → quantui-0.6.0}/setup.cfg +0 -0
|
@@ -7,6 +7,61 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-08-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Save orbital isosurfaces as images.** A **Save PNG** button sits under the
|
|
15
|
+
viewer and captures exactly what you are looking at, camera and all. Options
|
|
16
|
+
for the filename, the DPI written into the file (72–600; this sets the
|
|
17
|
+
*print* size, not the pixel count), and a transparent background for dropping
|
|
18
|
+
a figure onto a slide — the transparency applies only to the saved file, so
|
|
19
|
+
the viewer on screen is unchanged.
|
|
20
|
+
- **Tunable isosurface resolution** — Coarse/Medium/Fine/Very fine grids,
|
|
21
|
+
remembered between launches.
|
|
22
|
+
- **Live isosurface controls**: isovalue, opacity and a colour-scheme dropdown
|
|
23
|
+
(GaussView orange/blue, Avogadro/Jmol red/blue, journal yellow/blue, and
|
|
24
|
+
more). All three update the existing viewer without recomputing anything, so
|
|
25
|
+
the orientation you rotated into is preserved.
|
|
26
|
+
- **The isovalue is explained in chemical terms.** Beside the slider, QuantUI
|
|
27
|
+
reports what fraction of the orbital's density the surface actually encloses
|
|
28
|
+
— "encloses 98.4% of the density" — because an amplitude threshold is rarely
|
|
29
|
+
the question anyone has.
|
|
30
|
+
- **A Cancel button** for isosurface generation. The underlying PySCF call
|
|
31
|
+
cannot be interrupted, so the computation finishes in the background, but its
|
|
32
|
+
result is discarded and the controls come back immediately.
|
|
33
|
+
- **The molecule is shown before you generate anything**, so the panel is never
|
|
34
|
+
empty — and you can orient the structure first, since that view carries into
|
|
35
|
+
the isosurface.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Orbital isosurfaces are py3Dmol only.** The Plotly renderer is no longer
|
|
40
|
+
selectable for them: it downsamples the volume by construction, and it cannot
|
|
41
|
+
carry the new image export. Plotly remains available for the molecule viewers.
|
|
42
|
+
- **Exported animations now match what you saw.** Vibrational modes and
|
|
43
|
+
optimization trajectories both export from py3Dmol — the renderer that drew
|
|
44
|
+
them on screen — instead of being rebuilt in Plotly. Exported files are also
|
|
45
|
+
now complete HTML documents declaring UTF-8, so labels no longer risk
|
|
46
|
+
mojibake when opened from disk.
|
|
47
|
+
- **For geometry optimizations, the Orbital Isosurface panel opens by default**
|
|
48
|
+
on the Analysis tab.
|
|
49
|
+
- **Isosurface viewers are framed** like the other 3-D viewers, and generating a
|
|
50
|
+
new one dims the current one in place rather than collapsing the panel.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **3-D backgrounds follow the theme.** The isosurface, vibrational and
|
|
55
|
+
trajectory viewers, and the Analysis-tab molecule viewer, kept their old
|
|
56
|
+
background when switching Light/Dark until something happened to redraw them.
|
|
57
|
+
- **The page no longer jumps** when generating an isosurface.
|
|
58
|
+
- **Isosurface changes now apply correctly.** Adjusting the isovalue, opacity or
|
|
59
|
+
colours previously layered a new surface on top of the old one, so raising the
|
|
60
|
+
isovalue appeared to do nothing, lowering opacity did nothing, and switching
|
|
61
|
+
palettes made the surface progressively brighter.
|
|
62
|
+
- Very large orbital grids are dramatically lighter in the browser — the cube
|
|
63
|
+
data was being embedded three times per render.
|
|
64
|
+
|
|
10
65
|
## [0.5.2] - 2026-08-03
|
|
11
66
|
|
|
12
67
|
### Fixed
|
|
@@ -508,7 +563,8 @@ Initial public scaffolding of the QuantUI package: `quantui` package with
|
|
|
508
563
|
`calculator.py`, basic notebook launcher, Apptainer container definition,
|
|
509
564
|
MIT license, and project metadata.
|
|
510
565
|
|
|
511
|
-
[Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.
|
|
566
|
+
[Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.6.0...HEAD
|
|
567
|
+
[0.6.0]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.2...v0.6.0
|
|
512
568
|
[0.5.2]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.1...v0.5.2
|
|
513
569
|
[0.5.1]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.0...v0.5.1
|
|
514
570
|
[0.5.0]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.4.1...v0.5.0
|
|
@@ -7,7 +7,7 @@ Calculations run locally in the Jupyter session — no cluster or SLURM required
|
|
|
7
7
|
PySCF requires Linux/macOS/WSL. Windows users should use the Apptainer container.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "0.
|
|
10
|
+
__version__ = "0.6.0"
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
13
|
from typing import Any
|
|
@@ -147,6 +147,9 @@ from quantui.app_exports import (
|
|
|
147
147
|
from quantui.app_exports import (
|
|
148
148
|
on_iso_export_cube as _exp_on_iso_export_cube,
|
|
149
149
|
)
|
|
150
|
+
from quantui.app_exports import (
|
|
151
|
+
on_orb_png_captured as _exp_on_orb_png_captured,
|
|
152
|
+
)
|
|
150
153
|
from quantui.app_formatters import (
|
|
151
154
|
format_freq_result as _fmt_freq_result,
|
|
152
155
|
)
|
|
@@ -333,6 +336,12 @@ from quantui.app_visualization import (
|
|
|
333
336
|
from quantui.app_visualization import (
|
|
334
337
|
on_ir_mode_changed as _viz_on_ir_mode_changed,
|
|
335
338
|
)
|
|
339
|
+
from quantui.app_visualization import (
|
|
340
|
+
on_iso_appearance_changed as _viz_on_iso_appearance_changed,
|
|
341
|
+
)
|
|
342
|
+
from quantui.app_visualization import (
|
|
343
|
+
on_iso_cancel as _viz_on_iso_cancel,
|
|
344
|
+
)
|
|
336
345
|
from quantui.app_visualization import (
|
|
337
346
|
on_iso_generate as _viz_on_iso_generate,
|
|
338
347
|
)
|
|
@@ -360,6 +369,9 @@ from quantui.app_visualization import (
|
|
|
360
369
|
from quantui.app_visualization import (
|
|
361
370
|
render_vib_mode as _viz_render_vib_mode,
|
|
362
371
|
)
|
|
372
|
+
from quantui.app_visualization import (
|
|
373
|
+
rerender_3d_scenes_for_theme as _viz_rerender_3d_scenes_for_theme,
|
|
374
|
+
)
|
|
363
375
|
from quantui.app_visualization import (
|
|
364
376
|
show_ir_spectrum as _viz_show_ir_spectrum,
|
|
365
377
|
)
|
|
@@ -1628,9 +1640,31 @@ class QuantUIApp:
|
|
|
1628
1640
|
("Isosurface", "_pop_isosurface", False),
|
|
1629
1641
|
],
|
|
1630
1642
|
"geometry_opt": [
|
|
1631
|
-
|
|
1643
|
+
# ORDER MATTERS: the FIRST entry whose populator returns True and
|
|
1644
|
+
# carries auto_select=True becomes the default panel (see the rules
|
|
1645
|
+
# above). Isosurface therefore leads (requested 2026-08-04); an
|
|
1646
|
+
# earlier attempt put it last, which did nothing because Trajectory
|
|
1647
|
+
# had already claimed the selection.
|
|
1648
|
+
#
|
|
1649
|
+
# Trajectory keeps auto_select=True as the fallback: when a result
|
|
1650
|
+
# has no orbital data, _pop_isosurface returns False, Isosurface
|
|
1651
|
+
# never activates, and Trajectory becomes the default instead.
|
|
1652
|
+
# ORDER IS LOAD-BEARING TWICE OVER.
|
|
1653
|
+
#
|
|
1654
|
+
# 1. Execution: _pop_energies calls show_orbital_diagram, which is
|
|
1655
|
+
# what populates _last_orb_mo_coeff / _mol_atom / _mol_basis —
|
|
1656
|
+
# the very state _pop_isosurface checks. Energies MUST run
|
|
1657
|
+
# first, or Isosurface reports "required data is missing" on a
|
|
1658
|
+
# result that has it. (Putting Isosurface first did exactly
|
|
1659
|
+
# that, 2026-08-04.)
|
|
1660
|
+
# 2. Selection: the FIRST entry with auto_select=True that returns
|
|
1661
|
+
# True becomes the default panel. Energies is False, so
|
|
1662
|
+
# Isosurface is the first candidate and opens by default —
|
|
1663
|
+
# which is the request — while Trajectory keeps True as the
|
|
1664
|
+
# fallback for results with no orbital data.
|
|
1632
1665
|
("Energies", "_pop_energies", False),
|
|
1633
|
-
("Isosurface", "_pop_isosurface",
|
|
1666
|
+
("Isosurface", "_pop_isosurface", True),
|
|
1667
|
+
("Trajectory", "_pop_geo_trajectory", True),
|
|
1634
1668
|
],
|
|
1635
1669
|
"frequency": [
|
|
1636
1670
|
("Vibrational", "_pop_vibrational", True),
|
|
@@ -1979,6 +2013,27 @@ class QuantUIApp:
|
|
|
1979
2013
|
)
|
|
1980
2014
|
# Cube + bundle exports
|
|
1981
2015
|
self._iso_export_cube_btn.on_click(self._on_iso_export_cube)
|
|
2016
|
+
self._iso_cancel_btn.on_click(self._safe_cb(self._on_iso_cancel))
|
|
2017
|
+
# PNG capture arrives from the browser, so there is no button to bind
|
|
2018
|
+
# here — the viewer's own Save-PNG button posts into this Textarea and
|
|
2019
|
+
# ipywidgets syncs it back, firing this observer (ORBX.1).
|
|
2020
|
+
self._orb_png_inbox.observe(
|
|
2021
|
+
self._safe_cb(self._on_orb_png_captured), names="value"
|
|
2022
|
+
)
|
|
2023
|
+
# Persist the grid choice so it survives a relaunch (ORBX.2).
|
|
2024
|
+
self._iso_resolution_dd.observe(
|
|
2025
|
+
self._safe_cb(self._on_iso_resolution_changed), names="value"
|
|
2026
|
+
)
|
|
2027
|
+
# Appearance controls redraw from the cube on disk — no cubegen — so
|
|
2028
|
+
# they can respond directly rather than behind an Apply button.
|
|
2029
|
+
for _w in (
|
|
2030
|
+
self._iso_isovalue_slider,
|
|
2031
|
+
self._iso_opacity_slider,
|
|
2032
|
+
self._iso_colors_dd,
|
|
2033
|
+
# NOT _iso_png_transparent: it is an export-only option, applied at
|
|
2034
|
+
# capture time. Observing it here would change the live viewer.
|
|
2035
|
+
):
|
|
2036
|
+
_w.observe(self._safe_cb(self._on_iso_appearance_changed), names="value")
|
|
1982
2037
|
self._export_bundle_btn.on_click(self._on_export_bundle)
|
|
1983
2038
|
|
|
1984
2039
|
# ── Files tab ────────────────────────────────────────────────────────
|
|
@@ -2746,8 +2801,18 @@ class QuantUIApp:
|
|
|
2746
2801
|
_last_pes = getattr(self, "_last_pes_result", None)
|
|
2747
2802
|
if _last_pes is not None:
|
|
2748
2803
|
self._show_pes_scan_result(_last_pes)
|
|
2749
|
-
# Re-render 3D molecule
|
|
2750
|
-
|
|
2804
|
+
# Re-render BOTH 3D molecule viewers so scene_bgcolor updates
|
|
2805
|
+
# immediately. This used to call _refresh_calc_mol_viewer directly,
|
|
2806
|
+
# which covered the Calculate tab only — the Analysis-tab viewer kept
|
|
2807
|
+
# its old background until something else happened to redraw it.
|
|
2808
|
+
# _rerender_3d_views already handled both; it just was not reached from
|
|
2809
|
+
# here. Reported 2026-08-04.
|
|
2810
|
+
self._rerender_3d_views()
|
|
2811
|
+
# ...and the isosurface / vibrational viewers, which bake the same
|
|
2812
|
+
# colour into their generated HTML. Without this they keep the old
|
|
2813
|
+
# background until the user regenerates — reported 2026-08-04. Both
|
|
2814
|
+
# re-render from cached inputs; neither re-runs a calculation.
|
|
2815
|
+
_viz_rerender_3d_scenes_for_theme(self)
|
|
2751
2816
|
|
|
2752
2817
|
def _initialize_viz_state_from_preference(self) -> None:
|
|
2753
2818
|
"""Align _viz_backend and the three preference widgets with the
|
|
@@ -3313,6 +3378,28 @@ class QuantUIApp:
|
|
|
3313
3378
|
def _on_export_pdb(self, btn) -> None:
|
|
3314
3379
|
_exp_on_export_pdb(self, btn)
|
|
3315
3380
|
|
|
3381
|
+
def _on_iso_cancel(self, btn) -> None:
|
|
3382
|
+
_viz_on_iso_cancel(self, btn)
|
|
3383
|
+
|
|
3384
|
+
def _on_iso_appearance_changed(self, change) -> None:
|
|
3385
|
+
_viz_on_iso_appearance_changed(self, change)
|
|
3386
|
+
|
|
3387
|
+
def _on_orb_png_captured(self, change) -> None:
|
|
3388
|
+
_exp_on_orb_png_captured(self, change)
|
|
3389
|
+
|
|
3390
|
+
def _on_iso_resolution_changed(self, change) -> None:
|
|
3391
|
+
"""Persist the isosurface grid choice.
|
|
3392
|
+
|
|
3393
|
+
Saved on change rather than at generate time so the preference sticks
|
|
3394
|
+
even if the user picks a grid and then closes the app without running
|
|
3395
|
+
anything.
|
|
3396
|
+
"""
|
|
3397
|
+
new_val = (change or {}).get("new")
|
|
3398
|
+
if not new_val or new_val == self._user_settings.viz.iso_resolution:
|
|
3399
|
+
return
|
|
3400
|
+
self._user_settings.viz.iso_resolution = str(new_val)
|
|
3401
|
+
self._user_settings.save()
|
|
3402
|
+
|
|
3316
3403
|
def _on_iso_export_cube(self, btn) -> None:
|
|
3317
3404
|
_exp_on_iso_export_cube(self, btn)
|
|
3318
3405
|
|
|
@@ -14,6 +14,20 @@ from quantui import molecule_library as _ml
|
|
|
14
14
|
from quantui import theme as _theme
|
|
15
15
|
from quantui.help_content import HELP_TOPICS
|
|
16
16
|
from quantui.live_log import LiveLog
|
|
17
|
+
from quantui.orbital_visualization import (
|
|
18
|
+
DEFAULT_ORBITAL_COLORS as _DEFAULT_ORBITAL_COLORS,
|
|
19
|
+
)
|
|
20
|
+
from quantui.orbital_visualization import (
|
|
21
|
+
ISO_RESOLUTION_OPTIONS as _ISO_RESOLUTION_OPTIONS,
|
|
22
|
+
)
|
|
23
|
+
from quantui.orbital_visualization import (
|
|
24
|
+
ORBITAL_COLOR_OPTIONS as _ORBITAL_COLOR_OPTIONS,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Class on the hidden Textarea that receives PNG data URIs from the
|
|
28
|
+
# viewer's Save-PNG button. Shared with the JS that writes into it, so
|
|
29
|
+
# it is defined once here and passed down rather than spelled twice.
|
|
30
|
+
_ORB_PNG_INBOX_CLASS = "quantui-orb-png-inbox"
|
|
17
31
|
|
|
18
32
|
# Friendlier labels for the library category filter.
|
|
19
33
|
_CATEGORY_LABELS = {
|
|
@@ -1541,6 +1555,20 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
1541
1555
|
)
|
|
1542
1556
|
|
|
1543
1557
|
|
|
1558
|
+
def _persisted_iso_resolution(app: Any) -> str:
|
|
1559
|
+
"""The saved isosurface grid preset, or the default if anything is off.
|
|
1560
|
+
|
|
1561
|
+
Read off the app rather than threaded in as a parameter: this builder takes
|
|
1562
|
+
no settings arguments, and adding one for a single control would mean
|
|
1563
|
+
touching every caller. A missing or invalid value must never stop the panel
|
|
1564
|
+
from building — a broken preference should cost the preference, not the UI.
|
|
1565
|
+
"""
|
|
1566
|
+
try:
|
|
1567
|
+
return str(app._user_settings.viz.iso_resolution)
|
|
1568
|
+
except Exception: # noqa: BLE001 — settings must never gate widget creation
|
|
1569
|
+
return "medium"
|
|
1570
|
+
|
|
1571
|
+
|
|
1544
1572
|
def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
1545
1573
|
"""Build results and analysis tab panels/widgets."""
|
|
1546
1574
|
|
|
@@ -1822,7 +1850,9 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
1822
1850
|
),
|
|
1823
1851
|
app._orb_toggle,
|
|
1824
1852
|
app._orb_index_input,
|
|
1825
|
-
|
|
1853
|
+
# The viewer is NOT here. It sits below the Generate button in
|
|
1854
|
+
# iso_body, so the button stays next to the orbital selector rather
|
|
1855
|
+
# than being pushed ~620px down the page by the rendered viewer.
|
|
1826
1856
|
],
|
|
1827
1857
|
layout=layout_fn(display="none", margin="8px 0 0 0"),
|
|
1828
1858
|
)
|
|
@@ -1853,6 +1883,17 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
1853
1883
|
# result dir under a friendly name (HOMO.cube / LUMO.cube / etc.).
|
|
1854
1884
|
# Disabled until the first isosurface generation populates
|
|
1855
1885
|
# ``app._last_cube_path``.
|
|
1886
|
+
# Cancel abandons an in-flight generation. cubegen itself cannot be
|
|
1887
|
+
# interrupted mid-call, so this bumps the render token — the worker's
|
|
1888
|
+
# result is discarded on arrival and the UI is freed immediately, which is
|
|
1889
|
+
# what "cancel" means to the person waiting.
|
|
1890
|
+
app._iso_cancel_btn = widgets.Button(
|
|
1891
|
+
description="Cancel",
|
|
1892
|
+
icon="times",
|
|
1893
|
+
button_style="warning",
|
|
1894
|
+
tooltip="Abandon this isosurface generation and restore the controls.",
|
|
1895
|
+
layout=layout_fn(width="110px", display="none", margin="8px 0 4px 0"),
|
|
1896
|
+
)
|
|
1856
1897
|
app._iso_export_cube_btn = widgets.Button(
|
|
1857
1898
|
description="Export cube",
|
|
1858
1899
|
icon="download",
|
|
@@ -1863,6 +1904,119 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
1863
1904
|
),
|
|
1864
1905
|
layout=layout_fn(width="160px", margin="8px 0 4px 8px"),
|
|
1865
1906
|
)
|
|
1907
|
+
# Cubegen grid (ORBX.2). Persisted, so someone who works at "fine" does not
|
|
1908
|
+
# re-pick it every launch. The labels carry the cost multiplier because the
|
|
1909
|
+
# jump is steep — 100³ is ~4.6× the grid work of the 60³ default — and the
|
|
1910
|
+
# wait is the thing users are choosing between.
|
|
1911
|
+
_iso_res_opts = list(_ISO_RESOLUTION_OPTIONS)
|
|
1912
|
+
app._iso_resolution_dd = widgets.Dropdown(
|
|
1913
|
+
options=_iso_res_opts,
|
|
1914
|
+
value=_persisted_iso_resolution(app),
|
|
1915
|
+
description="Grid:",
|
|
1916
|
+
style={"description_width": "45px"},
|
|
1917
|
+
tooltip=(
|
|
1918
|
+
"Cube grid density. Higher is smoother but slower to compute; "
|
|
1919
|
+
"cost scales with the cube of this number."
|
|
1920
|
+
),
|
|
1921
|
+
layout=layout_fn(width="290px", margin="8px 0 4px 0"),
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
# ── Isosurface appearance (ORBX.2 cont.) ────────────────────────────
|
|
1925
|
+
# Both re-render from the cube already on disk — no cubegen — so dragging
|
|
1926
|
+
# either is fast. That is the whole reason they can be sliders rather than
|
|
1927
|
+
# an "apply" button.
|
|
1928
|
+
app._iso_isovalue_slider = widgets.FloatLogSlider(
|
|
1929
|
+
value=0.02,
|
|
1930
|
+
base=10,
|
|
1931
|
+
min=-3.0, # 0.001
|
|
1932
|
+
max=-0.7, # ~0.2
|
|
1933
|
+
step=0.02,
|
|
1934
|
+
description="Isovalue:",
|
|
1935
|
+
readout_format=".4f",
|
|
1936
|
+
continuous_update=False, # re-render on release, not per pixel
|
|
1937
|
+
style={"description_width": "70px"},
|
|
1938
|
+
layout=layout_fn(width="330px"),
|
|
1939
|
+
)
|
|
1940
|
+
# An isovalue is an amplitude threshold, which is not the question people
|
|
1941
|
+
# actually have — "how much of the orbital is inside this surface?" is.
|
|
1942
|
+
# That is the integral of |psi|^2 enclosed, computed from the same cube.
|
|
1943
|
+
app._iso_enclosed_label = widgets.HTML(
|
|
1944
|
+
value="", layout=layout_fn(margin="0 0 0 6px")
|
|
1945
|
+
)
|
|
1946
|
+
app._iso_colors_dd = widgets.Dropdown(
|
|
1947
|
+
options=list(_ORBITAL_COLOR_OPTIONS),
|
|
1948
|
+
value=_DEFAULT_ORBITAL_COLORS,
|
|
1949
|
+
description="Colours:",
|
|
1950
|
+
style={"description_width": "70px"},
|
|
1951
|
+
layout=layout_fn(width="330px"),
|
|
1952
|
+
)
|
|
1953
|
+
app._iso_opacity_slider = widgets.FloatSlider(
|
|
1954
|
+
value=0.85,
|
|
1955
|
+
min=0.1,
|
|
1956
|
+
max=1.0,
|
|
1957
|
+
step=0.05,
|
|
1958
|
+
description="Opacity:",
|
|
1959
|
+
readout_format=".2f",
|
|
1960
|
+
continuous_update=False,
|
|
1961
|
+
style={"description_width": "70px"},
|
|
1962
|
+
layout=layout_fn(width="330px"),
|
|
1963
|
+
)
|
|
1964
|
+
|
|
1965
|
+
# ── PNG export options (ORBX.1 cont.) ───────────────────────────────
|
|
1966
|
+
app._iso_png_name = widgets.Text(
|
|
1967
|
+
value="",
|
|
1968
|
+
placeholder="(defaults to the orbital label, e.g. HOMO)",
|
|
1969
|
+
description="PNG name:",
|
|
1970
|
+
style={"description_width": "70px"},
|
|
1971
|
+
layout=layout_fn(width="330px"),
|
|
1972
|
+
)
|
|
1973
|
+
app._iso_png_transparent = widgets.Checkbox(
|
|
1974
|
+
value=False,
|
|
1975
|
+
description="Transparent background (export only)",
|
|
1976
|
+
indent=False,
|
|
1977
|
+
tooltip=(
|
|
1978
|
+
"The saved PNG has no background, so the figure drops onto a slide "
|
|
1979
|
+
"or a coloured page. The viewer on screen is unchanged — "
|
|
1980
|
+
"transparency is applied at capture, then undone."
|
|
1981
|
+
),
|
|
1982
|
+
layout=layout_fn(width="330px"),
|
|
1983
|
+
)
|
|
1984
|
+
# Read by the capture JS (see _PNG_CAPTURE_JS), which looks for
|
|
1985
|
+
# "<inbox-class>-transparent input". That keeps the decision on the browser
|
|
1986
|
+
# side at the moment of capture, with no kernel round-trip.
|
|
1987
|
+
app._iso_png_transparent.add_class(f"{_ORB_PNG_INBOX_CLASS}-transparent")
|
|
1988
|
+
# DPI is metadata (the PNG pHYs chunk): it sets the PRINT size, not the
|
|
1989
|
+
# pixel count. 300 dpi is the usual journal floor. Labelled with the
|
|
1990
|
+
# resulting print width so the number means something.
|
|
1991
|
+
app._iso_png_dpi = widgets.Dropdown(
|
|
1992
|
+
options=[
|
|
1993
|
+
("72 dpi — screen", 72),
|
|
1994
|
+
("150 dpi — draft print", 150),
|
|
1995
|
+
("300 dpi — journal standard", 300),
|
|
1996
|
+
("600 dpi — high-res print", 600),
|
|
1997
|
+
],
|
|
1998
|
+
value=300,
|
|
1999
|
+
description="PNG dpi:",
|
|
2000
|
+
style={"description_width": "70px"},
|
|
2001
|
+
layout=layout_fn(width="330px"),
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
# Hidden inbox for client-side PNG capture (ORBX.1). The JS in the rendered
|
|
2005
|
+
# isosurface writes a data URI into this Textarea's DOM node and dispatches
|
|
2006
|
+
# an 'input' event; ipywidgets' own view then syncs `value` to the kernel.
|
|
2007
|
+
# It is a real widget (not display:none on the outer box) because the view
|
|
2008
|
+
# must exist in the DOM for that sync to happen at all.
|
|
2009
|
+
app._orb_png_inbox = widgets.Textarea(
|
|
2010
|
+
value="", layout=layout_fn(width="1px", height="1px", visibility="hidden")
|
|
2011
|
+
)
|
|
2012
|
+
app._orb_png_inbox.add_class(_ORB_PNG_INBOX_CLASS)
|
|
2013
|
+
|
|
2014
|
+
# Hidden Output that carries one-shot Javascript to the live viewer
|
|
2015
|
+
# (isovalue / opacity / colours / background). Mirrors _vib_js_bridge.
|
|
2016
|
+
app._iso_js_bridge = widgets.Output(
|
|
2017
|
+
layout=layout_fn(width="0px", height="0px", visibility="hidden")
|
|
2018
|
+
)
|
|
2019
|
+
|
|
1866
2020
|
app._iso_export_status = widgets.HTML(
|
|
1867
2021
|
value="", layout=layout_fn(margin="0 0 0 8px")
|
|
1868
2022
|
)
|
|
@@ -1884,12 +2038,31 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
|
|
|
1884
2038
|
widgets.HBox(
|
|
1885
2039
|
[
|
|
1886
2040
|
app._iso_generate_btn,
|
|
2041
|
+
app._iso_cancel_btn,
|
|
1887
2042
|
app._iso_spinner,
|
|
1888
2043
|
app._iso_export_cube_btn,
|
|
1889
2044
|
app._iso_export_status,
|
|
1890
2045
|
],
|
|
1891
2046
|
layout=layout_fn(align_items="center", gap="6px"),
|
|
1892
2047
|
),
|
|
2048
|
+
app._orb_iso_output,
|
|
2049
|
+
app._iso_resolution_dd,
|
|
2050
|
+
widgets.HBox(
|
|
2051
|
+
[app._iso_isovalue_slider, app._iso_enclosed_label],
|
|
2052
|
+
layout=layout_fn(align_items="center"),
|
|
2053
|
+
),
|
|
2054
|
+
app._iso_opacity_slider,
|
|
2055
|
+
app._iso_colors_dd,
|
|
2056
|
+
widgets.HTML(
|
|
2057
|
+
'<p style="color:#555;font-size:12px;margin:8px 0 2px">'
|
|
2058
|
+
"<b>PNG export</b> — the Save PNG button under the viewer "
|
|
2059
|
+
"captures the view exactly as you have rotated it.</p>"
|
|
2060
|
+
),
|
|
2061
|
+
app._iso_png_name,
|
|
2062
|
+
app._iso_png_dpi,
|
|
2063
|
+
app._iso_png_transparent,
|
|
2064
|
+
app._orb_png_inbox,
|
|
2065
|
+
app._iso_js_bridge,
|
|
1893
2066
|
],
|
|
1894
2067
|
layout=layout_fn(padding="8px"),
|
|
1895
2068
|
)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -126,6 +127,129 @@ def export_molecule_and_label(app: Any) -> tuple[Any, str, str]:
|
|
|
126
127
|
return mol, method, basis
|
|
127
128
|
|
|
128
129
|
|
|
130
|
+
# Data URIs from 3Dmol's pngURI(). Anchored and length-bounded: this value
|
|
131
|
+
# arrives from the browser, and while it is the user's own page rather than an
|
|
132
|
+
# untrusted party, decoding whatever lands in a widget traitlet without
|
|
133
|
+
# checking its shape is not a habit worth having.
|
|
134
|
+
logger = logging.getLogger(__name__)
|
|
135
|
+
|
|
136
|
+
_PNG_URI_PREFIX = "data:image/png;base64,"
|
|
137
|
+
_MAX_PNG_BYTES = 64 * 1024 * 1024
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _requested_dpi(app: Any) -> int:
|
|
141
|
+
try:
|
|
142
|
+
return int(getattr(app._iso_png_dpi, "value", 300))
|
|
143
|
+
except Exception: # noqa: BLE001 — a missing widget must not stop a save
|
|
144
|
+
return 300
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _with_dpi(raw: bytes, dpi: int) -> bytes:
|
|
148
|
+
"""Stamp *dpi* into the PNG's pHYs chunk.
|
|
149
|
+
|
|
150
|
+
⚠️ This sets the PRINT size, not the pixel count. The capture is whatever
|
|
151
|
+
the canvas holds, and re-encoding cannot invent detail — at 300 dpi a
|
|
152
|
+
760 px image simply declares itself 2.5 inches wide. That is what makes a
|
|
153
|
+
figure land at the right physical size in Word or LaTeX instead of being
|
|
154
|
+
scaled by hand, which is the actual complaint DPI settings answer.
|
|
155
|
+
|
|
156
|
+
Pillow is already a dependency (via matplotlib). If anything goes wrong the
|
|
157
|
+
original bytes are returned: a PNG without the metadata is a mild loss, a
|
|
158
|
+
failed export is not.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
import io
|
|
162
|
+
|
|
163
|
+
from PIL import Image
|
|
164
|
+
|
|
165
|
+
with Image.open(io.BytesIO(raw)) as im:
|
|
166
|
+
im.load()
|
|
167
|
+
buf = io.BytesIO()
|
|
168
|
+
# RGBA is preserved, so a transparent capture stays transparent.
|
|
169
|
+
im.save(buf, format="PNG", dpi=(dpi, dpi))
|
|
170
|
+
return buf.getvalue()
|
|
171
|
+
except Exception as exc: # noqa: BLE001
|
|
172
|
+
logger.warning("could not stamp %d dpi into the PNG: %s", dpi, exc)
|
|
173
|
+
return raw
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def on_orb_png_captured(app: Any, change: dict) -> None:
|
|
177
|
+
"""Write a PNG captured from the live isosurface viewer (ORBX.1).
|
|
178
|
+
|
|
179
|
+
Fires when the viewer's Save-PNG button writes a data URI into the hidden
|
|
180
|
+
inbox Textarea. The image is whatever the user is actually looking at,
|
|
181
|
+
camera included — which is the entire reason capture happens client-side
|
|
182
|
+
rather than by re-rendering server-side.
|
|
183
|
+
|
|
184
|
+
The inbox is cleared afterwards so saving the same view twice re-triggers
|
|
185
|
+
the traitlet (an unchanged value would not fire ``observe``).
|
|
186
|
+
"""
|
|
187
|
+
import base64
|
|
188
|
+
import binascii
|
|
189
|
+
|
|
190
|
+
uri = (change or {}).get("new") or ""
|
|
191
|
+
if not uri:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
def _fail(msg: str) -> None:
|
|
195
|
+
app._iso_export_status.value = f'<span style="color:#b22">{msg}</span>'
|
|
196
|
+
# Clear even on failure, or a retry of the identical capture is silent.
|
|
197
|
+
_clear_inbox(app)
|
|
198
|
+
|
|
199
|
+
def _clear_inbox(a: Any) -> None:
|
|
200
|
+
box = getattr(a, "_orb_png_inbox", None)
|
|
201
|
+
if box is not None and box.value:
|
|
202
|
+
box.value = ""
|
|
203
|
+
|
|
204
|
+
if not uri.startswith(_PNG_URI_PREFIX):
|
|
205
|
+
logger.warning("PNG capture: unexpected data URI prefix")
|
|
206
|
+
_fail("Capture failed (unexpected image format).")
|
|
207
|
+
return
|
|
208
|
+
if len(uri) > _MAX_PNG_BYTES:
|
|
209
|
+
_fail("Capture failed (image too large).")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
result_dir = getattr(app, "_last_result_dir", None)
|
|
213
|
+
if result_dir is None or not isinstance(result_dir, Path):
|
|
214
|
+
_fail("No result folder available.")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
raw = base64.b64decode(uri[len(_PNG_URI_PREFIX) :], validate=True)
|
|
219
|
+
except (binascii.Error, ValueError) as exc:
|
|
220
|
+
logger.warning("PNG capture: could not decode payload: %s", exc)
|
|
221
|
+
_fail("Capture failed (corrupt image data).")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# Filename: the user's, falling back to the orbital label. Sanitised
|
|
225
|
+
# either way — this builds a filesystem path, and a name typed into a text
|
|
226
|
+
# box is exactly where a stray "../" or "/" arrives.
|
|
227
|
+
# str() rather than trusting the attribute: this runs against real widgets
|
|
228
|
+
# in the app but also against partially-built ones, and a non-string here
|
|
229
|
+
# would turn a successful capture into a traceback on the .strip() below.
|
|
230
|
+
typed = getattr(getattr(app, "_iso_png_name", None), "value", "")
|
|
231
|
+
typed = typed.strip() if isinstance(typed, str) else ""
|
|
232
|
+
label = typed or getattr(app, "_last_cube_orbital", None) or "orbital"
|
|
233
|
+
label = str(label)
|
|
234
|
+
if label.lower().endswith(".png"):
|
|
235
|
+
label = label[:-4]
|
|
236
|
+
safe = "".join(c if c.isalnum() or c in "-_+ " else "_" for c in str(label))
|
|
237
|
+
safe = safe.strip() or "orbital"
|
|
238
|
+
dest = Path(result_dir) / f"{safe}.png"
|
|
239
|
+
|
|
240
|
+
raw = _with_dpi(raw, _requested_dpi(app))
|
|
241
|
+
try:
|
|
242
|
+
dest.write_bytes(raw)
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
logger.warning("PNG capture: could not write %s: %s", dest, exc)
|
|
245
|
+
_fail("Could not write the image (see log).")
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
logger.info("Saved orbital PNG: %s (%d bytes)", dest, len(raw))
|
|
249
|
+
app._iso_export_status.value = f'<span style="color:#2a7">Saved: {dest.name}</span>'
|
|
250
|
+
_clear_inbox(app)
|
|
251
|
+
|
|
252
|
+
|
|
129
253
|
def on_iso_export_cube(app: Any, btn: Any) -> None:
|
|
130
254
|
"""Copy the last-generated cube file to the result folder.
|
|
131
255
|
|