From 6c0f05657779fdb32c4416a34ad737a50ae3e7c3 Mon Sep 17 00:00:00 2001 From: NCCU Schultz Lab Date: Sat, 22 Aug 2026 02:25:38 +0000 Subject: [PATCH 1/2] Stamp export provenance into cube, PNG, and XYZ files (EXP2.4, ORBX.4) Cube files now carry a resolution label and calc method in their two free-text comment lines (post-written after cubegen, since PySCF's cubegen.orbital() has no comment parameter). Captured orbital/reorg PNGs gain method/basis/resolution as PNG tEXt metadata via Pillow, since PNGs have no comment-line equivalent to piggyback on. Exported XYZ files gain charge/multiplicity in their comment line alongside the existing formula/method/basis. Claude (Sonnet 5) Co-authored-by: Claude --- quantui/app_exports.py | 74 ++++++++++-- quantui/app_visualization.py | 8 ++ quantui/orbital_visualization.py | 87 +++++++++++++++ tests/test_app.py | 19 ++++ tests/test_export2_reorg_and_destination.py | 12 ++ tests/test_orbital_export_and_resolution.py | 18 +++ tests/test_orbital_visualization.py | 118 ++++++++++++++++++++ 7 files changed, 327 insertions(+), 9 deletions(-) diff --git a/quantui/app_exports.py b/quantui/app_exports.py index 8d9826f..0f94d15 100644 --- a/quantui/app_exports.py +++ b/quantui/app_exports.py @@ -4,7 +4,7 @@ import logging from pathlib import Path -from typing import Any, cast +from typing import Any, Optional, cast from .results_storage import _safe_name @@ -94,7 +94,15 @@ def on_export(app: Any, btn: Any) -> None: def on_export_xyz(app: Any, btn: Any) -> None: - """Export molecule geometry to an XYZ file.""" + """Export molecule geometry to an XYZ file. + + Provenance (M-EXPORT2 EXP2.4): the comment line carries charge and + multiplicity alongside method/basis, matching + :func:`on_export_reorg_geometries`'s format — before this fix the two + XYZ exporters disagreed (reorg had charge/multiplicity, this one didn't), + and charge/multiplicity is exactly the kind of thing unrecoverable from a + bare geometry once it's been handed off. + """ if app._molecule is None: app.struct_export_status.value = "Load a molecule first." return @@ -102,9 +110,11 @@ def on_export_xyz(app: Any, btn: Any) -> None: mol, method, basis = export_molecule_and_label(app) fname = f"{_safe_name(mol.get_formula())}_{_safe_name(method)}_{_safe_name(basis)}.xyz" xyz_body = mol.to_xyz_string() - full_xyz = ( - f"{len(mol.atoms)}\n{mol.get_formula()} {method}/{basis}\n{xyz_body}\n" + comment = ( + f"{mol.get_formula()} charge={mol.charge} multiplicity={mol.multiplicity} " + f"{method}/{basis}" ) + full_xyz = f"{len(mol.atoms)}\n{comment}\n{xyz_body}\n" dest = (app._last_result_dir / fname) if app._last_result_dir else Path(fname) dest.write_text(full_xyz, encoding="utf-8") app.struct_export_status.value = f"Saved: {dest}" @@ -267,8 +277,15 @@ def _requested_dpi(app: Any) -> int: return 300 -def _with_dpi(raw: bytes, dpi: int) -> bytes: - """Stamp *dpi* into the PNG's pHYs chunk. +def _with_dpi( + raw: bytes, dpi: Optional[int], *, metadata: Optional[dict[str, str]] = None +) -> bytes: + """Stamp *dpi* into the PNG's pHYs chunk, and optionally provenance + (M-EXPORT2 EXP2.4) into ``tEXt`` chunks — one re-encode for both, since + Pillow does both in the same ``save()`` call. *dpi* of ``None`` skips the + pHYs stamp and writes metadata only — for exporters that intentionally + don't offer a DPI setting (e.g. the reorg-geometry PNG, which deliberately + doesn't inherit the isosurface panel's DPI control). ⚠️ This sets the PRINT size, not the pixel count. The capture is whatever the canvas holds, and re-encoding cannot invent detail — at 300 dpi a @@ -276,6 +293,13 @@ def _with_dpi(raw: bytes, dpi: int) -> bytes: figure land at the right physical size in Word or LaTeX instead of being scaled by hand, which is the actual complaint DPI settings answer. + *metadata*, if given, becomes one ``tEXt`` chunk per key — method, basis, + grid resolution, whatever the caller has. A PNG has no comment-line + equivalent to an XYZ or cube file, so this is the export's only chance to + carry that context; without it a figure someone emailed you a year later + is orphaned from what produced it, same argument as ORBX.4 for cubes. + Read back with ``PIL.Image.open(path).text``. + Pillow is already a dependency (via matplotlib). If anything goes wrong the original bytes are returned: a PNG without the metadata is a mild loss, a failed export is not. @@ -284,15 +308,25 @@ def _with_dpi(raw: bytes, dpi: int) -> bytes: import io from PIL import Image + from PIL.PngImagePlugin import PngInfo with Image.open(io.BytesIO(raw)) as im: im.load() buf = io.BytesIO() + pnginfo = None + if metadata: + pnginfo = PngInfo() + for key, value in metadata.items(): + if value: + pnginfo.add_text(key, str(value)) + save_kwargs: dict[str, Any] = {"format": "PNG", "pnginfo": pnginfo} + if dpi is not None: + save_kwargs["dpi"] = (dpi, dpi) # RGBA is preserved, so a transparent capture stays transparent. - im.save(buf, format="PNG", dpi=(dpi, dpi)) + im.save(buf, **save_kwargs) return buf.getvalue() except Exception as exc: # noqa: BLE001 - logger.warning("could not stamp %d dpi into the PNG: %s", dpi, exc) + logger.warning("could not finalize PNG (dpi=%s): %s", dpi, exc) return raw @@ -360,7 +394,22 @@ def _clear_inbox(a: Any) -> None: safe = safe.strip() or "orbital" dest = Path(result_dir) / f"{safe}.png" - raw = _with_dpi(raw, _requested_dpi(app)) + # Provenance (M-EXPORT2 EXP2.4 / M-ORBEXPORT ORBX.4): best-effort from + # the live UI state, same caveat as the cube path — not re-verified + # against what actually produced the captured image (e.g. after a + # History replay). The resolution key is read directly from the dropdown + # here rather than reverse-mapped from a grid size, since this call site + # has the preset name itself. + metadata = { + "Software": "QuantUI", + "Orbital": label, + "Method": str(getattr(getattr(app, "method_dd", None), "value", "") or ""), + "Basis": str(getattr(getattr(app, "basis_dd", None), "value", "") or ""), + "Isosurface resolution": str( + getattr(getattr(app, "_iso_resolution_dd", None), "value", "") or "" + ), + } + raw = _with_dpi(raw, _requested_dpi(app), metadata=metadata) try: dest.write_bytes(raw) except OSError as exc: @@ -434,6 +483,13 @@ def _clear_inbox(a: Any) -> None: _fail(str(exc)) return + # Provenance (M-EXPORT2 EXP2.4) — metadata only, no DPI stamp (see + # _with_dpi's docstring for why this exporter skips DPI intentionally). + raw = _with_dpi( + raw, + None, + metadata={"Software": "QuantUI", "Method": method, "Basis": basis}, + ) try: dest.write_bytes(raw) except OSError as exc: diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 78aa914..88443fb 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -1304,6 +1304,13 @@ def _show_range_err() -> None: _grid = ISO_RESOLUTION_PRESETS.get( _res_key, ISO_RESOLUTION_PRESETS[DEFAULT_ISO_RESOLUTION] ) + # M-EXPORT2 EXP2.4 / M-ORBEXPORT ORBX.4: best-effort provenance, not a + # re-verified guarantee — the live method dropdown, not necessarily + # what actually produced the stored mo_coeff (e.g. after a History + # replay of a differently-computed result). + _method_for_provenance = str( + getattr(getattr(app, "method_dd", None), "value", "") or "" + ) generate_cube_from_arrays( mol_atom, mol_basis, @@ -1315,6 +1322,7 @@ def _show_range_err() -> None: nz=_grid, charge=_charge, spin=_spin, + method=_method_for_provenance, ) scene_bgcolor = app._plotly_theme_colors()["scene_bgcolor"] diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 4487289..124eb1c 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -577,6 +577,61 @@ def infer_charge_and_spin( return charge, spin +def _resolution_label(nx: int, ny: int, nz: int) -> str: + """Named preset (M-ORBEXPORT ORBX.2) matching *nx/ny/nz*, or ``"custom"``. + + Only a cubic grid matching one of :data:`ISO_RESOLUTION_PRESETS` gets its + friendly name back; anything else (a non-cubic grid, or a value nobody + picked from the dropdown) is reported honestly as custom rather than + guessed at. + """ + if nx == ny == nz: + for label, grid in ISO_RESOLUTION_PRESETS.items(): + if grid == nx: + return label + return "custom" + + +def _write_cube_provenance( + output_path: Path, + *, + basis: str, + nx: int, + ny: int, + nz: int, + charge: int, + spin: int, + method: str = "", +) -> None: + """Overwrite a freshly written cube file's two free-text comment lines + with QuantUI provenance (M-EXPORT2 EXP2.4 / M-ORBEXPORT ORBX.4). + + The Gaussian cube format reserves exactly its first two lines for + human-readable comments — everything from line 3 on (atom count, grid + header, atoms, volumetric data) is untouched, so this is safe to do + *after* :func:`pyscf.tools.cubegen.orbital` writes the file rather than + reimplementing its grid-computation logic just to pass a custom comment + through. Without this, every cube QuantUI writes carries cubegen's own + fixed comment ("Orbital value in real space (1/Bohr^3)") and nothing + about which basis, grid, or charge/spin state produced it — unrecoverable + once the file has been handed to Avogadro / VMD / Multiwfn or emailed on. + """ + line1 = ( + f"QuantUI orbital cube — {method}/{basis}" + if method + else f"QuantUI orbital cube — basis {basis}" + ) + label = _resolution_label(nx, ny, nz) + line2 = f"grid {nx}x{ny}x{nz} ({label}); charge={charge} spin={spin}" + text = output_path.read_text(encoding="utf-8") + lines = text.split("\n") + if len(lines) < 2: + return # not a well-formed cube file; leave it alone rather than corrupt it + lines[0] = line1 + lines[1] = line2 + output_path.write_text("\n".join(lines), encoding="utf-8") + + def generate_cube_file( results_path: Path, orbital_index: int, @@ -586,6 +641,7 @@ def generate_cube_file( ny: int = 60, nz: int = 60, margin: float = 5.0, + method: str = "", ) -> Path: """ Generate a Gaussian cube file for a molecular orbital. @@ -606,6 +662,10 @@ def generate_cube_file( Grid resolution along each axis. margin : float Extra space (Bohr) beyond atomic extents. + method : str + Method/functional label for the provenance comment (M-EXPORT2 + EXP2.4). ``results.npz`` doesn't carry it, so this has to come from + the caller; omitted from the comment when blank. Returns ------- @@ -670,6 +730,16 @@ def generate_cube_file( nz=nz, margin=margin, ) + _write_cube_provenance( + output_path, + basis=basis_str, + nx=nx, + ny=ny, + nz=nz, + charge=charge, + spin=spin, + method=method, + ) logger.info("Wrote cube file: %s", output_path) return output_path @@ -687,6 +757,7 @@ def generate_cube_from_arrays( margin: float = 5.0, charge: int = 0, spin: int = 0, + method: str = "", ) -> Path: """ Generate a cube file from in-session MO data (no ``.npz`` file required). @@ -720,6 +791,12 @@ def generate_cube_from_arrays( spin : int PySCF's ``2S = n_alpha - n_beta``. Required for open-shell (odd-electron) molecules — default 0 assumes closed-shell. + method : str + Method/functional label for the provenance comment (M-EXPORT2 + EXP2.4) — e.g. ``'B3LYP'``. Best-effort: the caller's current method + selection, not necessarily re-verified against what actually produced + *mo_coeff* (this function has no way to check that). Omitted from the + comment entirely when blank, rather than guessed at. Returns ------- @@ -760,6 +837,16 @@ def generate_cube_from_arrays( nz=nz, margin=margin, ) + _write_cube_provenance( + output_path, + basis=mol_basis, + nx=nx, + ny=ny, + nz=nz, + charge=charge, + spin=spin, + method=method, + ) logger.info("Wrote cube file: %s", output_path) return output_path diff --git a/tests/test_app.py b/tests/test_app.py index dbc9a6f..3fbf828 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -666,6 +666,25 @@ def test_xyz_no_molecule_shows_error(self): app._on_export_xyz(None) assert "molecule" in app.struct_export_status.value.lower() + def test_xyz_comment_line_carries_charge_and_multiplicity(self, tmp_path): + # M-EXPORT2 EXP2.4: was method/basis only; charge/multiplicity is + # exactly the kind of thing unrecoverable from a bare geometry once + # handed off, and the reorg-geometry exporter already included it — + # this closes that inconsistency. + app = QuantUIApp() + mol = _water() + mol.charge = 1 + mol.multiplicity = 2 + app._set_molecule(mol) + app._last_result_dir = tmp_path + + app._on_export_xyz(None) + + content = list(tmp_path.glob("*.xyz"))[0].read_text() + comment_line = content.splitlines()[1] + assert "charge=1" in comment_line + assert "multiplicity=2" in comment_line + def test_xyz_filename_sanitizes_basis_with_asterisk(self, tmp_path): """M11 audit fix (2026-07-14): a basis like "6-31G*" embedded verbatim in a filename is invalid on Windows ("*" is a reserved diff --git a/tests/test_export2_reorg_and_destination.py b/tests/test_export2_reorg_and_destination.py index a3ab021..0304bed 100644 --- a/tests/test_export2_reorg_and_destination.py +++ b/tests/test_export2_reorg_and_destination.py @@ -161,6 +161,18 @@ def test_a_capture_lands_on_disk(self, tmp_path): assert len(written) == 1 assert "Saved" in app._reorg_export_status.value + def test_the_saved_png_carries_method_and_basis_metadata(self, tmp_path): + # M-EXPORT2 EXP2.4: metadata only, no DPI stamp — this exporter + # deliberately has no DPI control of its own (see _with_dpi). + from PIL import Image + + app = self._app(tmp_path) + on_reorg_png_captured(app, {"new": _png_uri()}) + written = list(tmp_path.glob("*.png"))[0] + with Image.open(written) as im: + assert im.text["Method"] == "B3LYP" + assert im.text["Basis"] == "6-31G*" + def test_inbox_is_cleared_after_a_capture(self, tmp_path): app = self._app(tmp_path) on_reorg_png_captured(app, {"new": _png_uri()}) diff --git a/tests/test_orbital_export_and_resolution.py b/tests/test_orbital_export_and_resolution.py index 4a4e9d3..06d32cd 100644 --- a/tests/test_orbital_export_and_resolution.py +++ b/tests/test_orbital_export_and_resolution.py @@ -279,6 +279,24 @@ def test_the_filename_follows_the_orbital_label(self, tmp_path): on_orb_png_captured(app, {"new": self._uri()}) assert (tmp_path / "LUMO+1.png").exists() + def test_the_saved_png_carries_provenance_metadata(self, tmp_path): + # M-EXPORT2 EXP2.4 / M-ORBEXPORT ORBX.4: a PNG has no comment line + # like an XYZ or cube file, so tEXt chunks are its only chance to + # carry method/basis/resolution — otherwise unrecoverable from the + # file itself once it's been handed off. + from PIL import Image + + app = self._app(tmp_path) + app.method_dd = Mock(value="B3LYP") + app.basis_dd = Mock(value="6-31G*") + app._iso_resolution_dd = Mock(value="fine") + on_orb_png_captured(app, {"new": self._uri()}) + with Image.open(tmp_path / "HOMO.png") as im: + assert im.text["Method"] == "B3LYP" + assert im.text["Basis"] == "6-31G*" + assert im.text["Isosurface resolution"] == "fine" + assert im.text["Orbital"] == "HOMO" + def test_a_hostile_label_cannot_escape_the_result_directory(self, tmp_path): # The label reaches here from app state, but it feeds a filesystem path # and sanitising it costs nothing. diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py index 2d794a7..5bf7f17 100644 --- a/tests/test_orbital_visualization.py +++ b/tests/test_orbital_visualization.py @@ -503,5 +503,123 @@ def test_uhf_mo_coeff_uses_alpha_spin(self, tmp_path): assert result.exists() +# --------------------------------------------------------------------------- +# Cube provenance — M-EXPORT2 EXP2.4 / M-ORBEXPORT ORBX.4 +# --------------------------------------------------------------------------- + + +class TestResolutionLabel: + def test_matches_a_known_preset(self): + from quantui.orbital_visualization import _resolution_label + + assert _resolution_label(40, 40, 40) == "coarse" + assert _resolution_label(60, 60, 60) == "medium" + assert _resolution_label(80, 80, 80) == "fine" + assert _resolution_label(100, 100, 100) == "very fine" + + def test_non_cubic_grid_is_custom(self): + from quantui.orbital_visualization import _resolution_label + + assert _resolution_label(60, 60, 80) == "custom" + + def test_cubic_but_off_preset_grid_is_custom(self): + # Never guesses a nearby preset name for a value nobody picked from + # the dropdown — honesty over a plausible-looking guess. + from quantui.orbital_visualization import _resolution_label + + assert _resolution_label(55, 55, 55) == "custom" + + +class TestWriteCubeProvenance: + def test_overwrites_only_the_first_two_lines(self, tmp_path): + from quantui.orbital_visualization import _write_cube_provenance + + cube = tmp_path / "x.cube" + cube.write_text( + "Orbital value in real space (1/Bohr^3)\n" + "MO coefficients\n" + "2 0.0 0.0 0.0\n" + "line4\nline5\n", + encoding="utf-8", + ) + _write_cube_provenance( + cube, basis="STO-3G", nx=60, ny=60, nz=60, charge=0, spin=0, method="RHF" + ) + lines = cube.read_text(encoding="utf-8").split("\n") + assert lines[0] == "QuantUI orbital cube — RHF/STO-3G" + assert lines[2] == "2 0.0 0.0 0.0" + assert lines[3] == "line4" + assert lines[4] == "line5" + + def test_method_omitted_when_blank(self, tmp_path): + from quantui.orbital_visualization import _write_cube_provenance + + cube = tmp_path / "x.cube" + cube.write_text("a\nb\nc\n", encoding="utf-8") + _write_cube_provenance( + cube, basis="6-31G*", nx=60, ny=60, nz=60, charge=0, spin=0 + ) + line1 = cube.read_text(encoding="utf-8").split("\n")[0] + assert line1 == "QuantUI orbital cube — basis 6-31G*" + assert "/" not in line1.split("basis ")[1] # no dangling "method/" prefix + + def test_records_grid_preset_and_charge_spin(self, tmp_path): + from quantui.orbital_visualization import _write_cube_provenance + + cube = tmp_path / "x.cube" + cube.write_text("a\nb\nc\n", encoding="utf-8") + _write_cube_provenance( + cube, basis="def2-SVP", nx=80, ny=80, nz=80, charge=1, spin=1 + ) + line2 = cube.read_text(encoding="utf-8").split("\n")[1] + assert line2 == "grid 80x80x80 (fine); charge=1 spin=1" + + def test_leaves_a_malformed_file_alone(self, tmp_path): + # A cube file always has at least 2 header lines; a file that + # somehow doesn't must not be corrupted further by this helper. + from quantui.orbital_visualization import _write_cube_provenance + + cube = tmp_path / "x.cube" + cube.write_text("only one line", encoding="utf-8") + _write_cube_provenance( + cube, basis="STO-3G", nx=60, ny=60, nz=60, charge=0, spin=0 + ) + assert cube.read_text(encoding="utf-8") == "only one line" + + +class TestGenerateCubeFromArraysProvenance: + @_pyscf_only + def test_generated_cube_carries_provenance_and_still_parses(self, tmp_path): + from pyscf import gto, scf + + from quantui.orbital_visualization import ( + generate_cube_from_arrays, + parse_cube_file, + ) + + mol = gto.M(atom="H 0 0 0; H 0 0 1.4", basis="sto-3g", verbose=0) + mf = scf.RHF(mol) + mf.verbose = 0 + mf.kernel() + out_path = tmp_path / "homo.cube" + generate_cube_from_arrays( + mol_atom=[["H", [0.0, 0.0, 0.0]], ["H", [0.0, 0.0, 0.74]]], + mol_basis="sto-3g", + mo_coeff=mf.mo_coeff, + orbital_index=int(np.where(mf.mo_occ > 0)[0][-1]), + output_path=out_path, + nx=10, + ny=10, + nz=10, + method="RHF", + ) + lines = out_path.read_text(encoding="utf-8").split("\n") + assert lines[0] == "QuantUI orbital cube — RHF/sto-3g" + assert "grid 10x10x10" in lines[1] + # Rewriting the header must not disturb the parseable body. + cube = parse_cube_file(out_path) + assert cube["data"].shape == (10, 10, 10) + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) From b23243a7cdc0cfc89c9aa1fa10477654660e2005 Mon Sep 17 00:00:00 2001 From: NCCU Schultz Lab Date: Sat, 22 Aug 2026 02:25:49 +0000 Subject: [PATCH 2/2] Add wireframe surface finish toggle for orbital isosurfaces (ORBX.7) The vendored 3Dmol.js build only supports Lambert shading with no specular/metallic parameters, so a "metallic finish" option (as originally scoped) is unreachable. Substitute a wireframe toggle, which 3Dmol's addVolumetricData already supports via the wireframe style key, wired through the same iso_bridge_update live-push path used by opacity and isovalue changes. Claude (Sonnet 5) Co-authored-by: Claude --- quantui/app.py | 2 + quantui/app_builders.py | 15 ++++++ quantui/app_visualization.py | 2 + quantui/orbital_visualization.py | 24 ++++++++-- tests/test_orbital_export_and_resolution.py | 52 ++++++++++++++++++++- 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index 75747c0..e7240a2 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1194,6 +1194,7 @@ class QuantUIApp: _iso_export_cube_btn: Any _iso_isovalue_slider: Any _iso_opacity_slider: Any + _iso_wireframe_cb: Any _iso_resolution_dd: Any _last_result_dir: Any _nmr_accordion: Any @@ -2244,6 +2245,7 @@ def _wire_callbacks(self) -> None: for _w in ( self._iso_isovalue_slider, self._iso_opacity_slider, + self._iso_wireframe_cb, self._iso_colors_dd, # NOT _iso_png_transparent: it is an export-only option, applied at # capture time. Observing it here would change the live viewer. diff --git a/quantui/app_builders.py b/quantui/app_builders.py index f7ad668..0f41495 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -2093,6 +2093,20 @@ def _plot_export_row(prefix: str) -> widgets.HBox: style={"description_width": "70px"}, layout=layout_fn(width="330px"), ) + # M-ORBEXPORT ORBX.7 — surface finish. Re-scoped from the original + # "metallic" request: the vendored 3Dmol.js is Lambert-only (no specular + # term), so glossy/metallic isn't reachable on this backend; wireframe is + # what the renderer can actually do. + app._iso_wireframe_cb = widgets.Checkbox( + value=False, + description="Wireframe", + indent=False, + tooltip=( + "Show the isosurface mesh instead of a solid surface — useful for " + "seeing the atoms through a lobe, or for a slide." + ), + layout=layout_fn(width="330px"), + ) # ── PNG export options (ORBX.1 cont.) ─────────────────────────────── app._iso_png_name = widgets.Text( @@ -2184,6 +2198,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: layout=layout_fn(align_items="center"), ), app._iso_opacity_slider, + app._iso_wireframe_cb, app._iso_colors_dd, widgets.HTML( f'

' diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 88443fb..8cdef83 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -1961,6 +1961,7 @@ def _val(name: str, default): return { "isovalue": _val("_iso_isovalue_slider", 0.02), "opacity": _val("_iso_opacity_slider", 0.85), + "wireframe": _val("_iso_wireframe_cb", False), "color_scheme": _val("_iso_colors_dd", "blue-red"), "bgcolor": app._plotly_theme_colors()["scene_bgcolor"], "capture_class": ( @@ -2127,6 +2128,7 @@ def on_iso_appearance_changed(app: Any, change: dict | None = None) -> None: app, iso=opts["isovalue"], op=opts["opacity"], + wf=opts["wireframe"], pos=pos, neg=neg, ) diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 124eb1c..3da91a8 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -1090,7 +1090,7 @@ def orbital_colors(scheme: str) -> tuple[str, str]: (function(){ var UID="__UID__", DATA=__DATA__, FMT=__FMT__; var WITH_SURFACES=__WITH_SURFACES__, SCENE=__SCENE__; - var state={iso:__ISO__, op:__OP__, pos:__POS__, neg:__NEG__, bg:__BG__}; + var state={iso:__ISO__, op:__OP__, pos:__POS__, neg:__NEG__, bg:__BG__, wf:__WF__}; function v(){ return window["viewer_"+UID]; } // ⚠️ Isosurfaces are SHAPES, not surfaces. viewer.addVolumetricData() routes @@ -1115,9 +1115,11 @@ def orbital_colors(scheme: str) -> tuple[str, str]: // lobes; the roughness is the mesh, not the grid, so more cubegen points // don't fix it but a few smoothing passes do (GaussView-like surfaces). shapes.push(vw.addVolumetricData(DATA,"cube", - {isoval: state.iso, color: state.pos, opacity: state.op, smoothness: 5})); + {isoval: state.iso, color: state.pos, opacity: state.op, smoothness: 5, + wireframe: state.wf})); shapes.push(vw.addVolumetricData(DATA,"cube", - {isoval: -state.iso, color: state.neg, opacity: state.op, smoothness: 5})); + {isoval: -state.iso, color: state.neg, opacity: state.op, smoothness: 5, + wireframe: state.wf})); } function build(){ @@ -1159,6 +1161,11 @@ def orbital_colors(scheme: str) -> tuple[str, str]: if(opts.op!==undefined && opts.op!==state.op){ state.op=opts.op; geom=true; } if(opts.pos!==undefined && opts.pos!==state.pos){ state.pos=opts.pos; geom=true; } if(opts.neg!==undefined && opts.neg!==state.neg){ state.neg=opts.neg; geom=true; } + // Wireframe is baked into the shape at creation (addVolumetricData), same + // as colour/opacity above — there is no "restyle" call for an existing + // isosurface shape, so a toggle rebuilds it like every other appearance + // change here. + if(opts.wf!==undefined && opts.wf!==state.wf){ state.wf=opts.wf; geom=true; } if(opts.bg!==undefined){ state.bg=opts.bg; vw.setBackgroundColor(state.bg); } if(geom){ var cam=null; @@ -1211,6 +1218,7 @@ def render_orbital_isosurface_py3dmol( *, isovalue: float = 0.02, opacity: float = 0.85, + wireframe: bool = False, width: int = 760, height: int = 620, color_scheme: str = DEFAULT_ORBITAL_COLORS, @@ -1229,6 +1237,13 @@ def render_orbital_isosurface_py3dmol( isovalue, opacity Initial surface threshold and transparency. Both are changeable live via ``window.__quantuiIsoUpdate`` without rebuilding the viewer. + wireframe + Surface finish (M-ORBEXPORT ORBX.7). Re-scoped from the original + "metallic" request after reading the vendored 3Dmol.js: Lambert + shading has no specular term, so glossy/metallic isn't reachable on + this backend — wireframe is what the renderer can actually do. + Changeable live, same as isovalue/opacity, though 3Dmol.js rebuilds + the shape to apply it (no in-place restyle for volumetric data). color_scheme Key into :data:`ORBITAL_COLOR_SCHEMES`. bgcolor @@ -1247,6 +1262,7 @@ def render_orbital_isosurface_py3dmol( with_surfaces=True, isovalue=isovalue, opacity=opacity, + wireframe=wireframe, width=width, height=height, color_scheme=color_scheme, @@ -1323,6 +1339,7 @@ def _build_iso_viewer( with_surfaces: bool, isovalue: float = 0.02, opacity: float = 0.85, + wireframe: bool = False, width: int = 760, height: int = 620, color_scheme: str = DEFAULT_ORBITAL_COLORS, @@ -1353,6 +1370,7 @@ def _build_iso_viewer( .replace("__SCENE__", json.dumps(scene_key)) .replace("__ISO__", repr(float(isovalue))) .replace("__OP__", repr(float(opacity))) + .replace("__WF__", "true" if wireframe else "false") .replace("__POS__", json.dumps(pos_color)) .replace("__NEG__", json.dumps(neg_color)) .replace("__BG__", json.dumps(bgcolor)) diff --git a/tests/test_orbital_export_and_resolution.py b/tests/test_orbital_export_and_resolution.py index 06d32cd..901dafb 100644 --- a/tests/test_orbital_export_and_resolution.py +++ b/tests/test_orbital_export_and_resolution.py @@ -27,7 +27,7 @@ import re import tempfile from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch import numpy as np import pytest @@ -698,12 +698,14 @@ def test_the_options_reflect_the_widgets(self): app._plotly_theme_colors = lambda: {"scene_bgcolor": "white"} app._iso_isovalue_slider = Mock(value=0.07) app._iso_opacity_slider = Mock(value=0.4) + app._iso_wireframe_cb = Mock(value=True) app._iso_colors_dd = Mock(value="orange-blue") app._orb_png_inbox = Mock() opts = iso_render_options(app) assert opts["isovalue"] == pytest.approx(0.07) assert opts["opacity"] == pytest.approx(0.4) + assert opts["wireframe"] is True assert opts["color_scheme"] == "orange-blue" def test_missing_widgets_fall_back_to_defaults(self): @@ -714,9 +716,57 @@ def test_missing_widgets_fall_back_to_defaults(self): opts = iso_render_options(app) assert opts["isovalue"] == pytest.approx(0.02) assert opts["opacity"] == pytest.approx(0.85) + assert opts["wireframe"] is False assert opts["color_scheme"] == "blue-red" +class TestSurfaceFinish: + """M-ORBEXPORT ORBX.7 — re-scoped from "metallic" (unreachable on + py3Dmol's Lambert-only shading, per the roadmap's read of the vendored + 3Dmol.js) to wireframe, the surface-finish option this renderer can + actually do. + """ + + def test_wireframe_off_by_default(self, cube_file): + html = render_orbital_isosurface_py3dmol(cube_file) + assert "wf:false" in html + + def test_wireframe_on_when_requested(self, cube_file): + html = render_orbital_isosurface_py3dmol(cube_file, wireframe=True) + assert "wf:true" in html + + def test_both_lobes_get_the_wireframe_flag(self, cube_file): + # Positive and negative phases are two separate addVolumetricData + # calls (see _ISO_VIEWER_JS) — a fix that only touched one would + # render half the orbital solid and half wireframe. + html = render_orbital_isosurface_py3dmol(cube_file, wireframe=True) + assert html.count("wireframe: state.wf") == 2 + + def test_wireframe_is_a_live_update_option_not_a_rebuild_only_one(self): + # Same shape as isovalue/opacity: changeable via + # window.__quantuiIsoUpdate without a Python re-render. + from quantui.orbital_visualization import _ISO_VIEWER_JS + + assert "opts.wf" in _ISO_VIEWER_JS + + def test_toggling_wireframe_reaches_the_bridge(self): + from quantui.app_visualization import on_iso_appearance_changed + + app = Mock() + app._plotly_theme_colors = lambda: {"scene_bgcolor": "white"} + app._iso_isovalue_slider = Mock(value=0.02) + app._iso_opacity_slider = Mock(value=0.85) + app._iso_wireframe_cb = Mock(value=True) + app._iso_colors_dd = Mock(value="blue-red") + app._orb_png_inbox = Mock() + app._iso_js_bridge = Mock() + app._last_cube_path = None # skip update_iso_enclosed_label's cube read + + with patch("quantui.app_visualization.iso_bridge_update") as mock_update: + on_iso_appearance_changed(app) + assert mock_update.call_args.kwargs["wf"] is True + + class TestColourSchemes: """ORBX.6 — named pairs, because these are conventions people recognise from other software rather than arbitrary picks."""