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 ``
"
),
},
+ "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""
+ " | Clicks | "
+ " Shows |
"
+ "| 1 atom | "
+ " which atom is selected |
"
+ "| 2 atoms | "
+ " bond 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()