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.
126 lines
4.5 KiB
Python
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)
|