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 ``