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.
Files changed (71) hide show
  1. {quantui-0.5.2 → quantui-0.6.0}/CHANGELOG.md +57 -1
  2. {quantui-0.5.2/quantui.egg-info → quantui-0.6.0}/PKG-INFO +1 -1
  3. {quantui-0.5.2 → quantui-0.6.0}/pyproject.toml +1 -1
  4. {quantui-0.5.2 → quantui-0.6.0}/quantui/__init__.py +1 -1
  5. {quantui-0.5.2 → quantui-0.6.0}/quantui/app.py +91 -4
  6. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_builders.py +174 -1
  7. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_exports.py +124 -0
  8. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_visualization.py +385 -106
  9. {quantui-0.5.2 → quantui-0.6.0}/quantui/orbital_visualization.py +456 -47
  10. {quantui-0.5.2 → quantui-0.6.0}/quantui/user_settings.py +20 -0
  11. {quantui-0.5.2 → quantui-0.6.0}/quantui/viz_assets.py +21 -6
  12. {quantui-0.5.2 → quantui-0.6.0}/quantui/viz_backend_router.py +34 -8
  13. {quantui-0.5.2 → quantui-0.6.0/quantui.egg-info}/PKG-INFO +1 -1
  14. {quantui-0.5.2 → quantui-0.6.0}/LICENSE +0 -0
  15. {quantui-0.5.2 → quantui-0.6.0}/MANIFEST.in +0 -0
  16. {quantui-0.5.2 → quantui-0.6.0}/README.md +0 -0
  17. {quantui-0.5.2 → quantui-0.6.0}/SECURITY.md +0 -0
  18. {quantui-0.5.2 → quantui-0.6.0}/quantui/analytics.py +0 -0
  19. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_analysis.py +0 -0
  20. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_formatters.py +0 -0
  21. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_history.py +0 -0
  22. {quantui-0.5.2 → quantui-0.6.0}/quantui/app_runflow.py +0 -0
  23. {quantui-0.5.2 → quantui-0.6.0}/quantui/ase_bridge.py +0 -0
  24. {quantui-0.5.2 → quantui-0.6.0}/quantui/benchmarks.py +0 -0
  25. {quantui-0.5.2 → quantui-0.6.0}/quantui/c_stderr.py +0 -0
  26. {quantui-0.5.2 → quantui-0.6.0}/quantui/cactus.py +0 -0
  27. {quantui-0.5.2 → quantui-0.6.0}/quantui/calc_log.py +0 -0
  28. {quantui-0.5.2 → quantui-0.6.0}/quantui/calculator.py +0 -0
  29. {quantui-0.5.2 → quantui-0.6.0}/quantui/cancellation.py +0 -0
  30. {quantui-0.5.2 → quantui-0.6.0}/quantui/cli.py +0 -0
  31. {quantui-0.5.2 → quantui-0.6.0}/quantui/comparison.py +0 -0
  32. {quantui-0.5.2 → quantui-0.6.0}/quantui/config.py +0 -0
  33. {quantui-0.5.2 → quantui-0.6.0}/quantui/data/js/3Dmol-min.js +0 -0
  34. {quantui-0.5.2 → quantui-0.6.0}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
  35. {quantui-0.5.2 → quantui-0.6.0}/quantui/data/library/library.sqlite +0 -0
  36. {quantui-0.5.2 → quantui-0.6.0}/quantui/data/manifests/bulk_qm9.json +0 -0
  37. {quantui-0.5.2 → quantui-0.6.0}/quantui/data/manifests/curated.json +0 -0
  38. {quantui-0.5.2 → quantui-0.6.0}/quantui/data/manifests/presets.json +0 -0
  39. {quantui-0.5.2 → quantui-0.6.0}/quantui/descriptor_cards.py +0 -0
  40. {quantui-0.5.2 → quantui-0.6.0}/quantui/freq_calc.py +0 -0
  41. {quantui-0.5.2 → quantui-0.6.0}/quantui/freq_ir_workers.py +0 -0
  42. {quantui-0.5.2 → quantui-0.6.0}/quantui/gpu_offload.py +0 -0
  43. {quantui-0.5.2 → quantui-0.6.0}/quantui/help_content.py +0 -0
  44. {quantui-0.5.2 → quantui-0.6.0}/quantui/ir_plot.py +0 -0
  45. {quantui-0.5.2 → quantui-0.6.0}/quantui/issue_tracker.py +0 -0
  46. {quantui-0.5.2 → quantui-0.6.0}/quantui/live_log.py +0 -0
  47. {quantui-0.5.2 → quantui-0.6.0}/quantui/log_utils.py +0 -0
  48. {quantui-0.5.2 → quantui-0.6.0}/quantui/molecule.py +0 -0
  49. {quantui-0.5.2 → quantui-0.6.0}/quantui/molecule_library.py +0 -0
  50. {quantui-0.5.2 → quantui-0.6.0}/quantui/nmr_calc.py +0 -0
  51. {quantui-0.5.2 → quantui-0.6.0}/quantui/optimizer.py +0 -0
  52. {quantui-0.5.2 → quantui-0.6.0}/quantui/pes_scan.py +0 -0
  53. {quantui-0.5.2 → quantui-0.6.0}/quantui/preopt.py +0 -0
  54. {quantui-0.5.2 → quantui-0.6.0}/quantui/progress.py +0 -0
  55. {quantui-0.5.2 → quantui-0.6.0}/quantui/pubchem.py +0 -0
  56. {quantui-0.5.2 → quantui-0.6.0}/quantui/reorganization_energy.py +0 -0
  57. {quantui-0.5.2 → quantui-0.6.0}/quantui/results_storage.py +0 -0
  58. {quantui-0.5.2 → quantui-0.6.0}/quantui/security.py +0 -0
  59. {quantui-0.5.2 → quantui-0.6.0}/quantui/session_calc.py +0 -0
  60. {quantui-0.5.2 → quantui-0.6.0}/quantui/structure_providers.py +0 -0
  61. {quantui-0.5.2 → quantui-0.6.0}/quantui/tddft_calc.py +0 -0
  62. {quantui-0.5.2 → quantui-0.6.0}/quantui/theme.py +0 -0
  63. {quantui-0.5.2 → quantui-0.6.0}/quantui/utils.py +0 -0
  64. {quantui-0.5.2 → quantui-0.6.0}/quantui/vib_cache.py +0 -0
  65. {quantui-0.5.2 → quantui-0.6.0}/quantui/visualization_py3dmol.py +0 -0
  66. {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/SOURCES.txt +0 -0
  67. {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/dependency_links.txt +0 -0
  68. {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/entry_points.txt +0 -0
  69. {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/requires.txt +0 -0
  70. {quantui-0.5.2 → quantui-0.6.0}/quantui.egg-info/top_level.txt +0 -0
  71. {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.5.2...HEAD
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantui
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: An open-source frontend for DFT and post-HF quantum chemistry with PySCF
5
5
  Author-email: Jonathan Schultz <nccu-schultz-lab@users.noreply.github.com>
6
6
  License: MIT License
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
 
9
9
  [project]
10
10
  name = "quantui"
11
- version = "0.5.2"
11
+ version = "0.6.0"
12
12
  description = "An open-source frontend for DFT and post-HF quantum chemistry with PySCF"
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.9"
@@ -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.5.2"
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
- ("Trajectory", "_pop_geo_trajectory", True),
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", False),
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 viewer so scene_bgcolor updates immediately.
2750
- self._refresh_calc_mol_viewer()
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
- app._orb_iso_output,
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