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.
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""Regression tests for the Plotly canvas push contract.
|
|
|
|
``Plotly.react`` resets any scene attribute the incoming layout omits, so a
|
|
data-only push must be flagged ``preserveView`` (the JS side then carries the
|
|
live camera and axis ranges forward). Only explicit re-frames may send the
|
|
computed framing. These tests capture the JavaScript the canvas emits instead
|
|
of driving the browser, so they stay fast and deterministic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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"
|
|
|
|
|
|
def _canvas_with_captured_js(qtbot): # type: ignore[no-untyped-def]
|
|
from otko.views.canvas_plotly import PlotlyCanvas
|
|
|
|
canvas = PlotlyCanvas()
|
|
qtbot.addWidget(canvas)
|
|
# Pretend the page finished loading, then capture instead of running JS.
|
|
canvas._ready = True
|
|
calls: list[str] = []
|
|
canvas._eval = lambda js: calls.append(js) # type: ignore[method-assign]
|
|
return canvas, calls
|
|
|
|
|
|
def _parse_call(call: str) -> tuple[dict, bool]:
|
|
"""Split a captured ``window.otkoUpdate(<json>, <flag>)`` call."""
|
|
body = call[len("window.otkoUpdate(") : -1]
|
|
literal, _sep, flag = body.rpartition(", ")
|
|
return json.loads(json.loads(literal)), flag == "true"
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
|
|
assert calls, "show_project must push a figure"
|
|
payload, preserve = _parse_call(calls[-1])
|
|
assert preserve is False, "initial push must be a re-frame"
|
|
# The framing push carries the camera and the padded ranges.
|
|
scene = payload["layout"]["scene"]
|
|
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
|
|
def test_data_update_preserves_the_view(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
calls.clear()
|
|
|
|
canvas.selection.select_node(1) # data-only update
|
|
|
|
assert calls, "selection change must push"
|
|
payload, preserve = _parse_call(calls[-1])
|
|
assert preserve is True, "data update must preserve the view"
|
|
assert "camera" not in payload["layout"]["scene"], "must not re-send the camera"
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
|
|
for action in (canvas.view_xy, canvas.view_xz, canvas.view_yz, canvas.reset_camera):
|
|
calls.clear()
|
|
action()
|
|
assert calls[-1].endswith(", false)"), f"{action.__name__} must re-frame"
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""Re-colouring must not snap the camera back to the preset."""
|
|
from otko.views.canvas3d.style import RenderStyle
|
|
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
calls.clear()
|
|
|
|
canvas.set_style(RenderStyle(show_axis_outline=True))
|
|
|
|
assert calls, "a style change must push"
|
|
payload, preserve = _parse_call(calls[-1])
|
|
assert preserve is True, "colour changes must not re-frame the view"
|
|
assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""A plotly failure inside the page must not be invisible from Python."""
|
|
from PySide6.QtWebEngineCore import QWebEnginePage
|
|
|
|
from otko.views.canvas_plotly.plotly_canvas import _LoggingPage
|
|
|
|
messages: list[tuple[str, str]] = []
|
|
page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg)))
|
|
|
|
levels = QWebEnginePage.JavaScriptConsoleMessageLevel
|
|
page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html")
|
|
page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html")
|
|
page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "")
|
|
|
|
assert messages == [
|
|
("error", "[web] plot.html:7 boom"),
|
|
("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")
|