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:
smillmorel 2026-09-16 23:10:22 -04:00
commit d3878f23b3
26 changed files with 1687 additions and 153 deletions

View file

@ -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

View 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
)

View file

@ -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"

View file

@ -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")

View 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