otko/tests/gui/test_plotly_hover.py
smillmorel d3878f23b3 feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.

Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
  bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
  Help → Mouse Controls

Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
  and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page

Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00

126 lines
4.5 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. A top
# view is used because a point near the scene centre projects to nearly
# the same pixel from opposite diagonal views.
_run_js(
canvas,
qtbot,
"Plotly.relayout('plot',{'scene.camera.eye':{x:0,y:0,z:40},"
"'scene.camera.up':{x:0,y:1,z:0}})",
)
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)