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
|
|
@ -39,3 +39,21 @@ def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignor
|
|||
assert not window.isVisible()
|
||||
assert window.close() is True
|
||||
assert window._vm.is_dirty # unchanged: the prompt was skipped
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_mouse_controls_dialog_is_wired_and_reused(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.dialogs.mouse_controls import MouseControlsDialog
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
window = MainWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window._on_mouse_controls()
|
||||
dialog = window._mouse_controls_dialog
|
||||
assert isinstance(dialog, MouseControlsDialog)
|
||||
assert dialog.windowTitle() == "Mouse Controls"
|
||||
|
||||
# Raising the action again reuses the same modeless window.
|
||||
window._on_mouse_controls()
|
||||
assert window._mouse_controls_dialog is dialog
|
||||
|
|
|
|||
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
|
||||
)
|
||||
|
|
@ -100,8 +100,15 @@ def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type:
|
|||
assert float(shown["left"].rstrip("px")) > 0
|
||||
assert float(shown["top"].rstrip("px")) > 0
|
||||
|
||||
# Orbiting moves the target on screen without any plotly redraw.
|
||||
_run_js(canvas, qtbot, "Plotly.relayout('plot',{'scene.camera.eye':{x:-9,y:6,z:4}})")
|
||||
# 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"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignor
|
|||
assert "camera" in scene
|
||||
assert "range" in scene["xaxis"]
|
||||
assert scene["xaxis"]["autorange"] is False
|
||||
# plotly's camera lives in normalized scene units: the model is centred on
|
||||
# the origin and the eye is only a few units out (not a data-unit distance,
|
||||
# which parked the camera hundreds of units away and shrank the model).
|
||||
camera = scene["camera"]
|
||||
assert camera["center"] == {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
assert (
|
||||
camera["eye"]["x"] ** 2 + camera["eye"]["y"] ** 2 + camera["eye"]["z"] ** 2
|
||||
) ** 0.5 < 20.0
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
|
|
@ -121,3 +129,67 @@ def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-un
|
|||
("warning", "[web] plot.html:8 careful"),
|
||||
("info", "[web] plotly:9 hello"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_deformed_push_colours_by_scalar_and_ghosts_the_reference(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""The canvas wiring drives the builder's contour + ghost overlays."""
|
||||
import numpy as np
|
||||
|
||||
from otko.services.deformation import DeformationSource
|
||||
from otko.views.canvas3d.model_renderer import RendererMode
|
||||
|
||||
canvas, calls = _canvas_with_captured_js(qtbot)
|
||||
project = load_project(EXAMPLES / "cantilever.osmodel")
|
||||
canvas.set_project(project)
|
||||
ids = [node.id for node in project.nodes]
|
||||
source = DeformationSource(
|
||||
displacements=np.zeros((len(ids), 3)) + np.linspace(0, 1, len(ids))[:, None],
|
||||
node_id_to_row={nid: i for i, nid in enumerate(ids)},
|
||||
)
|
||||
|
||||
canvas.set_mode(RendererMode.DEFORMED, source)
|
||||
canvas.set_show_undeformed(True)
|
||||
calls.clear()
|
||||
canvas.render()
|
||||
|
||||
payload, _preserve = _parse_call(calls[-1])
|
||||
names = {trace.get("name") for trace in payload["data"]}
|
||||
assert "undeformed-reference" in names
|
||||
nodes = next(t for t in payload["data"] if t.get("name") == "nodes")
|
||||
assert nodes["marker"]["showscale"] is True
|
||||
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_animation_push_carries_prebuilt_frames(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""``animate`` must hand plotly a frame list, not drive Python re-renders."""
|
||||
import numpy as np
|
||||
|
||||
from otko.services.deformation import DeformationSource
|
||||
from otko.views.canvas_plotly.trace_builder import SceneOptions
|
||||
|
||||
canvas, calls = _canvas_with_captured_js(qtbot)
|
||||
project = load_project(EXAMPLES / "cantilever.osmodel")
|
||||
canvas.set_project(project)
|
||||
canvas._scene = canvas._builder.build(project, SceneOptions())
|
||||
|
||||
ids = [node.id for node in project.nodes]
|
||||
options = [
|
||||
SceneOptions(
|
||||
deformation=DeformationSource(
|
||||
displacements=np.full((len(ids), 3), float(k)),
|
||||
node_id_to_row={nid: i for i, nid in enumerate(ids)},
|
||||
)
|
||||
)
|
||||
for k in (1.0, 2.0, 3.0)
|
||||
]
|
||||
canvas.animate(options, duration_ms=25, loops=4)
|
||||
|
||||
assert calls and calls[-1].startswith("window.otkoAnimate(")
|
||||
body = calls[-1][len("window.otkoAnimate(") :]
|
||||
literal, end = json.JSONDecoder().raw_decode(body)
|
||||
payload = json.loads(literal)
|
||||
assert len(payload["frames"]) == 3
|
||||
assert payload["frames"][0]["data"], "frames must carry trace data"
|
||||
assert body[end:].startswith(", 25, 4, true")
|
||||
|
|
|
|||
61
tests/gui/test_undeformed_reference.py
Normal file
61
tests/gui/test_undeformed_reference.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Smoke tests for the undeformed-reference overlay on both backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ElasticBeamColumn, ElasticSection, Node, Project
|
||||
|
||||
|
||||
def _frame_project() -> Project:
|
||||
b, h = 0.30, 0.50
|
||||
return Project(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, True, True, True, True)),
|
||||
Node(id=2, coords=(6.0, 0.0, 0.0)),
|
||||
],
|
||||
sections=[
|
||||
ElasticSection(
|
||||
id=1,
|
||||
name="Rect",
|
||||
E=200e9,
|
||||
A=b * h,
|
||||
Iz=b * h**3 / 12.0,
|
||||
Iy=h * b**3 / 12.0,
|
||||
G=80e9,
|
||||
J=1e-6,
|
||||
)
|
||||
],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_pyvista_undeformed_reference_overlay(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.services.deformation import DeformationSource
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
from otko.views.canvas3d.model_renderer import RendererMode
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
canvas.show_project(_frame_project())
|
||||
|
||||
# In MODEL mode there is nothing to reference — the toggle must be a no-op.
|
||||
canvas.set_show_undeformed(True)
|
||||
assert canvas._renderer._undeformed_actor is None
|
||||
|
||||
# A deformed shape gets a ghost wireframe, removed again on toggle off.
|
||||
src = DeformationSource(
|
||||
displacements=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.5]]),
|
||||
node_id_to_row={1: 0, 2: 1},
|
||||
)
|
||||
canvas._renderer.set_mode(RendererMode.DEFORMED, src)
|
||||
canvas.render()
|
||||
canvas.set_show_undeformed(True)
|
||||
assert canvas._renderer._undeformed_actor is not None
|
||||
|
||||
canvas.set_show_undeformed(False)
|
||||
assert canvas._renderer._undeformed_actor is None
|
||||
Loading…
Reference in a new issue