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.
This commit is contained in:
parent
1b0620a392
commit
d3878f23b3
26 changed files with 1687 additions and 153 deletions
166
tests/gui/test_plotly_gestures.py
Normal file
166
tests/gui/test_plotly_gestures.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""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
|
||||
)
|
||||
Loading…
Reference in a new issue