From 350a5e5f676db3b9d44b55a0c8424c75370d3793 Mon Sep 17 00:00:00 2001 From: NCCU Schultz Lab Date: Sat, 22 Aug 2026 04:23:14 +0000 Subject: [PATCH] Add click-to-measure atom selection for the Analysis-tab viewer (M-MEASURE) Click 2/3/4 atoms in the Analysis tab's molecule viewer to read a bond length, angle, and dihedral, GaussView-style. py3Dmol-only for v1 (the Analysis viewer's plotlymol backend is a static figure with no live click callback); the panel shows an explanatory message instead of controls when plotlymol is resolved (MEAS.6), never a silent no-op. - quantui/measurement.py (MEAS.1): distance/angle/dihedral wrappers around ASE's Atoms.get_distance/get_angle/get_dihedral -- the read direction of pes_scan.py's existing set_distance/set_angle/ set_dihedral -- plus describe_picks(), the progressive readout text ("Picked: H1 -> O1 (0.958 A) -> H2 (104.5 deg)"). A collinear inner angle (undefined dihedral plane) is caught and reported in plain language rather than raising ASE's ZeroDivisionError. - quantui/app_measurement.py (MEAS.2-.6): the click transport reuses ORBX.1's exact JS->kernel trick (write into a hidden Textarea's DOM node, dispatch an 'input' event) with a new inbox; picked-atom highlighting pushes to the LIVE viewer via a kernel->JS bridge (mirrors iso_bridge_update) so a re-render never discards the camera orientation the user rotated into. A 5th click starts a new chain; repicking an already-selected atom is ignored rather than producing a degenerate (undefined) measurement. - Wired into both places that render app._analysis_mol_output (app_visualization.show_result_3d and app._rerender_3d_views), so it works identically after a calc and after a backend-toggle switch. Never touches the Results-tab viewer or any other VizTask. - Help topic ("measure") added to help_content.py; readout panel styled with M-THEME tokens, no new hardcoded hex. Cloud-tested: geometry-math unit tests, inbox/pick-state-machine tests (mirroring the existing PNG-inbox test shape), injected-HTML content assertions, and a router-unaffected check for the sibling STRUCTURE_VIEW_RESULTS task. The actual browser click-through is a LOCAL/Voila exit gate per roadmap 45, not attempted here. Claude (Sonnet 5) Co-authored-by: Claude --- quantui/app.py | 39 +++++ quantui/app_builders.py | 64 +++++++ quantui/app_measurement.py | 311 +++++++++++++++++++++++++++++++++++ quantui/app_visualization.py | 7 + quantui/help_content.py | 35 ++++ quantui/measurement.py | 80 +++++++++ tests/test_measure_wiring.py | 277 +++++++++++++++++++++++++++++++ tests/test_measurement.py | 151 +++++++++++++++++ 8 files changed, 964 insertions(+) create mode 100644 quantui/app_measurement.py create mode 100644 quantui/measurement.py create mode 100644 tests/test_measure_wiring.py create mode 100644 tests/test_measurement.py diff --git a/quantui/app.py b/quantui/app.py index 8e53d40..2f3274f 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -204,6 +204,12 @@ from quantui.app_history import ( on_view_log as _hist_on_view_log, ) +from quantui.app_measurement import ( + on_measure_clear as _measure_on_clear, +) +from quantui.app_measurement import ( + on_measure_inbox_changed as _measure_on_inbox_changed, +) from quantui.app_runflow import ( calc_type_key as _run_calc_type_key, ) @@ -1219,6 +1225,15 @@ class QuantUIApp: _iso_wireframe_cb: Any _iso_resolution_dd: Any _last_result_dir: Any + _measure_inbox: Any + _measure_js_bridge: Any + _measure_readout: Any + _measure_clear_btn: Any + _measure_help_btn: Any + _measure_controls: Any + _measure_fallback_msg: Any + _measure_panel: Any + _measure_picks: Any _nmr_accordion: Any _nmr_output: Any _orb_accordion: Any @@ -2258,6 +2273,14 @@ def _wire_callbacks(self) -> None: self._reorg_png_inbox.observe( self._safe_cb(self._on_reorg_png_captured), names="value" ) + # Click-to-measure (M-MEASURE MEAS.2/3): a click in the Analysis-tab + # viewer posts an atom index into this inbox the same way the PNG + # buttons post a data URI. + self._measure_inbox.observe( + self._safe_cb(self._on_measure_inbox_changed), names="value" + ) + self._measure_clear_btn.on_click(self._on_measure_clear) + self._measure_help_btn.on_click(self._on_measure_help) # Persist the grid choice so it survives a relaunch (ORBX.2). self._iso_resolution_dd.observe( self._safe_cb(self._on_iso_resolution_changed), names="value" @@ -3199,6 +3222,12 @@ def _rerender_3d_views(self) -> None: lighting=self._viz_lighting, bgcolor=self._plotly_theme_colors()["scene_bgcolor"], ) + # M-MEASURE: same wiring as the post-calc render path — a + # backend-toggle switch is still a fresh viewer, so stale + # picks from the previous backend must not survive it. + from quantui.app_measurement import finalize_analysis_html + + html = finalize_analysis_html(self, html, chosen) self._set_html_output(self._analysis_mol_output, html) self._update_analysis_backend_label(chosen) @@ -3690,6 +3719,16 @@ def _on_orb_png_captured(self, change) -> None: def _on_reorg_png_captured(self, change) -> None: _exp_on_reorg_png_captured(self, change) + def _on_measure_inbox_changed(self, change) -> None: + _measure_on_inbox_changed(self, change) + + def _on_measure_clear(self, btn=None) -> None: + _measure_on_clear(self, btn) + + def _on_measure_help(self, btn) -> None: + _ = btn + self._show_help_topic("measure") + def _on_iso_resolution_changed(self, change) -> None: """Persist the isosurface grid choice. diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 0f41495..5a38f14 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -10,6 +10,7 @@ from IPython.display import HTML, display import quantui +from quantui import app_measurement as _measure from quantui import molecule_library as _ml from quantui import theme as _theme from quantui.help_content import HELP_TOPICS @@ -2426,6 +2427,68 @@ def _plot_export_row(prefix: str) -> widgets.HBox: # box around it — the Output widget cannot shrink-wrap. app._analysis_mol_output = widgets.Output() + # ── Click-to-measure (M-MEASURE MEAS.2-.6) ────────────────────────── + # Hidden inbox for the click JS injected into the py3Dmol-rendered + # Analysis viewer (app_measurement.inject_click_js) — same JS->kernel + # "write into a hidden Textarea's DOM node, dispatch 'input'" trick + # ORBX.1's PNG capture uses, new inbox. + app._measure_inbox = widgets.Textarea( + value="", layout=layout_fn(width="1px", height="1px", visibility="hidden") + ) + app._measure_inbox.add_class(_measure.MEASURE_INBOX_CLASS) + # Hidden Output that carries one-shot Javascript pushing highlight + # updates to the live viewer. Mirrors _iso_js_bridge. + app._measure_js_bridge = widgets.Output( + layout=layout_fn(width="0px", height="0px", visibility="hidden") + ) + app._measure_readout = widgets.HTML( + value=_measure._readout_html(_measure._PLACEHOLDER_TEXT) + ) + app._measure_clear_btn = widgets.Button( + description="Clear", + button_style="", + tooltip="Clear the picked atoms and their highlights", + layout=layout_fn(width="70px"), + ) + app._measure_help_btn = widgets.Button( + description="?", + button_style="", + layout=layout_fn(width="28px", height="28px"), + tooltip="Click-to-measure bond length / angle / dihedral — opens Help tab", + ) + app._measure_controls = widgets.VBox( + [ + widgets.HBox( + [app._measure_readout], + layout=layout_fn(align_items="center"), + ), + widgets.HBox( + [app._measure_clear_btn, app._measure_help_btn], + layout=layout_fn(align_items="center", gap="6px", margin="2px 0 0"), + ), + ] + ) + # DEC-009: the panel stays visible regardless of backend — only its + # content swaps (MEAS.6). Shown when the resolved backend can't support + # native clicking (plotlymol, or py3Dmol simply unavailable). + app._measure_fallback_msg = widgets.HTML( + value=( + f'

' + "Click-to-measure needs the py3Dmol viewer — switch backends above " + "(or in Settings) to use it.

" + ), + layout=layout_fn(display="none"), + ) + app._measure_panel = widgets.VBox( + [ + app._measure_controls, + app._measure_fallback_msg, + app._measure_inbox, + app._measure_js_bridge, + ], + layout=layout_fn(margin="4px 0 8px"), + ) + # Analysis-tab backend toggle — mirrors the Calculate-tab `viz_backend_toggle`. # Created only when both backends are available (matches Calculate-tab # convention). Synchronized with the Calculate-tab toggle via @@ -2490,6 +2553,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: ana_children = [ app._analysis_context_lbl, app._analysis_mol_output, + app._measure_panel, ] if ana_backend_row is not None: ana_children.append(ana_backend_row) diff --git a/quantui/app_measurement.py b/quantui/app_measurement.py new file mode 100644 index 0000000..7333bc4 --- /dev/null +++ b/quantui/app_measurement.py @@ -0,0 +1,311 @@ +"""Click-to-measure atom selection for the Analysis-tab viewer (M-MEASURE). + +py3Dmol-only for v1 (MEAS.2-.6): the Analysis tab's top viewer +(``app._analysis_mol_output``) is dual-backend (py3Dmol / plotlymol), but +only py3Dmol renders through a live ``$3Dmol.GLViewer`` with a native +``setClickable`` — plotlymol is a static Plotly figure with no equivalent +without restructuring it into a live widget, which is out of scope here. See +roadmap 45 ("Real constraint found: backend split"). + +Two one-way bridges, same shape as the isosurface panel's: + +- **JS -> kernel** (a click): the exact "standard trick" ORBX.1 uses for its + Save-PNG button — the click handler writes the picked atom's index into a + hidden ``widgets.Textarea`` and dispatches an ``input`` event, which + ipywidgets' own view syncs to the kernel. See ``inject_click_js``. +- **kernel -> JS** (a highlight update): mirrors + ``app_visualization.iso_bridge_update`` — a hidden ``widgets.Output`` + receives one-shot ``IPython.display.Javascript`` that calls a bridge + function the click JS defined, so the live viewer can be restyled without a + Python re-render (which would lose the camera orientation the user rotated + into — GOTCHAS: "Camera state does NOT persist across atomic HTML swaps"). +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, List, Sequence + +from . import theme as _theme +from .measurement import describe_picks + +logger = logging.getLogger(__name__) + +MAX_PICKS = 4 + +# CSS class the hidden inbox Textarea carries — read by inject_click_js's +# generated JS (``. textarea``) and by app_builders.py, which actually +# builds the widget and applies this class to it. +MEASURE_INBOX_CLASS = "quantui-measure-inbox" + +# --------------------------------------------------------------------------- +# JS -> kernel: click transport (MEAS.2) +# --------------------------------------------------------------------------- + +# Appended after the normal py3Dmol-rendered HTML for the Analysis-tab viewer +# ONLY (a per-VizTask opt-in, matching the router's existing scoping — this +# must never wire into every py3Dmol render). References the live viewer by +# the uid py3Dmol bakes into its own generated HTML +# (``3dmolviewer_`` / ``viewer_``), same lookup +# orbital_visualization._build_iso_viewer uses. +_MEASURE_CLICK_JS = """ +(function(){ + var UID="__UID__"; + function v(){ return window["viewer_"+UID]; } + var shapes=[]; + + function clearHighlights(){ + var vw=v(); if(!vw){ return; } + for(var i=0;i str: + """Append the click-to-measure wiring to already-rendered py3Dmol HTML. + + Finds the viewer's uid the same way ``_build_iso_viewer`` does (py3Dmol + bakes ``3dmolviewer_`` into its own output), so this works on + whatever ``visualization_py3dmol.render_molecule_html`` returns without + that function needing to know about measurement at all. Returns *html* + unchanged (logging a warning) if no viewer id is found — a click-to- + measure feature that silently does nothing is worse than one that is + visibly absent, but this must never turn a render failure into a crash. + """ + m = re.search(r"3dmolviewer_(\w+)", html) + if m is None: + logger.warning("could not find py3Dmol viewer id; click-to-measure unavailable") + return html + uid = m.group(1) + js = ( + _MEASURE_CLICK_JS.replace("__UID__", uid) + .replace("__INBOX_CLASS__", inbox_class) + .replace("__HL_COLOR__", _HIGHLIGHT_COLOR) + ) + return f"{html}" + + +# --------------------------------------------------------------------------- +# kernel -> JS: push a highlight update to the live viewer (MEAS.4) +# --------------------------------------------------------------------------- + + +def push_highlight(app: Any, indices: Sequence[int]) -> None: + """Restyle the LIVE viewer's picked-atom highlights — no Python re-render. + + Same shape as ``app_visualization.iso_bridge_update``: re-rendering would + replace the viewer wholesale and lose the camera orientation the user + rotated into. + """ + bridge = getattr(app, "_measure_js_bridge", None) + if bridge is None: + return + from IPython.display import Javascript, display + + payload = json.dumps(list(indices)) + js = ( + "(function(){var n=0;function go(){n++;" # noqa: UP031 — JS is brace-dense + "if(window.__quantuiMeasureHighlight){window.__quantuiMeasureHighlight(%s);}" + "else if(n<40){setTimeout(go,50);}}go();})();" % payload + ) + try: + bridge.clear_output(wait=True) + with bridge: + display(Javascript(js)) + except Exception as exc: # noqa: BLE001 — a highlight push must never raise + logger.debug("measure highlight push failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Readout text (MEAS.5) +# --------------------------------------------------------------------------- + +_PLACEHOLDER_TEXT = "Click an atom in the viewer to start measuring." + + +def _readout_html(text: str) -> str: + return ( + f'
{text}
' + ) + + +def _set_readout(app: Any, text: str) -> None: + readout = getattr(app, "_measure_readout", None) + if readout is not None: + readout.value = _readout_html(text) + + +# --------------------------------------------------------------------------- +# Python-side pick state machine (MEAS.3) +# --------------------------------------------------------------------------- + + +def on_measure_inbox_changed(app: Any, change: dict) -> None: + """Accumulate a picked atom index from a viewer click. + + Fires when the click JS (``inject_click_js``) writes an atom index into + the hidden inbox Textarea. 1-4 picks accumulate into a running bond / + angle / dihedral readout; a 5th click starts a new chain. Clicking an + already-picked atom again mid-chain is ignored rather than accepted, + since a repeated index makes the geometry degenerate (ASE raises + ``ZeroDivisionError`` on a zero-length vector) for no benefit to the + student. + """ + raw = (change or {}).get("new") or "" + box = getattr(app, "_measure_inbox", None) + + def _clear_inbox() -> None: + if box is not None and box.value: + box.value = "" + + if not raw: + return + try: + idx = int(raw) + except (TypeError, ValueError): + _clear_inbox() + return + + molecule = getattr(app, "_analysis_displayed_molecule", None) + if molecule is None or not (0 <= idx < len(molecule.atoms)): + _clear_inbox() + return + + picks: List[int] = list(getattr(app, "_measure_picks", None) or []) + if len(picks) >= MAX_PICKS: + picks = [idx] + elif idx in picks: + _clear_inbox() + return + else: + picks.append(idx) + app._measure_picks = picks + + try: + _set_readout(app, describe_picks(molecule, picks)) + except Exception as exc: # noqa: BLE001 — a click must never crash the app + logger.debug("measurement readout failed: %s", exc) + _set_readout(app, "Could not compute a measurement for these atoms.") + + push_highlight(app, picks) + _clear_inbox() + + +def on_measure_clear(app: Any, btn: Any = None) -> None: + """Clear button (MEAS.5): empty the pick chain and its highlights.""" + _ = btn + app._measure_picks = [] + _set_readout(app, _PLACEHOLDER_TEXT) + push_highlight(app, []) + + +def reset_picks(app: Any) -> None: + """Drop any stale picks — called whenever the Analysis viewer re-renders. + + A fresh render is a brand-new ```` with none of the old + highlights, so there is nothing to push to the browser here; only the + Python-side state and the readout text need clearing (MEAS.3: "Switching + molecules or leaving the Analysis tab clears stale picks — no + measurement is ever silently computed against the wrong structure."). + """ + app._measure_picks = [] + _set_readout(app, _PLACEHOLDER_TEXT) + + +# --------------------------------------------------------------------------- +# plotlymol fallback messaging (MEAS.6) +# --------------------------------------------------------------------------- + + +def update_panel_for_backend(app: Any, backend: Any) -> None: + """Swap the measurement panel between controls and an explanation. + + DEC-009: the panel itself stays visible either way — only its *content* + switches, never a silent no-op when the resolved backend can't support + clicking. + """ + from .viz_backend_router import VizBackend + + controls = getattr(app, "_measure_controls", None) + fallback = getattr(app, "_measure_fallback_msg", None) + if controls is None or fallback is None: + return + is_py3dmol = backend == VizBackend.PY3DMOL + controls.layout.display = "" if is_py3dmol else "none" + fallback.layout.display = "none" if is_py3dmol else "" + + +# --------------------------------------------------------------------------- +# One entry point both analysis-viewer render paths call +# --------------------------------------------------------------------------- + + +def finalize_analysis_html(app: Any, html: str, backend: Any) -> str: + """Wire click-to-measure into a freshly rendered Analysis-viewer HTML. + + Called at both places that render into ``app._analysis_mol_output`` + (the post-calc render in ``app_visualization.show_result_3d`` and the + backend-toggle re-render in ``app._rerender_3d_views``), so click-to- + measure and MEAS.3's stale-pick reset apply identically regardless of + which path produced the view. + """ + from .viz_backend_router import VizBackend + + reset_picks(app) + update_panel_for_backend(app, backend) + if backend == VizBackend.PY3DMOL: + html = inject_click_js(html, inbox_class=MEASURE_INBOX_CLASS) + return html diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 8cdef83..a9e7e86 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -137,6 +137,13 @@ def show_result_3d( lighting=app._viz_lighting, bgcolor=app._plotly_theme_colors()["scene_bgcolor"], ) + if is_analysis_output: + # M-MEASURE: wires click-to-measure into the freshly + # rendered HTML (py3Dmol only) and resets any stale picks + # from whatever was shown before. + from quantui.app_measurement import finalize_analysis_html + + html = finalize_analysis_html(app, html, chosen) app._set_html_output(extra_output, html) if is_analysis_output: app._update_analysis_backend_label(chosen) diff --git a/quantui/help_content.py b/quantui/help_content.py index 875ad15..ebfcc97 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -320,6 +320,41 @@ "run multiple calculations and view energy differences in one table.

" ), }, + "measure": { + "title": "Click-to-measure (bond / angle / dihedral)", + "body": ( + "

Click atoms directly in the Analysis tab's molecule viewer to " + "read off geometry, the same way GaussView's picker works:

" + "" + f"" + " " + " " + "" + " " + "" + " " + "" + " " + "" + " " + "
ClicksShows
1 atomwhich atom is selected
2 atomsbond length, in Å
3 atoms+ the angle at the 2nd atom, in " + "degrees
4 atoms+ the dihedral (torsion) angle, " + "in degrees
" + "

A 5th click starts a new chain from that atom. Clicking an " + "atom already in the chain is ignored — repeating an atom makes " + "the geometry undefined. Picked atoms are highlighted in the " + "viewer; Clear resets the selection.

" + "

Note: this needs the py3Dmol viewer — if the " + "panel shows a message instead of the picker, switch backends " + "with the toggle above the viewer (or in Settings). Switching " + "molecules or leaving the Analysis tab clears the current " + "selection, so a measurement is never shown against the wrong " + "structure.

" + "

If four picked atoms include three that fall in a straight " + "line, the dihedral has no defined plane — the panel reports " + "this rather than showing a number.

" + ), + }, "resuming_calculations": { "title": "Resuming an interrupted calculation", "body": ( diff --git a/quantui/measurement.py b/quantui/measurement.py new file mode 100644 index 0000000..004dbcc --- /dev/null +++ b/quantui/measurement.py @@ -0,0 +1,80 @@ +"""Bond length / angle / dihedral measurement from picked atoms (M-MEASURE MEAS.1). + +Thin wrappers around ASE's ``Atoms.get_distance`` / ``get_angle`` / +``get_dihedral`` -- the read-direction sibling of the coordinate math +:mod:`quantui.pes_scan` already writes with ``set_distance`` / ``set_angle`` / +``set_dihedral``. Pure geometry, no widget dependency, same separation +``ase_bridge.py`` / ``pes_scan.py`` already keep. + +All indices here are 0-based, matching ``Molecule.atoms`` / ``.coordinates`` +and ``pes_scan.py``'s ``atom_indices`` convention; ``atom_label`` converts to +the 1-based label used for display. +""" + +from __future__ import annotations + +from typing import Sequence + +from .ase_bridge import molecule_to_atoms +from .molecule import Molecule + +__all__ = ["distance", "angle", "dihedral", "atom_label", "describe_picks"] + + +def distance(molecule: Molecule, i: int, j: int) -> float: + """Distance between atoms ``i`` and ``j``, in Angstroms.""" + atoms = molecule_to_atoms(molecule) + return float(atoms.get_distance(i, j)) + + +def angle(molecule: Molecule, i: int, j: int, k: int) -> float: + """Angle i-j-k in degrees, vertex at ``j``.""" + atoms = molecule_to_atoms(molecule) + return float(atoms.get_angle(i, j, k)) + + +def dihedral(molecule: Molecule, i: int, j: int, k: int, l: int) -> float: # noqa: E741 + """Dihedral i-j-k-l in degrees. + + Raises: + ZeroDivisionError: from ASE, when three consecutive atoms of the + chain are collinear -- the dihedral plane is then undefined. + Callers that surface this to a user (the click-to-measure + picker) should catch it and show a plain-language message + instead of crashing the click callback. + """ + atoms = molecule_to_atoms(molecule) + return float(atoms.get_dihedral(i, j, k, l)) + + +def atom_label(molecule: Molecule, i: int) -> str: + """1-based, element-prefixed atom label, e.g. ``"O1"``, ``"H2"``.""" + return f"{molecule.atoms[i]}{i + 1}" + + +def describe_picks(molecule: Molecule, picks: Sequence[int]) -> str: + """Progressive click-to-measure readout for 1-4 picked atom indices. + + Mirrors GaussView's convention: each atom after the first is annotated + with the measurement it newly completes -- bond length for the 2nd pick, + angle (vertex at the 2nd atom) for the 3rd, dihedral for the 4th. + """ + if not picks: + return "Click an atom to start measuring." + parts = [atom_label(molecule, picks[0])] + if len(picks) >= 2: + d = distance(molecule, picks[0], picks[1]) + parts.append(f"{atom_label(molecule, picks[1])} ({d:.3f} Å)") + if len(picks) >= 3: + a = angle(molecule, picks[0], picks[1], picks[2]) + parts.append(f"{atom_label(molecule, picks[2])} ({a:.1f}°)") + if len(picks) >= 4: + try: + dh = dihedral(molecule, picks[0], picks[1], picks[2], picks[3]) + parts.append(f"{atom_label(molecule, picks[3])} ({dh:.1f}°)") + except ZeroDivisionError: + parts.append( + f"{atom_label(molecule, picks[3])} " + "(dihedral undefined — atoms are collinear)" + ) + return "Picked: " + " → ".join(parts) diff --git a/tests/test_measure_wiring.py b/tests/test_measure_wiring.py new file mode 100644 index 0000000..7d258c3 --- /dev/null +++ b/tests/test_measure_wiring.py @@ -0,0 +1,277 @@ +"""Tests for click-to-measure wiring (M-MEASURE MEAS.2/3/5/6/7). + +Cloud-doable per the roadmap: HTML-content assertions for the injected click +JS (mirrors test_orbital_export_and_resolution.py's TestCaptureButtonIsOptIn +for the same inbox-textarea mechanism), the inbox -> pick-state observer, and +a router-unchanged check for STRUCTURE_VIEW_RESULTS. The actual browser +click-through is LOCAL/Voila-only (MEAS.7) and not attempted here. +""" + +from __future__ import annotations + +import re +from unittest.mock import Mock + +import pytest + +from quantui.app_measurement import ( + MAX_PICKS, + MEASURE_INBOX_CLASS, + finalize_analysis_html, + inject_click_js, + on_measure_clear, + on_measure_inbox_changed, + push_highlight, + reset_picks, + update_panel_for_backend, +) +from quantui.molecule import Molecule +from quantui.visualization_py3dmol import PY3DMOL_AVAILABLE +from quantui.viz_backend_router import VizBackend + +pytestmark = pytest.mark.skipif(not PY3DMOL_AVAILABLE, reason="py3Dmol not installed") + + +def _water() -> Molecule: + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + ) + + +def _py3dmol_html() -> str: + from quantui.visualization_py3dmol import render_molecule_html + + return render_molecule_html(_water(), backend="py3dmol") + + +class TestInjectClickJS: + def test_setclickable_is_wired(self): + html = inject_click_js(_py3dmol_html(), inbox_class=MEASURE_INBOX_CLASS) + assert "setClickable" in html + + def test_targets_the_class_the_widget_actually_carries(self): + html = inject_click_js(_py3dmol_html(), inbox_class=MEASURE_INBOX_CLASS) + assert MEASURE_INBOX_CLASS in html + + def test_the_sync_event_is_dispatched(self): + # Same mechanism as ORBX.1: setting .value alone is invisible to the + # widget model — 'input' is what the kernel actually observes. + html = inject_click_js(_py3dmol_html(), inbox_class=MEASURE_INBOX_CLASS) + assert 'dispatchEvent(new Event("input"' in html + assert "bubbles:true" in html + + def test_binds_to_this_viewer(self): + html = inject_click_js(_py3dmol_html(), inbox_class=MEASURE_INBOX_CLASS) + uid = re.search(r"3dmolviewer_(\w+)", html).group(1) + assert f'var UID="{uid}"' in html + + def test_highlight_bridge_function_is_defined(self): + html = inject_click_js(_py3dmol_html(), inbox_class=MEASURE_INBOX_CLASS) + assert "__quantuiMeasureHighlight" in html + assert "addSphere" in html + + def test_missing_viewer_id_returns_html_unchanged(self): + html = "

not a py3dmol viewer

" + out = inject_click_js(html, inbox_class=MEASURE_INBOX_CLASS) + assert out == html + + +class TestPickStateMachine: + @staticmethod + def _app() -> Mock: + app = Mock() + app._measure_inbox = Mock(value="pending") + app._measure_readout = Mock(value="") + app._measure_js_bridge = None # push_highlight no-ops cleanly + app._analysis_displayed_molecule = _water() + app._measure_picks = [] + return app + + def test_first_click_shows_only_the_label(self): + app = self._app() + on_measure_inbox_changed(app, {"new": "0"}) + assert app._measure_picks == [0] + assert "O1" in app._measure_readout.value + + def test_two_clicks_show_a_bond_length(self): + app = self._app() + on_measure_inbox_changed(app, {"new": "0"}) + on_measure_inbox_changed(app, {"new": "1"}) + assert app._measure_picks == [0, 1] + assert "Å" in app._measure_readout.value + + def test_three_clicks_add_an_angle(self): + app = self._app() + for idx in (1, 0, 2): + on_measure_inbox_changed(app, {"new": str(idx)}) + assert app._measure_picks == [1, 0, 2] + assert "°" in app._measure_readout.value + + def test_a_fifth_click_starts_a_new_chain(self): + # Water only has 3 atoms, so borrow a 4-atom molecule for this one. + app = self._app() + app._analysis_displayed_molecule = Molecule( + atoms=["H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 1.0], + ], + ) + for idx in (0, 1, 2, 3): + on_measure_inbox_changed(app, {"new": str(idx)}) + assert len(app._measure_picks) == MAX_PICKS + # A genuine 5th distinct pick resets to a fresh one-atom chain. + on_measure_inbox_changed(app, {"new": "0"}) + assert app._measure_picks == [0] + + def test_repeating_an_already_picked_atom_is_ignored(self): + app = self._app() + on_measure_inbox_changed(app, {"new": "0"}) + on_measure_inbox_changed(app, {"new": "0"}) + assert app._measure_picks == [0] # not [0, 0] — would be degenerate + + def test_the_inbox_is_cleared_after_every_click(self): + app = self._app() + on_measure_inbox_changed(app, {"new": "0"}) + assert app._measure_inbox.value == "" + + def test_empty_payload_is_a_noop(self): + app = self._app() + on_measure_inbox_changed(app, {"new": ""}) + assert app._measure_picks == [] + + def test_garbage_payload_does_not_raise(self): + app = self._app() + on_measure_inbox_changed(app, {"new": "not-a-number"}) + assert app._measure_picks == [] + + def test_out_of_range_index_is_ignored(self): + app = self._app() + on_measure_inbox_changed(app, {"new": "99"}) + assert app._measure_picks == [] + + def test_no_molecule_loaded_is_a_noop(self): + app = self._app() + app._analysis_displayed_molecule = None + on_measure_inbox_changed(app, {"new": "0"}) + assert app._measure_picks == [] + + def test_collinear_dihedral_reports_undefined_rather_than_raising(self): + app = self._app() + app._analysis_displayed_molecule = Molecule( + atoms=["H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 1.0, 0.0], + ], + ) + for idx in (0, 1, 2, 3): + on_measure_inbox_changed(app, {"new": str(idx)}) + assert "undefined" in app._measure_readout.value.lower() + + +class TestClearButton: + def test_clear_empties_picks_and_resets_readout(self): + app = Mock() + app._measure_picks = [0, 1, 2] + app._measure_readout = Mock(value="stale") + app._measure_js_bridge = None + on_measure_clear(app) + assert app._measure_picks == [] + assert "Click an atom" in app._measure_readout.value + + +class TestResetPicks: + def test_reset_clears_state_and_readout(self): + app = Mock() + app._measure_picks = [0, 1] + app._measure_readout = Mock(value="stale") + reset_picks(app) + assert app._measure_picks == [] + assert "Click an atom" in app._measure_readout.value + + +class TestPanelBackendSwitch: + @staticmethod + def _app() -> Mock: + app = Mock() + app._measure_controls = Mock() + app._measure_controls.layout = Mock() + app._measure_fallback_msg = Mock() + app._measure_fallback_msg.layout = Mock() + return app + + def test_py3dmol_shows_controls(self): + app = self._app() + update_panel_for_backend(app, VizBackend.PY3DMOL) + assert app._measure_controls.layout.display == "" + assert app._measure_fallback_msg.layout.display == "none" + + def test_plotlymol_shows_fallback_message(self): + app = self._app() + update_panel_for_backend(app, VizBackend.PLOTLYMOL) + assert app._measure_controls.layout.display == "none" + assert app._measure_fallback_msg.layout.display == "" + + def test_missing_panel_widgets_is_a_noop(self): + # Guards a bare Mock() app (as other test modules construct) from + # crashing when the panel was never built. + app = Mock() + app._measure_controls = None + app._measure_fallback_msg = None + update_panel_for_backend(app, VizBackend.PY3DMOL) # must not raise + + +class TestFinalizeAnalysisHtml: + @staticmethod + def _app() -> Mock: + app = Mock() + app._measure_picks = [0, 1] + app._measure_readout = Mock(value="stale") + app._measure_controls = Mock() + app._measure_controls.layout = Mock() + app._measure_fallback_msg = Mock() + app._measure_fallback_msg.layout = Mock() + return app + + def test_py3dmol_gets_click_js_and_resets_picks(self): + app = self._app() + html = finalize_analysis_html(app, _py3dmol_html(), VizBackend.PY3DMOL) + assert "setClickable" in html + assert app._measure_picks == [] + + def test_plotlymol_gets_no_click_js(self): + app = self._app() + html = finalize_analysis_html(app, _py3dmol_html(), VizBackend.PLOTLYMOL) + assert "setClickable" not in html + assert app._measure_picks == [] + + +class TestPushHighlightIsSafe: + def test_no_bridge_is_a_noop(self): + app = Mock() + app._measure_js_bridge = None + push_highlight(app, [0, 1]) # must not raise + + +class TestRouterUnaffected: + """MEAS.7: confirm ANALYSIS_STRUCTURE_VIEW's sibling task is untouched — + click-to-measure must be a per-VizTask opt-in, never a global one.""" + + def test_results_tab_render_carries_no_click_js(self, tmp_path, monkeypatch): + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + from quantui.app import QuantUIApp + + app = QuantUIApp() + mol = _water() + app._show_result_3d(mol, extra_output=None) # results_tab only + out = app.result_viz_output.outputs + combined = "".join( + o.get("data", {}).get("text/html", "") for o in out if "data" in o + ) + assert "quantui-measure-inbox" not in combined diff --git a/tests/test_measurement.py b/tests/test_measurement.py new file mode 100644 index 0000000..344fd80 --- /dev/null +++ b/tests/test_measurement.py @@ -0,0 +1,151 @@ +"""Tests for quantui.measurement (M-MEASURE MEAS.1) — pure geometry, no browser.""" + +from __future__ import annotations + +import pytest + +from quantui.ase_bridge import ASE_AVAILABLE +from quantui.measurement import angle, atom_label, describe_picks, dihedral, distance +from quantui.molecule import Molecule + +ase_only = pytest.mark.skipif(not ASE_AVAILABLE, reason="ase not installed") + + +def _water() -> Molecule: + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.757, 0.587, 0.0], [-0.757, 0.587, 0.0]], + ) + + +def _methane() -> Molecule: + return Molecule( + atoms=["C", "H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [0.63, 0.63, 0.63], + [-0.63, -0.63, 0.63], + [-0.63, 0.63, -0.63], + [0.63, -0.63, -0.63], + ], + ) + + +def _linear_plus_one() -> Molecule: + # Atoms 0-1-2 collinear (a linear triatomic), atom 3 off-axis: the + # dihedral 0-1-2-3 has an undefined inner-angle plane. + return Molecule( + atoms=["H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 1.0, 0.0], + ], + ) + + +@ase_only +class TestDistance: + def test_water_oh_bond_length(self): + d = distance(_water(), 0, 1) + assert d == pytest.approx(0.958, abs=1e-3) + + def test_distance_is_symmetric(self): + mol = _water() + assert distance(mol, 0, 1) == pytest.approx(distance(mol, 1, 0)) + + def test_same_atom_is_zero(self): + assert distance(_water(), 0, 0) == pytest.approx(0.0) + + +@ase_only +class TestAngle: + def test_water_h_o_h_angle(self): + a = angle(_water(), 1, 0, 2) + assert a == pytest.approx(104.5, abs=0.2) + + def test_methane_tetrahedral_angle(self): + a = angle(_methane(), 1, 0, 2) + assert a == pytest.approx(109.47, abs=0.1) + + def test_linear_angle_is_180(self): + a = angle(_linear_plus_one(), 0, 1, 2) + assert a == pytest.approx(180.0, abs=1e-6) + + def test_duplicate_vertex_neighbor_is_undefined(self): + with pytest.raises(ZeroDivisionError): + angle(_water(), 0, 1, 1) + + +@ase_only +class TestDihedral: + def test_linear_inner_angle_is_undefined(self): + # The documented edge case: atoms 0-1-2 are collinear, so the + # dihedral 0-1-2-3 has no well-defined plane. + with pytest.raises(ZeroDivisionError): + dihedral(_linear_plus_one(), 0, 1, 2, 3) + + def test_well_defined_dihedral_returns_a_float(self): + # Non-degenerate 4-atom chain (staggered-ish, no collinear triple). + mol = Molecule( + atoms=["H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + ], + ) + dh = dihedral(mol, 0, 1, 2, 3) + assert isinstance(dh, float) + # ASE's get_dihedral returns degrees in [0, 360), not signed. + assert 0.0 <= dh <= 360.0 + + +class TestAtomLabel: + def test_one_based_element_prefixed(self): + mol = _water() + assert atom_label(mol, 0) == "O1" + assert atom_label(mol, 1) == "H2" + assert atom_label(mol, 2) == "H3" + + +@ase_only +class TestDescribePicks: + def test_no_picks(self): + assert "Click an atom" in describe_picks(_water(), []) + + def test_one_pick_shows_only_label(self): + text = describe_picks(_water(), [0]) + assert text == "Picked: O1" + + def test_two_picks_show_bond_length(self): + text = describe_picks(_water(), [0, 1]) + assert "O1" in text + assert "H2" in text + assert "Å" in text + assert "0.958" in text + + def test_three_picks_add_angle(self): + text = describe_picks(_water(), [1, 0, 2]) + assert "°" in text + assert "104.4" in text or "104.5" in text + + def test_four_picks_add_dihedral(self): + mol = Molecule( + atoms=["H", "H", "H", "H"], + coordinates=[ + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + ], + ) + text = describe_picks(mol, [0, 1, 2, 3]) + assert text.count("°") == 2 # angle AND dihedral both shown + + def test_four_picks_with_collinear_inner_angle_reports_undefined(self): + text = describe_picks(_linear_plus_one(), [0, 1, 2, 3]) + assert "undefined" in text.lower() + assert "collinear" in text.lower()