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.
166 lines
5.9 KiB
Python
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
|
|
)
|