otko/tests/gui/test_plotly_hover.py
smillmorel 1b0620a392 perf: replace the Plotly snap marker with a projected DOM overlay
Orbiting and the snap preview felt far behind the cursor. Measured in the
page: a single Plotly.restyle on a gl3d plot costs about 90 ms even for a
one-trace figure, so every marker update stalled the scene and the marker
trailed the mouse; each one also queued another redraw while the user was
dragging.

The marker is now a pointer-events-none div positioned by projecting the
snapped world point through glplot.cameraParams (validated: the camera
centre lands at the canvas centre), updated with one style write and
re-projected on plotly_relayout so it stays glued to the target during
orbit. No plotly calls at all on the hover path - 200 mouse-move events
now cost 0 restyles - and the empty hover trace is gone from the figure.

Also logs the WebGL renderer once (via the console bridge) since hardware
acceleration decides how smooth orbit feels and is otherwise invisible.
2026-09-16 20:52:40 -04:00

119 lines
4.3 KiB
Python

"""Regression tests for the Plotly snap-hover marker.
``Plotly.restyle`` on a gl3d plot costs ~90 ms even for a single trace (measured
in-page), so the marker used to lag far behind the cursor and stall orbiting.
It is now a pointer-events-none DOM overlay positioned by projecting the snapped
world point through gl-plot3d's own camera matrices, which costs microseconds
and issues no plotly calls at all.
"""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("PySide6")
pytest.importorskip("plotly")
from otko.services import load_project
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
#: 200 mouse-move events over a *non-snap* point with snapping armed.
_CHURN_PROBE = """(function(){
var gd = document.getElementById('plot');
var n = 0; var original = Plotly.restyle;
Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); };
var point = { data: gd.data[0], customdata: [0, 0, 0] };
try {
for (var i = 0; i < 200; i++) {
gd.emit('plotly_hover', { points: [point] });
gd.emit('plotly_unhover', {});
}
return n;
} finally { Plotly.restyle = original; }
})()"""
_MARKER_STATE = """(function(){
var marker = document.getElementById('otko-snap-marker');
if (!marker) return 'missing';
return JSON.stringify({ display: marker.style.display, left: marker.style.left,
top: marker.style.top });
})()"""
_EMIT_SNAP_HOVER = """(function(){
var gd = document.getElementById('plot');
var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0];
var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] };
gd.emit('plotly_hover', { points: [point] });
return true;
})()"""
_EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true"
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas()
qtbot.addWidget(canvas)
canvas.show_project(load_project(EXAMPLES / name))
qtbot.waitUntil(lambda: canvas._ready, timeout=30000)
qtbot.wait(1500) # let the first Plotly.react settle
return canvas
def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def]
box: dict[str, object] = {}
canvas._web.page().runJavaScript(script, lambda value: box.update(value=value))
qtbot.waitUntil(lambda: "value" in box, timeout=timeout)
return box["value"]
@pytest.mark.gui
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
"""The original handler restyled on every hover/unhover event."""
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
assert restyles == 0, f"mouse movement caused {restyles} plot restyles"
@pytest.mark.gui
def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def]
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
# Nothing shows while snapping is off (the marker belongs to the draw tools).
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
qtbot.wait(150)
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
canvas.set_snap_preview_enabled(True)
qtbot.wait(150)
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
qtbot.wait(200)
shown = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
assert shown["display"] == "block", "snap target not shown"
assert float(shown["left"].rstrip("px")) > 0
assert float(shown["top"].rstrip("px")) > 0
# Orbiting moves the target on screen without any plotly redraw.
_run_js(canvas, qtbot, "Plotly.relayout('plot',{'scene.camera.eye':{x:-9,y:6,z:4}})")
qtbot.wait(600)
moved = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
assert moved["display"] == "block"
assert (moved["left"], moved["top"]) != (shown["left"], shown["top"])
_run_js(canvas, qtbot, _EMIT_UNHOVER)
qtbot.wait(200)
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
def _json_state(raw: object) -> dict:
import json
assert isinstance(raw, str) and raw != "missing", f"marker element missing: {raw!r}"
return json.loads(raw)