otko/tests/gui/test_plotly_gestures.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

166 lines
5.9 KiB
Python

"""Regression tests for the Plotly canvas mouse-gesture layer.
plotly's built-in drag bindings (shift+left rotates, ctrl+left pans, middle
zooms, right pans) disagree with the PyVista backend's VTK trackball style, so
the Plotly canvas re-implements them in JS to match VTK: shift+left pan,
ctrl+left spin, middle pan, right zoom.
The page runs offscreen under QtWebEngine, where gl-plot3d's render loop is
throttled and camera animations do not advance, so these tests spy on the
gl-plot3d camera's ``view.pan`` / ``view.rotate`` calls and assert the mapping
(and that plotly's own handler is suppressed for the gesture) rather than the
resulting camera position. A full-camera smoke check is unnecessary here;
plotly owns the maths.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
pytest.importorskip("PySide6")
pytest.importorskip("plotly")
from otko.services import load_project
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
#: Install spies on the live camera, run one synthetic drag, report the calls.
#: ``keyBindingMode`` distinguishes our layer (false) from plotly's native
#: handler ('rotate'), which calls the same ``view`` methods.
_SPY_DRAG = """(function(){
var gd = document.getElementById('plot');
var s = gd._fullLayout.scene._scene;
var cam = s.camera;
var el = s.glplot.canvas;
var r = el.getBoundingClientRect();
var x0 = r.left + r.width/2, y0 = r.top + r.height/2;
var calls = [];
var origPan = cam.view.pan;
var origRotate = cam.view.rotate;
cam.view.pan = function (t, dx, dy, dz) {
calls.push(['pan', cam.keyBindingMode, dx, dy, dz]);
};
cam.view.rotate = function (t, p, y, ro) {
calls.push(['rotate', cam.keyBindingMode, p, y, ro]);
};
function ev(t, x, y, b) {
el.dispatchEvent(new MouseEvent(t, {
bubbles: true, cancelable: true, view: window,
clientX: x, clientY: y, button: __BUTTON__, buttons: b,
shiftKey: __SHIFT__, ctrlKey: __CTRL__, altKey: __ALT__
}));
}
ev('mousedown', x0, y0, __BUTTONS__);
ev('mousemove', x0 + 60, y0 + 40, __BUTTONS__);
ev('mouseup', x0 + 60, y0 + 40, 0);
cam.view.pan = origPan;
cam.view.rotate = origRotate;
return JSON.stringify({calls: calls, kbm: cam.keyBindingMode});
})()"""
def _js_bool(value: bool) -> str:
return "true" if value else "false"
def _open_canvas(qtbot): # type: ignore[no-untyped-def]
from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas()
qtbot.addWidget(canvas)
canvas.show_project(load_project(EXAMPLES / "basic_truss.osmodel"))
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"]
def _spy_drag(
canvas, # type: ignore[no-untyped-def]
qtbot,
*,
button: int,
buttons: int,
shift: bool = False,
ctrl: bool = False,
alt: bool = False,
) -> dict[str, Any]:
script = (
_SPY_DRAG.replace("__BUTTON__", str(button))
.replace("__BUTTONS__", str(buttons))
.replace("__SHIFT__", _js_bool(shift))
.replace("__CTRL__", _js_bool(ctrl))
.replace("__ALT__", _js_bool(alt))
)
raw = _run_js(canvas, qtbot, script)
assert isinstance(raw, str)
return json.loads(raw)
def _ours(calls: list[list[Any]], kind: str) -> list[list[Any]]:
return [c for c in calls if c[0] == kind and c[1] is False]
@pytest.mark.gui
def test_gesture_bindings_match_vtk(qtbot) -> None: # type: ignore[no-untyped-def]
"""One page for all gestures: each QWebEngineView costs a WebGL context."""
canvas = _open_canvas(qtbot)
# Shift+left pans (not plotly's rotate).
result = _spy_drag(canvas, qtbot, button=0, buttons=1, shift=True)
pans = _ours(result["calls"], "pan")
assert pans and pans[0][3] != 0, result
assert not _ours(result["calls"], "rotate"), result
assert result["kbm"] == "rotate", "binding must be restored after the drag"
# Middle drag pans, not zooms.
result = _spy_drag(canvas, qtbot, button=1, buttons=4)
pans = _ours(result["calls"], "pan")
assert pans and pans[0][3] != 0 and pans[0][4] == 0, result
assert result["kbm"] == "rotate", result
# Right drag dollies (z pan), not plotly's pan.
result = _spy_drag(canvas, qtbot, button=2, buttons=2)
zooms = [c for c in _ours(result["calls"], "pan") if c[2] == 0 and c[3] == 0]
assert zooms and zooms[0][4] != 0, result
assert result["kbm"] == "rotate", result
# Ctrl+left spins (pure roll).
result = _spy_drag(canvas, qtbot, button=0, buttons=1, ctrl=True)
rolls = [c for c in _ours(result["calls"], "rotate") if c[2] == 0 and c[3] == 0]
assert rolls and rolls[0][4] != 0, result
assert result["kbm"] == "rotate", result
# Alt is not a VTK modifier: it falls back to a plain rotate.
result = _spy_drag(canvas, qtbot, button=0, buttons=1, alt=True)
rots = [c for c in _ours(result["calls"], "rotate") if c[4] == 0]
assert rots and (rots[0][2] != 0 or rots[0][3] != 0), result
assert result["kbm"] == "rotate", result
# Plain left drag stays on plotly's native path (keyBindingMode 'rotate').
result = _spy_drag(canvas, qtbot, button=0, buttons=1)
assert not _ours(result["calls"], "pan"), result
assert not _ours(result["calls"], "rotate"), result
assert any(c[1] == "rotate" for c in result["calls"]), result
assert result["kbm"] == "rotate", result
# In-page animation entry points are exposed to Python.
assert (
_run_js(
canvas,
qtbot,
"typeof window.otkoAnimate === 'function' && "
"typeof window.otkoStopAnimation === 'function'",
)
is True
)