From 1b0620a3921344c060158c48f863c8fbb9022792 Mon Sep 17 00:00:00 2001
From: smillmorel
Date: Wed, 16 Sep 2026 20:52:40 -0400
Subject: [PATCH 1/2] perf: replace the Plotly snap marker with a projected DOM
overlay
Orbiting and the snap preview felt far behind the cursor. Measured in the
page: a single Plotly.restyle on a gl3d plot costs about 90 ms even for a
one-trace figure, so every marker update stalled the scene and the marker
trailed the mouse; each one also queued another redraw while the user was
dragging.
The marker is now a pointer-events-none div positioned by projecting the
snapped world point through glplot.cameraParams (validated: the camera
centre lands at the canvas centre), updated with one style write and
re-projected on plotly_relayout so it stays glued to the target during
orbit. No plotly calls at all on the hover path - 200 mouse-move events
now cost 0 restyles - and the empty hover trace is gone from the figure.
Also logs the WebGL renderer once (via the console bridge) since hardware
acceleration decides how smooth orbit feels and is otherwise invisible.
---
AGENTS.md | 2 +-
src/otko/views/canvas_plotly/html.py | 136 ++++++++++++------
src/otko/views/canvas_plotly/trace_builder.py | 26 ----
tests/gui/test_plotly_hover.py | 64 ++++++---
tests/unit/test_plotly_trace_builder.py | 14 +-
5 files changed, 147 insertions(+), 95 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index babac38..1eb5f24 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
- `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.**
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
-- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`.
+- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and never re-send `scene.camera`. The snap-hover marker is a `pointer-events-none` DOM overlay positioned by projecting the target through `glplot.cameraParams` — **not** a trace, because a gl3d `Plotly.restyle` costs ~90 ms per call (measured, even for one trace), which made the marker lag behind the cursor and stall orbiting. Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`.
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py
index 46e145e..12c9bc8 100644
--- a/src/otko/views/canvas_plotly/html.py
+++ b/src/otko/views/canvas_plotly/html.py
@@ -38,6 +38,12 @@ _PAGE = """
@@ -49,7 +55,6 @@ _PAGE = """
var bridge = null;
var snapEnabled = false;
var handlersReady = false;
- var hoverIndex = -1;
var config = {
responsive: true,
@@ -69,44 +74,73 @@ _PAGE = """
return data && data.meta ? data.meta.kind : null;
}
- function findHover() {
- var gd = document.getElementById('plot');
- hoverIndex = -1;
- if (!gd || !gd.data) return;
- for (var i = 0; i < gd.data.length; i++) {
- var meta = gd.data[i] && gd.data[i].meta;
- if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
- }
- // A freshly pushed figure has an empty hover marker again.
- hoverShown = false;
- hoverPoint = null;
- }
-
// --- snap-target marker ------------------------------------------------
- // Updated synchronously but only when the visual state actually changes.
- // Restyling on every hover/unhover made the plot redraw per mouse move,
- // which re-fired hover and ended in a runaway redraw loop (RangeError:
- // Maximum call stack size exceeded, frozen canvas). Timers were avoided
- // deliberately: WebEngine throttles them to ~1 s when the page is not
- // compositing, which made the preview lag.
- var hoverShown = false;
- var hoverPoint = null;
+ // A pointer-events-none DOM dot positioned by projecting the snapped world
+ // point through gl-plot3d's own camera matrices. It deliberately is *not* a
+ // plotly trace: Plotly.restyle on a gl3d plot costs ~90 ms even for a single
+ // trace, so a trace marker lagged far behind the cursor and stalled orbiting.
+ // A projection plus one style write is a few microseconds.
+ var snapPoint = null;
+ var snapMarker = null;
- function samePoint(a, b) {
- return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
+ function snapMarkerElement() {
+ if (!snapMarker) {
+ snapMarker = document.createElement('div');
+ snapMarker.id = 'otko-snap-marker';
+ document.body.appendChild(snapMarker);
+ }
+ return snapMarker;
}
- function setHoverMarker(point) {
- var want = snapEnabled ? point : null;
- if (samePoint(want, hoverPoint) && !!want === hoverShown) return;
- if (!want && !hoverShown) { hoverPoint = null; return; }
- hoverPoint = want;
- hoverShown = !!want;
- if (hoverIndex < 0) return;
- var x = want ? [want[0]] : [];
- var y = want ? [want[1]] : [];
- var z = want ? [want[2]] : [];
- Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]);
+ function mat4Multiply(a, b) {
+ var out = new Array(16);
+ for (var col = 0; col < 4; col++) {
+ for (var row = 0; row < 4; row++) {
+ var sum = 0;
+ for (var k = 0; k < 4; k++) { sum += a[k * 4 + row] * b[col * 4 + k]; }
+ out[col * 4 + row] = sum;
+ }
+ }
+ return out;
+ }
+
+ function projectToClient(point) {
+ var gd = document.getElementById('plot');
+ var scene = gd && gd._fullLayout ? gd._fullLayout.scene._scene : null;
+ var params = scene && scene.glplot ? scene.glplot.cameraParams : null;
+ if (!params) return null;
+ var matrix = mat4Multiply(
+ mat4Multiply(params.projection, params.view), params.model
+ );
+ var x = point[0], y = point[1], z = point[2];
+ var clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
+ if (!isFinite(clipW) || Math.abs(clipW) < 1e-9) return null;
+ var clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
+ var clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
+ var canvas = scene.glplot.canvas;
+ var ratio = scene.glplot.pixelRatio || 1;
+ var localX = ((clipX / clipW) * 0.5 + 0.5) * canvas.width / ratio;
+ var localY = (1 - ((clipY / clipW) * 0.5 + 0.5)) * canvas.height / ratio;
+ if (localX < 0 || localY < 0 || localX > canvas.clientWidth || localY > canvas.clientHeight) {
+ return null; // off-screen target
+ }
+ var rect = canvas.getBoundingClientRect();
+ return [rect.left + localX, rect.top + localY];
+ }
+
+ function setSnapMarker(point) {
+ var marker = snapMarkerElement();
+ snapPoint = snapEnabled ? point : null;
+ if (!snapPoint) { marker.style.display = 'none'; return; }
+ var position = projectToClient(snapPoint);
+ if (!position) { marker.style.display = 'none'; return; }
+ marker.style.left = position[0] + 'px';
+ marker.style.top = position[1] + 'px';
+ marker.style.display = 'block';
+ }
+
+ function refreshSnapMarker() {
+ if (snapPoint) setSnapMarker(snapPoint);
}
// --- view preservation -------------------------------------------------
@@ -178,8 +212,8 @@ _PAGE = """
mergeView(fig, job.preserve ? currentView() : null);
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
- findHover();
installHandlers();
+ logRendererOnce();
}, function (err) {
if (window.console) console.error('otko: react failed', err);
}).then(function () {
@@ -188,6 +222,24 @@ _PAGE = """
});
}
+ // One-off diagnostic: whether WebGL is hardware-accelerated decides how
+ // smooth orbiting feels, and it is invisible from Python otherwise.
+ var loggedRenderer = false;
+ function logRendererOnce() {
+ if (loggedRenderer) return;
+ loggedRenderer = true;
+ try {
+ var canvas = document.createElement('canvas');
+ var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
+ if (!gl) { if (window.console) console.warn('otko: WebGL unavailable'); return; }
+ var info = gl.getExtension('WEBGL_debug_renderer_info');
+ var name = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
+ if (window.console) console.warn('otko: WebGL renderer = ' + name);
+ } catch (err) {
+ if (window.console) console.warn('otko: WebGL probe failed: ' + err.message);
+ }
+ }
+
window.otkoUpdate = function (payloadJson, preserveView) {
pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
runUpdate();
@@ -218,16 +270,20 @@ _PAGE = """
var pts = ev.points || [];
if (!pts.length) return;
var p = pts[0];
- if (kindOf(p) !== 'snap') { setHoverMarker(null); return; }
+ if (kindOf(p) !== 'snap') { setSnapMarker(null); return; }
var c = p.customdata;
- if (c) setHoverMarker([c[0], c[1], c[2]]);
+ if (c) setSnapMarker([c[0], c[1], c[2]]);
});
+ // Orbit/zoom moves the world point on screen: re-project so the marker
+ // stays glued to the target without a plotly redraw.
+ gd.on('plotly_relayout', function () { refreshSnapMarker(); });
+
gd.on('plotly_unhover', function () {
// Nothing can be visible unless snapping is armed, so stay out of the
// redraw path entirely for ordinary mouse movement.
if (!snapEnabled) return;
- setHoverMarker(null);
+ setSnapMarker(null);
});
}
@@ -237,7 +293,7 @@ _PAGE = """
window.otkoSetSnapEnabled = function (on) {
snapEnabled = !!on;
- if (!snapEnabled) setHoverMarker(null);
+ if (!snapEnabled) setSnapMarker(null);
};
})();
diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py
index fa5fc94..ebe5e13 100644
--- a/src/otko/views/canvas_plotly/trace_builder.py
+++ b/src/otko/views/canvas_plotly/trace_builder.py
@@ -112,8 +112,6 @@ class Scene:
layout: dict[str, Any]
center: tuple[float, float, float] = (0.0, 0.0, 0.0)
diagonal: float = 1.0
- #: Trace index (in ``data``) of the empty hover-snap marker, or -1.
- hover_trace: int = -1
#: Padded ``(min, max)`` per axis, used to frame the camera deterministically.
axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
@@ -333,7 +331,6 @@ class PlotlyTraceBuilder:
self._build_frames(project, data, opts, points, node_row)
self._build_nodes(data, opts, points, node_ids)
self._build_labels(project, data, opts, points, node_row)
- hover_trace = self._build_hover_marker(data)
candidates = [points] if len(points) else []
if grid_pts is not None:
@@ -346,7 +343,6 @@ class PlotlyTraceBuilder:
layout=self._layout(),
center=(float(center[0]), float(center[1]), float(center[2])),
diagonal=_diag_of_bounds(bounds),
- hover_trace=hover_trace,
axis_bounds=bounds,
)
@@ -951,28 +947,6 @@ class PlotlyTraceBuilder:
)
)
- @staticmethod
- def _build_hover_marker(data: list[dict[str, Any]]) -> int:
- data.append(
- {
- "type": "scatter3d",
- "mode": "markers",
- "x": [],
- "y": [],
- "z": [],
- "marker": {
- "color": "#ffd900",
- "size": 13,
- "line": {"color": "#8a6d00", "width": 1},
- },
- "hoverinfo": "skip",
- "name": "snap-hover",
- "showlegend": False,
- "meta": {"kind": "hover"},
- }
- )
- return len(data) - 1
-
def _cone_trace(
x: list[float],
diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py
index 78d5160..3033886 100644
--- a/tests/gui/test_plotly_hover.py
+++ b/tests/gui/test_plotly_hover.py
@@ -1,10 +1,10 @@
-"""Regression tests for the Plotly hover/snap-marker contract.
+"""Regression tests for the Plotly snap-hover marker.
-``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves.
-Touching the plot on each one made it redraw per mouse move, re-firing hover
-until the stack blew (``RangeError: Maximum call stack size exceeded``) and the
-canvas froze. The marker is therefore coalesced and only touched when its
-shown/hidden state actually changes.
+``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
@@ -35,10 +35,12 @@ _CHURN_PROBE = """(function(){
} finally { Plotly.restyle = original; }
})()"""
-_HOVER_MARKER_X = (
- "JSON.stringify((document.getElementById('plot').data"
- ".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)"
-)
+_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');
@@ -48,6 +50,8 @@ _EMIT_SNAP_HOVER = """(function(){
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
@@ -69,7 +73,7 @@ def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[n
@pytest.mark.gui
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
- """The unfixed version restyled once per hover/unhover event."""
+ """The original handler restyled on every hover/unhover event."""
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
@@ -78,16 +82,38 @@ def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore
@pytest.mark.gui
-def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def]
+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 _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]"
+ 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(100)
+ qtbot.wait(150)
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
- qtbot.wait(250)
- assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown"
+ 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
- _run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})")
- qtbot.wait(250)
- assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared"
+ # 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}})")
+ 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)
diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py
index 45b2b21..1d20e4a 100644
--- a/tests/unit/test_plotly_trace_builder.py
+++ b/tests/unit/test_plotly_trace_builder.py
@@ -26,10 +26,6 @@ def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def]
return [trace for trace in scene.data if trace.get("name") == name]
-def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def]
- return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data]
-
-
def test_builds_grid_nodes_and_frames() -> None:
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
names = {trace.get("name") for trace in scene.data}
@@ -37,8 +33,9 @@ def test_builds_grid_nodes_and_frames() -> None:
assert "nodes" in names
assert "elements" in names
assert scene.diagonal > 0
- # The hover-snap marker is always present so JS can restyle it.
- assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover"
+ # The snap marker is a DOM overlay, not a trace (a gl3d restyle costs
+ # ~90 ms, which made a trace marker lag behind the cursor).
+ assert all(trace.get("meta", {}).get("kind") != "hover" for trace in scene.data)
def test_nodes_carry_ids_as_customdata() -> None:
@@ -187,15 +184,14 @@ def test_payload_is_json_serialisable() -> None:
assert scene.layout["scene"]["aspectmode"] == "data"
-def test_empty_project_yields_only_the_hover_marker() -> None:
+def test_empty_project_yields_no_traces() -> None:
scene = PlotlyTraceBuilder().build(None, SceneOptions())
assert scene.data == []
- assert scene.hover_trace == -1
from otko.core import Project
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
- assert _kinds(empty) == ["hover"]
+ assert empty.data == []
def test_frame_trace_meta_marks_elements_pickable() -> None:
From d3878f23b3b5744e4c5210c24f503bd44f8af572 Mon Sep 17 00:00:00 2001
From: smillmorel
Date: Wed, 16 Sep 2026 23:10:22 -0400
Subject: [PATCH 2/2] feat(plotly): opstool-style contour, loads, supports,
ghost and animation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
AGENTS.md | Bin 6843 -> 8430 bytes
NOTICE | 7 +-
docs/QUICK_GUIDE.md | 31 +-
src/otko/services/deformation.py | 13 +
src/otko/views/action_handlers.py | 14 +
src/otko/views/canvas3d/model_canvas.py | 5 +
src/otko/views/canvas3d/model_renderer.py | 63 +-
src/otko/views/canvas_base.py | 5 +
src/otko/views/canvas_plotly/html.py | 157 ++++-
src/otko/views/canvas_plotly/plotly_canvas.py | 77 ++-
src/otko/views/canvas_plotly/trace_builder.py | 565 ++++++++++++++----
src/otko/views/dialogs/__init__.py | 2 +
src/otko/views/dialogs/mouse_controls.py | 68 +++
src/otko/views/dialogs/quick_guide.py | 4 +
src/otko/views/dock_manager.py | 33 +
src/otko/views/docks/mode_shape_animator.py | 17 +
src/otko/views/main_window.py | 5 +
src/otko/views/menu_builder.py | 8 +
src/otko/views/render_controls.py | 14 +
tests/gui/test_main_window.py | 18 +
tests/gui/test_plotly_gestures.py | 166 +++++
tests/gui/test_plotly_hover.py | 11 +-
tests/gui/test_plotly_view_preservation.py | 72 +++
tests/gui/test_undeformed_reference.py | 61 ++
tests/unit/test_plotly_trace_builder.py | 113 ++++
vis_improvement.md | 307 ++++++++++
26 files changed, 1685 insertions(+), 151 deletions(-)
create mode 100644 src/otko/views/dialogs/mouse_controls.py
create mode 100644 tests/gui/test_plotly_gestures.py
create mode 100644 tests/gui/test_undeformed_reference.py
create mode 100644 vis_improvement.md
diff --git a/AGENTS.md b/AGENTS.md
index 1eb5f243ea02412830e6c28dfc8afd30be03ce4d..2ca6f867d78b375163bc5db3b6933151451e29a5 100644
GIT binary patch
delta 1616
zcmeH`zit#U5XPZ95Ctt2pVH)RlUxc!lZb*pLP#i5iYNt5Jhz_Pb5LxVey$20T&X6E+gkJn!pgXQ}`m2m}(Aoo?>MAq;Qv1DXf
zg<5b)`u|DhF&a{z8E@y?xgy4hRXH_IM=P}iK&_z*@
zoNN*`RDG^Lm4A#!amtky=4*md>$GrR{v`a3=1h4`$)r+Q;&o
z?HEVC5yr$gZCoLsz#wai^!viZvio@4o+eEDrzamVp?tK4{U3!K7U!0eCu5d-fl~lVW{SYFJxaEvc8c0DzQGx
zdk;zMM%R~v*)lc9nmPZesG=Cm=RBIUZKh&OIH%NO
zRoH}Uy&%UFs1gP5?DH0Zr-7wp+3aXPvasIQqwhZlPaa;c9z7eZ`z+rc-#%SF__ybO
L{(pO>e<%Gfj+<~&
delta 43
zcmaFoxZ89?702Yc+?k>Y={W^C`6YVEiMgpoi2;d4iMhoIo0stLFmAreyIlYPhaeFo
diff --git a/NOTICE b/NOTICE
index 24b2dd1..7264cda 100644
--- a/NOTICE
+++ b/NOTICE
@@ -51,8 +51,11 @@ The canvas element colour palette in `src/otko/views/canvas3d/style.py`
(per-family element colours and the diverging response colour scale) and the
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
adapted from the `opstool` project, which is distributed under the **GNU
-General Public License v3.0**. opstool is Copyright © Yexiang Yan and
-contributors.
+General Public License v3.0**. The support (boundary-condition) glyph
+geometry in `views/canvas_plotly/trace_builder.py` (`_support_loops`) is
+ported and modified from opstool's `_get_bc_points_3d` / `_get_bc_points_2d`,
+and the plotly response-colour and load-pattern presentation follows
+opstool's plotly recipes. opstool is Copyright © Yexiang Yan and contributors.
In accordance with GPLv3 §5(a)/(b) this notice records that the material was
modified and adapted for OTKO. Combining the GPLv3-covered material with
diff --git a/docs/QUICK_GUIDE.md b/docs/QUICK_GUIDE.md
index 0bc4b8c..a6ff736 100644
--- a/docs/QUICK_GUIDE.md
+++ b/docs/QUICK_GUIDE.md
@@ -66,7 +66,9 @@ correctly.
periods, frequencies, and participation factors per mode.
4. **Display → Animate Mode Shape** to view each mode. Use the mode
selector and the animation controls, and **Export…** if you want a
- video of the mode shape.
+ video of the mode shape. On the Plotly backend the **Play** button runs
+ the animation inside the viewport (plotly frames); on PyVista it is
+ driven from Python.
5. Modal results also feed the response-spectrum case: define a response
spectrum, then run the SRSS or CQC combination and open
**Display → Show Response Spectrum**.
@@ -77,12 +79,20 @@ After a run, the results/report panel shows a summary for the active case
(static reactions and forces, modal periods, and so on). Use the display
actions to inspect the model visually:
-- **Display → Show Deformed Shape** — with a scale slider.
+- **Display → Show Deformed Shape** — with a scale slider. The deformed
+ shape is coloured by displacement magnitude with a colourbar, and
+ **Display → Show Undeformed Reference** overlays the original shape for
+ comparison.
- **Display → Show Force Diagram…** — axial (P), shear (V2/V3), moment
(M2/M3) diagrams.
- **Display → Show Pushover Curve**, **Show Time-History**, **Show
Hysteresis** as applicable.
+Nodal and distributed loads are drawn as arrows whose length scales with the
+load magnitude; each load pattern gets its own colour and hovering an arrow
+shows its value. Support symbols show which translation directions are
+restrained.
+
To hand the analysis to someone else, or to archive exactly what was run,
export a script:
@@ -122,6 +132,23 @@ wrapped in a single macro, so one undo removes the whole step. File
loads, analysis runs, and display-only changes are not model mutations and
are not undoable.
+## 6. Navigating the 3D view
+
+Both canvas backends (**Options → Canvas Backend**) use the same VTK-style mouse
+bindings, and **Help → Mouse Controls** lists them in the app:
+
+- **Rotate** — left-drag.
+- **Pan** — Shift + left-drag, or middle-drag.
+- **Zoom** — right-drag, or the mouse wheel.
+- **Spin (roll)** — Ctrl + left-drag.
+- **Select** — left-click a node or element; Ctrl+click or Shift+click adds
+ to the selection. A drag moves the view; only a click without movement
+ changes the selection.
+
+View presets: **Ctrl+1** isometric, **Ctrl+2** top (XY), **Ctrl+3** front
+(XZ), **Ctrl+4** right (YZ), **Ctrl+E** zoom extents. The camera keeps Z up,
+so the horizon stays level while you orbit.
+
## Where to go next
- [`architecture.md`](architecture.md) — MVVM layering and command order.
diff --git a/src/otko/services/deformation.py b/src/otko/services/deformation.py
index 8c60f7b..084e2ab 100644
--- a/src/otko/services/deformation.py
+++ b/src/otko/services/deformation.py
@@ -36,6 +36,19 @@ class DeformationSource:
out[i] += self.scale * self.displacements[row]
return out
+ def magnitudes(self, node_ids: list[int]) -> np.ndarray:
+ """Per-node displacement magnitude (scaled) in ``node_ids`` order.
+
+ Used to colour a deformed / modal shape by response value through
+ plotly's colour axis (the contour recipe adapted from opstool).
+ """
+ out = np.zeros(len(node_ids), dtype=float)
+ for i, nid in enumerate(node_ids):
+ row = self.node_id_to_row.get(nid)
+ if row is not None:
+ out[i] = self.scale * float(np.linalg.norm(self.displacements[row]))
+ return out
+
def static_to_deformation(
project: Project,
diff --git a/src/otko/views/action_handlers.py b/src/otko/views/action_handlers.py
index 4352b00..f699036 100644
--- a/src/otko/views/action_handlers.py
+++ b/src/otko/views/action_handlers.py
@@ -53,6 +53,7 @@ from otko.views.dialogs import (
MaterialLibraryDialog,
MaterialTesterDialog,
MirrorDialog,
+ MouseControlsDialog,
MoveDialog,
PathTimeSeriesDialog,
PatternLoadsDialog,
@@ -987,6 +988,19 @@ class ActionHandlers:
dlg.raise_()
dlg.activateWindow()
+ def _on_mouse_controls(self) -> None:
+ """Help → Mouse Controls — modeless viewport navigation reference.
+
+ Cached like the Quick Guide so re-invoking raises the same window.
+ """
+ dlg = getattr(self, "_mouse_controls_dialog", None)
+ if dlg is None:
+ dlg = MouseControlsDialog(self) # type: ignore[arg-type]
+ self._mouse_controls_dialog = dlg
+ dlg.show()
+ dlg.raise_()
+ dlg.activateWindow()
+
def _on_set_units(self) -> None:
"""Options → Set Display Units — SAP2000 parity.
diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py
index 3e00425..b6301a6 100644
--- a/src/otko/views/canvas3d/model_canvas.py
+++ b/src/otko/views/canvas3d/model_canvas.py
@@ -342,6 +342,11 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._renderer.set_show_local_axes(enabled)
self.render()
+ def set_show_undeformed(self, enabled: bool) -> None:
+ """Show a faint undeformed reference behind a deformed / modal shape."""
+ self._renderer.set_show_undeformed(enabled)
+ self.render()
+
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the scene.
diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py
index 12c88cb..70babee 100644
--- a/src/otko/views/canvas3d/model_renderer.py
+++ b/src/otko/views/canvas3d/model_renderer.py
@@ -12,7 +12,6 @@ from __future__ import annotations
import contextlib
import enum
-from dataclasses import dataclass
from typing import Any
import numpy as np
@@ -33,6 +32,7 @@ from otko.core import (
ZeroLengthElement,
ZeroLengthSectionElement,
)
+from otko.services.deformation import DeformationSource
from otko.views.canvas3d.style import (
SELECTED_STATE,
RenderStyle,
@@ -165,23 +165,6 @@ def _dof_indices(ndf: int) -> tuple[int, ...]:
return tuple(range(ndf))
-@dataclass
-class DeformationSource:
- """Per-node displacement vectors used to draw deformed shapes."""
-
- displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
- node_id_to_row: dict[int, int]
- scale: float = 1.0
-
- def shifted(self, original_points: np.ndarray, node_ids: list[int]) -> np.ndarray:
- out = original_points.copy()
- for i, nid in enumerate(node_ids):
- row = self.node_id_to_row.get(nid)
- if row is not None:
- out[i] += self.scale * self.displacements[row]
- return out
-
-
class ModelRenderer:
"""Glyphed-PolyData renderer with mode-aware deformation support."""
@@ -219,6 +202,8 @@ class ModelRenderer:
self._hover_actor: Any = None # single yellow-ring snap marker
self._show_section_extrusions: bool = False
self._show_local_axes: bool = False
+ self._show_undeformed: bool = False
+ self._undeformed_actor: Any = None
# SAP2000-style working plane: when set, the grid renders ONLY
# the lines / intersections lying on this plane so a user in
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
@@ -282,6 +267,13 @@ class ModelRenderer:
if self._project is not None:
self.render(self._project)
+ def set_show_undeformed(self, on: bool) -> None:
+ """Toggle the faint undeformed-shape reference in deformed/modal views."""
+ if self._show_undeformed == on:
+ return
+ self._show_undeformed = on
+ self._refresh_undeformed_overlay()
+
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and rebuild the scene it colours.
@@ -1114,8 +1106,39 @@ class ModelRenderer:
if self._frame_pd is not None:
self._frame_pd.points = new_pts
self._frame_pd.Modified()
+ self._refresh_undeformed_overlay()
self._rebuild_labels()
+ def _refresh_undeformed_overlay(self) -> None:
+ """Faint undeformed wireframe shown behind a deformed / modal shape."""
+ if self._undeformed_actor is not None:
+ with contextlib.suppress(Exception):
+ self._plotter.remove_actor(self._undeformed_actor, render=False)
+ self._undeformed_actor = None
+ if (
+ not self._show_undeformed
+ or self._mode == RendererMode.MODEL
+ or self._deformation is None
+ or self._frame_pd is None
+ or self._node_original_points is None
+ ):
+ self._plotter.render()
+ return
+ import pyvista as pv
+
+ ghost = pv.PolyData()
+ ghost.points = self._node_original_points
+ ghost.lines = np.asarray(self._frame_pd.lines).copy()
+ self._undeformed_actor = self._plotter.add_mesh(
+ ghost,
+ color="#9aa3ad",
+ opacity=0.45,
+ line_width=1,
+ lighting=False,
+ pickable=False,
+ )
+ self._plotter.render()
+
# ── helpers ─────────────────────────────────────────────────────
def _teardown_all(self) -> None:
self._clear_label_actors()
@@ -1126,6 +1149,10 @@ class ModelRenderer:
for a in self._aux_actors:
with contextlib.suppress(Exception):
self._plotter.remove_actor(a, render=False)
+ if self._undeformed_actor is not None:
+ with contextlib.suppress(Exception):
+ self._plotter.remove_actor(self._undeformed_actor, render=False)
+ self._undeformed_actor = None
self._node_actor = None
self._frame_actor = None
self._aux_actors.clear()
diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py
index 0352961..544985f 100644
--- a/src/otko/views/canvas_base.py
+++ b/src/otko/views/canvas_base.py
@@ -45,6 +45,9 @@ class CanvasCapabilities:
labels: bool = True
#: Off-screen frame capture (mode-shape / time-history video export).
animation_export: bool = True
+ #: In-page animation playback (plotly frames) instead of Python-driven
+ #: per-frame re-renders.
+ in_page_animation: bool = False
class CanvasBackend(Protocol):
@@ -87,6 +90,8 @@ class CanvasBackend(Protocol):
def set_show_local_axes(self, enabled: bool) -> None: ...
+ def set_show_undeformed(self, enabled: bool) -> None: ...
+
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ...
def set_default_selection_enabled(self, enabled: bool) -> None: ...
diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py
index 12c9bc8..80b4a88 100644
--- a/src/otko/views/canvas_plotly/html.py
+++ b/src/otko/views/canvas_plotly/html.py
@@ -13,9 +13,21 @@ The JS side exposes three entry points to Python (called via
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
parallel-projection toggle).
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview.
+- ``otkoAnimate(payloadJson, durationMs, loops, preserveView)`` /
+ ``otkoStopAnimation()`` — push a pre-built frame list and let plotly play
+ it in-page (mode-shape / time-history animation).
Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
entity picks carry the trace ``meta.kind`` and the point ``customdata``.
+
+There is also an in-page mouse-gesture layer. plotly's built-in drag bindings
+disagree with the PyVista backend's VTK trackball style (plotly's shift+left
+rotates, ctrl+left pans, middle zooms), so both backends would feel different.
+The scene stays in ``turntable`` mode (Z-up, level horizon) and the
+non-default gestures are re-implemented in JS against the same gl-plot3d
+camera object plotly exposes, matching VTK: shift+left pan, ctrl+left spin,
+ctrl+shift+left / right dolly, middle pan. Plain left drag and the wheel stay
+on plotly's native path.
"""
from __future__ import annotations
@@ -108,9 +120,14 @@ _PAGE = """
var gd = document.getElementById('plot');
var scene = gd && gd._fullLayout ? gd._fullLayout.scene._scene : null;
var params = scene && scene.glplot ? scene.glplot.cameraParams : null;
- if (!params) return null;
+ var camera = scene && scene.camera ? scene.camera : null;
+ if (!params || !camera) return null;
+ // gl-plot3d only refreshes ``params.view`` on the render loop, so after a
+ // camera jump (view preset / relayout) it can be a frame behind. Recompute
+ // the live view matrix first so the marker lands on the settled camera.
+ camera.view.recalcMatrix(camera.view.lastT());
var matrix = mat4Multiply(
- mat4Multiply(params.projection, params.view), params.model
+ mat4Multiply(params.projection, camera.view.computedMatrix), params.model
);
var x = point[0], y = point[1], z = point[2];
var clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
@@ -285,12 +302,148 @@ _PAGE = """
if (!snapEnabled) return;
setSnapMarker(null);
});
+
+ installGestureHandlers(gd);
+ }
+
+ // --- mouse gestures (VTK/PyVista parity) -------------------------------
+ // plotly's built-in drag bindings clash with the PyVista backend's VTK
+ // trackball style, so both backends would feel different. We keep the
+ // turntable scene (Z-up, level horizon) and re-implement the non-default
+ // gestures, driving the same gl-plot3d camera object plotly exposes. Plain
+ // left drag and the wheel stay on plotly's native path.
+ var gesture = null;
+ var gesturesReady = false;
+
+ function plotCamera() {
+ var gd = document.getElementById('plot');
+ var scene = gd && gd._fullLayout ? gd._fullLayout.scene._scene : null;
+ return scene && scene.camera ? scene.camera : null;
+ }
+
+ function nowMs() {
+ return window.performance && window.performance.now ? window.performance.now() : Date.now();
+ }
+
+ function gestureModeFor(button, mods) {
+ // button: 0 left, 1 middle, 2 right.
+ if (button === 1) return 'pan';
+ if (button === 2) return mods.shift ? 'rotate' : 'zoom';
+ if (button !== 0) return null;
+ if (mods.ctrl && mods.shift) return 'zoom';
+ if (mods.ctrl) return 'roll'; // VTK spin
+ if (mods.shift) return 'pan';
+ if (mods.alt) return 'rotate'; // VTK ignores Alt: falls back to rotate
+ return null; // plain left drag -> plotly's native rotate
+ }
+
+ function applyGesture(camera, mode, dx, dy) {
+ // Mirror the maths gl-plot3d uses for its own mouse handler so the feel
+ // matches; dx/dy are normalised by the viewport height.
+ var view = camera.view;
+ var t = nowMs();
+ var distance = Math.exp(view.computedRadius[0]);
+ var speed = camera.translateSpeed || 1;
+ var drot = Math.PI * (camera.rotateSpeed || 1);
+ if (mode === 'rotate') {
+ view.rotate(t, -drot * dx, drot * dy, 0);
+ } else if (mode === 'pan') {
+ view.pan(t, -speed * dx * distance, speed * dy * distance, 0);
+ } else if (mode === 'zoom') {
+ view.pan(t, 0, 0, distance * (Math.exp(-3.0 * dy) - 1));
+ } else if (mode === 'roll') {
+ view.rotate(t, 0, 0, drot * dx);
+ }
+ }
+
+ function installGestureHandlers(gd) {
+ if (gesturesReady) return;
+ gesturesReady = true;
+
+ // Capture phase: run before plotly's own (bubble-phase) camera listener
+ // on the scene container, so keyBindingMode is already off when it sees
+ // the event and it no-ops for this gesture.
+ gd.addEventListener('mousedown', function (ev) {
+ if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
+ var mode = gestureModeFor(ev.button, {
+ ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey
+ });
+ if (!mode) return;
+ var camera = plotCamera();
+ if (!camera) return;
+ camera.keyBindingMode = false;
+ gesture = { mode: mode, x: ev.clientX, y: ev.clientY };
+ }, true);
+
+ window.addEventListener('mousemove', function (ev) {
+ if (!gesture) return;
+ var camera = plotCamera();
+ if (!camera) { gesture = null; return; }
+ // A Plotly.react mid-gesture re-enables plotly's handler; keep it off.
+ camera.keyBindingMode = false;
+ var height = camera.element.clientHeight || 1;
+ var dx = (ev.clientX - gesture.x) / height;
+ var dy = (ev.clientY - gesture.y) / height;
+ gesture.x = ev.clientX;
+ gesture.y = ev.clientY;
+ applyGesture(camera, gesture.mode, dx, dy);
+ }, true);
+
+ window.addEventListener('mouseup', function () {
+ if (!gesture) return;
+ var camera = plotCamera();
+ gesture = null;
+ // Restore plotly's rotate binding. Its mouseup runs with buttons === 0,
+ // so it only refreshes its bookkeeping and never jumps the camera.
+ if (camera) camera.keyBindingMode = 'rotate';
+ }, true);
}
window.otkoSetCamera = function (cameraJson) {
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
};
+ // --- frame animation ---------------------------------------------------
+ // A pre-built frame list is pushed once and played by plotly itself, so a
+ // mode shape / time history animates in-page instead of re-reacting the
+ // whole figure ~30x per second from Python.
+ window.otkoAnimate = function (payloadJson, durationMs, loops, preserveView) {
+ var payload;
+ try { payload = JSON.parse(payloadJson); } catch (err) {
+ if (window.console) console.error('otko: bad animation payload', err);
+ return;
+ }
+ pendingUpdate = null; // the animation owns the graph div
+ var names = payload.frames.map(function (frame, index) {
+ frame.name = 'otko-' + index;
+ return frame.name;
+ });
+ var sequence = [];
+ var repeat = Math.max(1, loops || 1);
+ for (var i = 0; i < repeat; i++) { sequence = sequence.concat(names); }
+ mergeView(payload, preserveView ? currentView() : null);
+ Plotly.react('plot', payload.data, payload.layout, config).then(function () {
+ return Plotly.addFrames('plot', payload.frames);
+ }).then(function () {
+ return Plotly.animate('plot', sequence, {
+ frame: { duration: durationMs, redraw: true },
+ transition: { duration: 0 },
+ fromcurrent: false,
+ mode: 'immediate'
+ });
+ }).then(function () {
+ installHandlers();
+ }, function (err) {
+ // An interrupt (a newer animate call, or otkoStopAnimation) rejects with
+ // no reason; only surface real failures.
+ if (err && window.console) console.error('otko: animate failed', err);
+ });
+ };
+
+ window.otkoStopAnimation = function () {
+ Plotly.animate('plot', [], { mode: 'immediate', transition: { duration: 0 } });
+ };
+
window.otkoSetSnapEnabled = function (on) {
snapEnabled = !!on;
if (!snapEnabled) setSnapMarker(null);
diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py
index ddecb15..1ea3306 100644
--- a/src/otko/views/canvas_plotly/plotly_canvas.py
+++ b/src/otko/views/canvas_plotly/plotly_canvas.py
@@ -38,6 +38,7 @@ from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder,
Scene,
SceneOptions,
+ framed_camera_distance,
)
#: View preset directions (unit-ish vectors from the scene centre to the eye).
@@ -149,7 +150,9 @@ class PlotlyCanvas(QWidget):
#: Force diagrams and off-screen video capture are not implemented on
#: this backend yet (both are PyVista-specific today).
- capabilities = CanvasCapabilities(diagrams=False, animation_export=False)
+ capabilities = CanvasCapabilities(
+ diagrams=False, animation_export=False, in_page_animation=True
+ )
def __init__(
self,
@@ -170,6 +173,7 @@ class PlotlyCanvas(QWidget):
self._parallel = False
self._view_preset = "iso"
self._snap_enabled = False
+ self._show_undeformed = False
self._default_selection_enabled = True
self._working_plane: tuple[str, float] | None = None
self._camera = _CameraShim(self)
@@ -308,6 +312,45 @@ class PlotlyCanvas(QWidget):
self._options = replace(self._options, show_local_axes=bool(enabled))
self.render()
+ def set_show_undeformed(self, enabled: bool) -> None:
+ """Faint undeformed reference behind a deformed / modal shape."""
+ self._show_undeformed = bool(enabled)
+ self.render()
+
+ def base_scene_options(self) -> SceneOptions:
+ """The current display options, for callers building animation frames."""
+ return replace(self._options, show_undeformed=self._show_undeformed)
+
+ def animate(
+ self,
+ options: list[SceneOptions],
+ *,
+ duration_ms: int = 40,
+ loops: int = 100,
+ ) -> None:
+ """Play a pre-built list of scenes as an in-page plotly animation.
+
+ Each entry's traces are built once and handed to plotly's frame
+ machinery, so playback does not round-trip through Python per frame.
+ """
+ if not self._ready or not options:
+ return
+ frames = [self._builder.build(self._project, opts).data for opts in options]
+ payload = json.dumps(
+ {
+ "data": frames[0],
+ "frames": [{"data": frame} for frame in frames],
+ "layout": dict(self._scene.layout),
+ }
+ )
+ self._eval(
+ f"window.otkoAnimate({json.dumps(payload)}, {int(duration_ms)}, {int(loops)}, true)"
+ )
+
+ def stop_animation(self) -> None:
+ """Stop any in-page plotly animation."""
+ self._eval("window.otkoStopAnimation()")
+
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the figure.
@@ -344,7 +387,10 @@ class PlotlyCanvas(QWidget):
``reset_camera``) sends the computed framing. Style changes are
non-framing: they re-colour in place.
"""
- self._scene = self._builder.build(self._project, self._options)
+ self._scene = self._builder.build(
+ self._project,
+ replace(self._options, show_undeformed=self._show_undeformed),
+ )
if not self._ready:
# Nothing to push yet; crucially this must come *before* the
# framing flag is consumed, or the framing scheduled before
@@ -363,20 +409,31 @@ class PlotlyCanvas(QWidget):
self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
- cx, cy, cz = scene.center
- distance = max(scene.diagonal, 1e-6) * 1.6
+ """A framing camera in plotly's normalized scene coordinates.
+
+ plotly does not use data units for ``scene.camera``: gl-plot3d scales
+ the scene box by the trace extents and re-centres it on the origin, so
+ the camera lives in a normalized space (the origin is the model centre
+ and the default eye is only ~1.25 away). Passing a data-unit eye — the
+ old ``diagonal * 1.6`` — parked the camera hundreds of normalized units
+ out and rendered the model as a speck. So: centre on the origin, and
+ take the eye distance from the model's *normalized* bounding sphere.
+ """
+ distance = framed_camera_distance(scene)
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
eye = (
- cx + direction[0] / norm * distance,
- cy + direction[1] / norm * distance,
- cz + direction[2] / norm * distance,
+ direction[0] / norm * distance,
+ direction[1] / norm * distance,
+ direction[2] / norm * distance,
)
- # Looking straight down the Z axis needs a non-degenerate up vector.
- up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0)
+ # Turntable dragmode pins up to +Z; the turntable controller derives a
+ # well-defined screen-up from the view angle even for the straight-down
+ # Top (XY) preset, so no special-case up vector is needed.
+ up = (0.0, 0.0, 1.0)
return {
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
- "center": {"x": cx, "y": cy, "z": cz},
+ "center": {"x": 0.0, "y": 0.0, "z": 0.0},
"up": {"x": up[0], "y": up[1], "z": up[2]},
"projection": {"type": "orthographic" if self._parallel else "perspective"},
}
diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py
index ebe5e13..37c0577 100644
--- a/src/otko/views/canvas_plotly/trace_builder.py
+++ b/src/otko/views/canvas_plotly/trace_builder.py
@@ -24,7 +24,9 @@ tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads
from __future__ import annotations
-from dataclasses import dataclass, field
+import math
+from dataclasses import dataclass, field, replace
+from itertools import pairwise
from typing import Any
import numpy as np
@@ -76,7 +78,7 @@ _BOX_TRIS = (
(1, 6, 5),
)
-#: Support kind → plotly 3D marker symbol.
+#: Support kind → plotly 3D marker symbol (fallback for rotation-only supports).
_SUPPORT_SYMBOLS = {
"fix": "square",
"pin": "triangle-up",
@@ -88,6 +90,18 @@ _NODE_MARKER_SIZE = 7.0
_SUPPORT_MARKER_SIZE = 11.0
_SNAP_MARKER_SIZE = 8.0
+#: Distinct colours cycled across load patterns (opstool tints each pattern).
+_PATTERN_PALETTE = (
+ "#1f77b4",
+ "#ff7f0e",
+ "#2ca02c",
+ "#d62728",
+ "#9467bd",
+ "#8c564b",
+ "#e377c2",
+ "#17becf",
+)
+
@dataclass(frozen=True)
class SceneOptions:
@@ -102,6 +116,13 @@ class SceneOptions:
show_element_labels: bool = False
show_extrusions: bool = False
show_local_axes: bool = False
+ #: Draw the undeformed shape faintly behind a deformed / modal shape.
+ show_undeformed: bool = False
+ #: Per-node scalar response (aligned with ``project.nodes``), coloured
+ #: through a shared colour axis with a colorbar — opstool's contour recipe.
+ scalars: Any = None
+ scalar_label: str = ""
+ scalar_clim: tuple[float, float] | None = None
@dataclass
@@ -114,6 +135,9 @@ class Scene:
diagonal: float = 1.0
#: Padded ``(min, max)`` per axis, used to frame the camera deterministically.
axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
+ #: Unpadded model ``(min, max)`` per axis (no grid), used to normalise the
+ #: camera the same way plotly normalises the scene box.
+ data_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
def to_payload(self) -> dict[str, Any]:
"""Figure dict without the camera — camera is owned by the widget."""
@@ -213,6 +237,54 @@ def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float:
return diagonal if diagonal > 0 else 1.0
+def _raw_axis_bounds(pts: np.ndarray | None) -> dict[str, tuple[float, float]]:
+ """Unpadded model ``(min, max)`` per axis.
+
+ plotly scales the scene box by the *trace* extents, so the camera frame
+ distance has to be computed from these (grid-free, unpadded) bounds.
+ Degenerate axes keep a zero extent; callers substitute a unit scale.
+ """
+ if pts is None or len(pts) == 0:
+ return {name: (0.0, 0.0) for name in ("x", "y", "z")}
+ lower = np.asarray(pts, dtype=float).min(axis=0)
+ upper = np.asarray(pts, dtype=float).max(axis=0)
+ return {
+ name: (float(lower[index]), float(upper[index]))
+ for index, name in enumerate(("x", "y", "z"))
+ }
+
+
+#: gl-plot3d's fixed vertical field of view (radians).
+_FOV_Y = math.pi / 4
+#: How much of the viewport the model's bounding sphere should span.
+_FRAME_MARGIN = 1.25
+
+
+def framed_camera_distance(scene: Scene) -> float:
+ """Eye distance, in plotly's normalized scene units, that fits the model.
+
+ ``layout.scene.camera`` is not in data units: gl-plot3d re-centres the
+ scene on the origin and, with ``aspectmode`` ``data``, scales each axis by
+ ``product(data_scale) ** (1/3) / data_scale`` (plotly's ``scene.js``) — so
+ the default eye is only ~1.25 away. Mirror that to get the model's
+ bounding-sphere radius in the camera's own units, then back off by half the
+ field of view so the model fills the viewport with a small margin.
+ """
+ bounds = scene.data_bounds or scene.axis_bounds
+ if len(bounds) < 3:
+ return 1.6
+ scales = []
+ for low, high in bounds.values():
+ extent = high - low
+ scales.append(1.0 / extent if extent > 1e-12 else 1.0)
+ axis_scale = (scales[0] * scales[1] * scales[2]) ** (1.0 / 3.0)
+ aspect = [axis_scale / scale for scale in scales]
+ radius = 0.5 * math.sqrt(sum(value * value for value in aspect))
+ if radius <= 1e-12:
+ return 1.6
+ return radius / math.sin(_FOV_Y / 2.0) * _FRAME_MARGIN
+
+
def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
x = x_local / float(np.linalg.norm(x_local))
@@ -262,6 +334,104 @@ def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) ->
return "custom"
+def _scalar_clim(scalars: Any, clim: tuple[float, float] | None) -> tuple[float, float]:
+ """Colour limits for a scalar array, falling back to its finite range."""
+ if clim is not None:
+ return float(clim[0]), float(clim[1])
+ values = np.asarray(scalars, dtype=float).ravel()
+ values = values[np.isfinite(values)]
+ if not len(values):
+ return 0.0, 1.0
+ low, high = float(values.min()), float(values.max())
+ if high <= low:
+ high = low + 1.0
+ return low, high
+
+
+def _support_loops(
+ coord: tuple[float, float, float],
+ restraint: tuple[bool, ...],
+ size: float,
+ ndm: int,
+) -> list[list[tuple[float, float, float]]]:
+ """Closed polylines for a support's restrained translation DOFs.
+
+ Adapted from opstool's ``_get_bc_points_3d`` / ``_get_bc_points_2d``
+ (GPL-3.0; see ``NOTICE``). Each fixed translation axis gets a plate
+ perpendicular to it; a 2D roller gets a circle. Returns an empty list when
+ only rotations are restrained (the caller falls back to a marker).
+ """
+ x, y, z = coord
+ s = size
+ loops: list[list[tuple[float, float, float]]] = []
+ if ndm >= 3:
+ if restraint[0]:
+ loops.append(
+ [
+ (x, y - s / 2, z - s / 2),
+ (x, y + s / 2, z - s / 2),
+ (x, y + s / 2, z + s / 2),
+ (x, y - s / 2, z + s / 2),
+ (x, y - s / 2, z - s / 2),
+ ]
+ )
+ if restraint[1]:
+ loops.append(
+ [
+ (x - s / 2, y, z - s / 2),
+ (x + s / 2, y, z - s / 2),
+ (x + s / 2, y, z + s / 2),
+ (x - s / 2, y, z + s / 2),
+ (x - s / 2, y, z - s / 2),
+ ]
+ )
+ if restraint[2]:
+ loops.append(
+ [
+ (x - s / 2, y - s / 2, z),
+ (x + s / 2, y - s / 2, z),
+ (x + s / 2, y + s / 2, z),
+ (x - s / 2, y + s / 2, z),
+ (x - s / 2, y - s / 2, z),
+ ]
+ )
+ elif restraint[2]:
+ yb = y - s / 2
+ loops.append(
+ [
+ (x - s / 2, yb - s / 2, z),
+ (x + s / 2, yb - s / 2, z),
+ (x + s / 2, yb + s / 2, z),
+ (x - s / 2, yb + s / 2, z),
+ (x - s / 2, yb - s / 2, z),
+ ]
+ )
+ elif restraint[0] and restraint[1]:
+ loops.append(
+ [
+ (x - s * 0.5, y - s, z),
+ (x + s * 0.5, y - s, z),
+ (x, y, z),
+ (x - s * 0.5, y - s, z),
+ ]
+ )
+ elif restraint[0] or restraint[1]:
+ angles = np.linspace(0.0, 2.0 * np.pi, 17)
+ ox = x - s / 2 if restraint[0] else x
+ oy = y - s / 2 if restraint[1] else y
+ loops.append(
+ [
+ (
+ float(ox + 0.5 * s * math.cos(angle)),
+ float(oy + 0.5 * s * math.sin(angle)),
+ z,
+ )
+ for angle in angles
+ ]
+ )
+ return loops
+
+
def _line_trace(
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
*,
@@ -318,12 +488,28 @@ class PlotlyTraceBuilder:
nodes = list(project.nodes)
node_ids = [n.id for n in nodes]
points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3))
+ original_points = points.copy()
if opts.deformation is not None and len(points):
points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float)
node_row = {nid: i for i, nid in enumerate(node_ids)}
+ # Response colouring: auto-derive a per-node scalar from the deformation
+ # source (displacement magnitude) unless the caller supplied one.
+ if opts.scalars is None and opts.deformation is not None:
+ get_magnitudes = getattr(opts.deformation, "magnitudes", None)
+ if callable(get_magnitudes) and len(node_ids):
+ opts = replace(
+ opts,
+ scalars=get_magnitudes(node_ids),
+ scalar_label=opts.scalar_label or "|u|",
+ )
+ elif opts.scalars is not None:
+ opts = replace(opts, scalars=np.asarray(opts.scalars, dtype=float))
+
data: list[dict[str, Any]] = []
grid_pts = self._build_grid(project, data, opts)
+ if opts.show_undeformed and opts.deformation is not None and len(points):
+ self._build_undeformed_reference(project, data, original_points, node_row)
self._build_extrusions(project, data, opts)
self._build_local_axes(project, data, opts)
self._build_loads(project, data, opts)
@@ -332,18 +518,24 @@ class PlotlyTraceBuilder:
self._build_nodes(data, opts, points, node_ids)
self._build_labels(project, data, opts, points, node_row)
- candidates = [points] if len(points) else []
- if grid_pts is not None:
- candidates.append(grid_pts)
- all_pts = np.vstack(candidates) if candidates else np.empty((0, 3))
- center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0)
- bounds = _padded_axis_bounds(all_pts)
+ # Frame the structure, not the (often much larger) reference grid:
+ # SAP-style "zoom extents" fits the model. The grid still draws, but
+ # does not shrink the model into a corner of the viewport.
+ if len(points):
+ frame_pts = points
+ elif grid_pts is not None:
+ frame_pts = grid_pts
+ else:
+ frame_pts = np.empty((0, 3))
+ center = tuple(np.mean(frame_pts, axis=0)) if len(frame_pts) else (0.0, 0.0, 0.0)
+ bounds = _padded_axis_bounds(frame_pts)
return Scene(
data=data,
layout=self._layout(),
center=(float(center[0]), float(center[1]), float(center[2])),
diagonal=_diag_of_bounds(bounds),
axis_bounds=bounds,
+ data_bounds=_raw_axis_bounds(frame_pts),
)
# ── layout ───────────────────────────────────────────────────────
@@ -376,7 +568,10 @@ class PlotlyTraceBuilder:
"scene": {
"bgcolor": style.background_bottom,
"aspectmode": "data",
- "dragmode": "orbit",
+ # Turntable (plotly's CAD-style orbit) locks ``camera.up`` to
+ # +Z, so the horizon stays level. plotly's ``orbit`` mode
+ # rotates the up vector instead and tips the model over.
+ "dragmode": "turntable",
"xaxis": axis(style.fix_color, "X"),
"yaxis": axis(style.load_color, "Y"),
"zaxis": axis(style.truss_color, "Z"),
@@ -526,12 +721,41 @@ class PlotlyTraceBuilder:
) -> None:
if not len(points):
return
- colors = [
- self._style.node_selected_color
- if nid in opts.selection_nodes
- else self._style.node_color
- for nid in node_ids
- ]
+ scalars = opts.scalars
+ if scalars is not None and len(scalars) == len(points):
+ low, high = _scalar_clim(scalars, opts.scalar_clim)
+ marker: dict[str, Any] = {
+ "color": [float(value) for value in scalars],
+ "colorscale": self._style.response_colorscale(),
+ "cmin": low,
+ "cmax": high,
+ "size": _NODE_MARKER_SIZE,
+ "line": {"color": "#4d4d4d", "width": 1},
+ "showscale": True,
+ "colorbar": {
+ "title": {"text": opts.scalar_label or "value", "side": "right"},
+ "thickness": 14,
+ "len": 0.6,
+ },
+ }
+ customdata: list[Any] = [
+ [int(nid), float(value)] for nid, value in zip(node_ids, scalars, strict=True)
+ ]
+ hovertemplate = "Node #%{customdata[0]}
%{customdata[1]:.4g}"
+ else:
+ colors = [
+ self._style.node_selected_color
+ if nid in opts.selection_nodes
+ else self._style.node_color
+ for nid in node_ids
+ ]
+ marker = {
+ "color": colors,
+ "size": _NODE_MARKER_SIZE,
+ "line": {"color": "#4d4d4d", "width": 1},
+ }
+ customdata = list(node_ids)
+ hovertemplate = "Node #%{customdata}"
data.append(
{
"type": "scatter3d",
@@ -539,16 +763,12 @@ class PlotlyTraceBuilder:
"x": [float(p[0]) for p in points],
"y": [float(p[1]) for p in points],
"z": [float(p[2]) for p in points],
- "marker": {
- "color": colors,
- "size": _NODE_MARKER_SIZE,
- "line": {"color": "#4d4d4d", "width": 1},
- },
- "customdata": list(node_ids),
+ "marker": marker,
+ "customdata": customdata,
"meta": {"kind": "node"},
# Engineering-notation hover (opstool's trace recipe) so a
# hover identifies the entity instead of showing the raw id.
- "hovertemplate": "Node #%{customdata}",
+ "hovertemplate": hovertemplate,
"name": "nodes",
"showlegend": False,
}
@@ -573,13 +793,16 @@ class PlotlyTraceBuilder:
"""
if not len(points):
return
+ scalar_values: np.ndarray | None = None
+ if opts.scalars is not None and len(opts.scalars) == len(points):
+ scalar_values = np.asarray(opts.scalars, dtype=float)
scale_colors = family_palette(self._style)
selected_index = len(scale_colors) - 1
x: list[float | None] = []
y: list[float | None] = []
z: list[float | None] = []
- color_index: list[float] = []
+ color_value: list[float | None] = []
customdata: list[Any] = []
for el in project.elements:
if not isinstance(el, _FRAME_CLASSES):
@@ -588,18 +811,40 @@ class PlotlyTraceBuilder:
j = node_row.get(el.nodes[1])
if i is None or j is None:
continue
- index = float(
- selected_index if el.id in opts.selection_elements else element_family_index(el)
- )
x.extend([float(points[i][0]), float(points[j][0]), None])
y.extend([float(points[i][1]), float(points[j][1]), None])
z.extend([float(points[i][2]), float(points[j][2]), None])
- color_index.extend([index, index, index])
+ if scalar_values is not None:
+ color_value.extend([float(scalar_values[i]), float(scalar_values[j]), None])
+ else:
+ index = float(
+ selected_index if el.id in opts.selection_elements else element_family_index(el)
+ )
+ color_value.extend([index, index, index])
customdata.extend([el.id, el.id, None])
if not x:
return
- count = len(scale_colors)
+ if scalar_values is not None:
+ low, high = _scalar_clim(scalar_values, opts.scalar_clim)
+ line: dict[str, Any] = {
+ "color": color_value,
+ "colorscale": self._style.response_colorscale(),
+ "cmin": low,
+ "cmax": high,
+ "width": 4,
+ }
+ else:
+ count = len(scale_colors)
+ line = {
+ "color": color_value,
+ "colorscale": [
+ (index / (count - 1), color) for index, color in enumerate(scale_colors)
+ ],
+ "cmin": 0,
+ "cmax": count - 1,
+ "width": 4,
+ }
data.append(
{
"type": "scatter3d",
@@ -607,15 +852,7 @@ class PlotlyTraceBuilder:
"x": x,
"y": y,
"z": z,
- "line": {
- "color": color_index,
- "colorscale": [
- (index / (count - 1), color) for index, color in enumerate(scale_colors)
- ],
- "cmin": 0,
- "cmax": count - 1,
- "width": 4,
- },
+ "line": line,
"customdata": customdata,
"meta": {"kind": "element"},
"hovertemplate": "Element #%{customdata}",
@@ -624,34 +861,103 @@ class PlotlyTraceBuilder:
}
)
+ def _build_undeformed_reference(
+ self,
+ project: Project,
+ data: list[dict[str, Any]],
+ original_points: np.ndarray,
+ node_row: dict[int, int],
+ ) -> None:
+ """Faint grey wireframe of the undeformed shape (opstool ``show_origin``)."""
+ x: list[float | None] = []
+ y: list[float | None] = []
+ z: list[float | None] = []
+ for el in project.elements:
+ if not isinstance(el, _FRAME_CLASSES):
+ continue
+ i = node_row.get(el.nodes[0])
+ j = node_row.get(el.nodes[1])
+ if i is None or j is None:
+ continue
+ x.extend([float(original_points[i][0]), float(original_points[j][0]), None])
+ y.extend([float(original_points[i][1]), float(original_points[j][1]), None])
+ z.extend([float(original_points[i][2]), float(original_points[j][2]), None])
+ if not x:
+ return
+ data.append(
+ {
+ "type": "scatter3d",
+ "mode": "lines",
+ "x": x,
+ "y": y,
+ "z": z,
+ "line": {"color": "#9aa3ad", "width": 1},
+ "opacity": 0.45,
+ "hoverinfo": "skip",
+ "name": "undeformed-reference",
+ "showlegend": False,
+ }
+ )
+
def _build_supports(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
) -> None:
+ """Oriented support glyphs for each restrained translation DOF.
+
+ Ported and modified from opstool's ``_get_bc_points_3d/_2d`` (GPL-3.0;
+ see ``NOTICE``): a fixed axis is drawn as a plate perpendicular to it,
+ a roller as a circle, so the restrained directions are legible instead
+ of a generic square/triangle marker.
+ """
if not project.nodes:
return
+ pts = np.array([n.coords for n in project.nodes], dtype=float)
+ size = max(_diag_of_points(pts) * 0.03, 1e-6)
+ ndm = int(getattr(project, "ndm", 3))
+ segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
+ markers: list[Any] = []
dof_idx = _dof_indices(project.ndf)
- groups: dict[str, list[Any]] = {}
for node in project.nodes:
- if not any(node.restraint[i] for i in dof_idx):
+ restraint = tuple(bool(flag) for flag in node.restraint)
+ if not any(restraint[index] for index in dof_idx):
continue
- kind = _classify_support(node.restraint, dof_idx)
- groups.setdefault(kind, []).append(node)
- for kind, nodes in groups.items():
+ loops = _support_loops(
+ (float(node.coords[0]), float(node.coords[1]), float(node.coords[2])),
+ restraint,
+ size,
+ ndm,
+ )
+ if loops:
+ for loop in loops:
+ for a, b in pairwise(loop):
+ segments.append((a, b))
+ else:
+ markers.append(node)
+ if segments:
+ data.append(
+ _line_trace(
+ segments,
+ color=self._style.support_color,
+ width=3,
+ name="supports",
+ )
+ )
+ if markers:
data.append(
{
"type": "scatter3d",
"mode": "markers",
- "x": [float(n.coords[0]) for n in nodes],
- "y": [float(n.coords[1]) for n in nodes],
- "z": [float(n.coords[2]) for n in nodes],
+ "x": [float(n.coords[0]) for n in markers],
+ "y": [float(n.coords[1]) for n in markers],
+ "z": [float(n.coords[2]) for n in markers],
"marker": {
"color": self._style.support_color,
"size": _SUPPORT_MARKER_SIZE,
- "symbol": _SUPPORT_SYMBOLS[kind],
+ "symbol": "square",
"line": {"color": "#7f3f00", "width": 1},
},
"hoverinfo": "skip",
- "name": f"support-{kind}",
+ "name": "supports",
"showlegend": False,
}
)
@@ -660,6 +966,12 @@ class PlotlyTraceBuilder:
def _build_loads(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
) -> None:
+ """Arrow glyphs for nodal and distributed loads.
+
+ Arrow length is proportional to the load magnitude (opstool scales by
+ ``|F| / max|F|``), so a 1 kN and a 10 kN load no longer look identical,
+ and each load pattern gets its own colour plus a hover readout.
+ """
if not project.load_patterns or not project.nodes:
return
node_by_id = {n.id: n for n in project.nodes}
@@ -667,22 +979,16 @@ class PlotlyTraceBuilder:
pts = np.array([n.coords for n in project.nodes], dtype=float)
scale = max(_diag_of_points(pts) * 0.05, 1e-6)
- nodal_x: list[float] = []
- nodal_y: list[float] = []
- nodal_z: list[float] = []
- nodal_u: list[float] = []
- nodal_v: list[float] = []
- nodal_w: list[float] = []
- dist_x: list[float] = []
- dist_y: list[float] = []
- dist_z: list[float] = []
- dist_u: list[float] = []
- dist_v: list[float] = []
- dist_w: list[float] = []
+ patterns = [
+ pattern for pattern in project.load_patterns if isinstance(pattern, PlainLoadPattern)
+ ]
+ if not patterns:
+ return
- for pattern in project.load_patterns:
- if not isinstance(pattern, PlainLoadPattern):
- continue
+ # (pattern index, tail, unit direction, magnitude, subject label)
+ nodal: list[tuple[int, np.ndarray, np.ndarray, float, str]] = []
+ dist: list[tuple[int, np.ndarray, np.ndarray, float, str]] = []
+ for pat_idx, pattern in enumerate(patterns):
for nload in pattern.nodal_loads:
if not isinstance(nload, NodalLoad):
continue
@@ -693,15 +999,15 @@ class PlotlyTraceBuilder:
mag = float(np.linalg.norm(f))
if mag < 1e-12:
continue
- direction = f / mag
- tail = np.asarray(node.coords, dtype=float) - direction * scale
- nodal_x.append(float(tail[0]))
- nodal_y.append(float(tail[1]))
- nodal_z.append(float(tail[2]))
- nodal_u.append(float(direction[0]))
- nodal_v.append(float(direction[1]))
- nodal_w.append(float(direction[2]))
-
+ nodal.append(
+ (
+ pat_idx,
+ np.asarray(node.coords, dtype=float),
+ f / mag,
+ mag,
+ f"N{nload.node_id}",
+ )
+ )
for eload in pattern.element_loads:
if not isinstance(eload, UniformElementLoad):
continue
@@ -730,45 +1036,60 @@ class PlotlyTraceBuilder:
if mag < 1e-12:
continue
direction = load_vec / mag
- n_arrows = 5
- for k in range(n_arrows):
- t = (k + 0.5) / n_arrows
- tail = pi + t * axis - direction * (0.4 * scale)
- dist_x.append(float(tail[0]))
- dist_y.append(float(tail[1]))
- dist_z.append(float(tail[2]))
- dist_u.append(float(direction[0]))
- dist_v.append(float(direction[1]))
- dist_w.append(float(direction[2]))
+ for k in range(5):
+ t = (k + 0.5) / 5.0
+ dist.append((pat_idx, pi + t * axis, direction, mag, f"E{eload.element_id}"))
- if nodal_x:
- data.append(
- _cone_trace(
- nodal_x,
- nodal_y,
- nodal_z,
- nodal_u,
- nodal_v,
- nodal_w,
- color=self._style.nodal_load_color,
- name="nodal-loads",
- size=scale,
+ max_mag = max((entry[3] for entry in (*nodal, *dist)), default=0.0)
+ if max_mag <= 0.0:
+ return
+ colors = self._pattern_colors(len(patterns))
+ # With a single pattern there is nothing to distinguish, so keep the
+ # per-type default tints (nodal vs element loads).
+ single = len(patterns) == 1
+
+ def _arrow_len(mag: float) -> float:
+ return max(scale * mag / max_mag, 0.15 * scale)
+
+ for pat_idx, pattern in enumerate(patterns):
+ label = getattr(pattern, "name", "") or f"Pattern {pat_idx + 1}"
+ for group, name, width, default in (
+ (nodal, "nodal-loads", 0.12, self._style.nodal_load_color),
+ (dist, "element-loads", 0.12, self._style.element_load_color),
+ ):
+ entries = [entry for entry in group if entry[0] == pat_idx]
+ if not entries:
+ continue
+ color = default if single else colors[pat_idx]
+ x, y, z, u, v, w, hovers = [], [], [], [], [], [], []
+ for _idx, tail, direction, mag, subject in entries:
+ length = _arrow_len(mag)
+ x.append(float(tail[0]))
+ y.append(float(tail[1]))
+ z.append(float(tail[2]))
+ u.append(float(direction[0] * length))
+ v.append(float(direction[1] * length))
+ w.append(float(direction[2] * length))
+ hovers.append(f"{label} · {subject}
|F| = {mag:.4g}")
+ data.append(
+ _cone_trace(
+ x,
+ y,
+ z,
+ u,
+ v,
+ w,
+ color=color,
+ name=name,
+ size=width,
+ customdata=hovers,
+ )
)
- )
- if dist_x:
- data.append(
- _cone_trace(
- dist_x,
- dist_y,
- dist_z,
- dist_u,
- dist_v,
- dist_w,
- color=self._style.element_load_color,
- name="element-loads",
- size=0.6 * scale,
- )
- )
+
+ def _pattern_colors(self, count: int) -> list[str]:
+ """One palette colour per load pattern (used only when count > 1)."""
+ palette = _PATTERN_PALETTE
+ return [palette[index % len(palette)] for index in range(count)]
def _build_local_axes(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
@@ -814,9 +1135,10 @@ class PlotlyTraceBuilder:
bucket["x"].append(float(mid[0]))
bucket["y"].append(float(mid[1]))
bucket["z"].append(float(mid[2]))
- bucket["u"].append(float(direction[0]))
- bucket["v"].append(float(direction[1]))
- bucket["w"].append(float(direction[2]))
+ # Cone length = (u, v, w) magnitude: scale to the model.
+ bucket["u"].append(float(direction[0] * cap))
+ bucket["v"].append(float(direction[1] * cap))
+ bucket["w"].append(float(direction[2] * cap))
for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")):
bucket = axes[key]
if bucket["x"]:
@@ -830,7 +1152,7 @@ class PlotlyTraceBuilder:
bucket["w"],
color=color,
name=f"local-{key}",
- size=cap,
+ size=0.12,
)
)
@@ -958,9 +1280,17 @@ def _cone_trace(
*,
color: str,
name: str,
- size: float,
+ size: float = 0.4,
+ customdata: list[Any] | None = None,
) -> dict[str, Any]:
- return {
+ """A cone/arrow trace.
+
+ ``sizemode`` stays ``"scaled"``: cone size is then proportional to the
+ vector norm times the unitless ``sizeref``, so every arrow keeps the same
+ aspect ratio. ``"absolute"`` interprets ``sizeref`` against the vector
+ norm in *normalized* scene units, which rendered fat fins on large models.
+ """
+ trace: dict[str, Any] = {
"type": "cone",
"x": x,
"y": y,
@@ -969,7 +1299,7 @@ def _cone_trace(
"v": v,
"w": w,
"anchor": "tail",
- "sizemode": "absolute",
+ "sizemode": "scaled",
"sizeref": float(size),
"colorscale": [[0, color], [1, color]],
"showscale": False,
@@ -977,6 +1307,11 @@ def _cone_trace(
"name": name,
"showlegend": False,
}
+ if customdata is not None:
+ trace["customdata"] = customdata
+ trace["hovertemplate"] = "%{customdata}"
+ trace["hoverinfo"] = "text"
+ return trace
def _text_trace(
diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py
index e4dff02..59bfb83 100644
--- a/src/otko/views/dialogs/__init__.py
+++ b/src/otko/views/dialogs/__init__.py
@@ -30,6 +30,7 @@ from otko.views.dialogs.locate_origin import (
from otko.views.dialogs.material_library import MaterialLibraryDialog
from otko.views.dialogs.material_tester import MaterialTesterDialog
from otko.views.dialogs.mirror import MirrorDialog
+from otko.views.dialogs.mouse_controls import MouseControlsDialog
from otko.views.dialogs.move import MoveDialog
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
@@ -66,6 +67,7 @@ __all__ = [
"MaterialLibraryDialog",
"MaterialTesterDialog",
"MirrorDialog",
+ "MouseControlsDialog",
"MoveDialog",
"PathTimeSeriesDialog",
"PatternLoadsDialog",
diff --git a/src/otko/views/dialogs/mouse_controls.py b/src/otko/views/dialogs/mouse_controls.py
new file mode 100644
index 0000000..c3e9340
--- /dev/null
+++ b/src/otko/views/dialogs/mouse_controls.py
@@ -0,0 +1,68 @@
+"""Modeless Mouse Controls reference for the 3D viewport.
+
+Both canvas backends share the VTK trackball convention: the PyVista canvas
+uses it natively and the Plotly canvas re-implements it in JS, so the table
+below holds whichever backend is active.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QTextBrowser,
+ QVBoxLayout,
+ QWidget,
+)
+
+_MOUSE_HTML = """Mouse Controls
+The 3D viewport follows the VTK trackball convention on both canvas
+backends (Options → Canvas Backend).
+
+Navigate the view
+
+| Rotate | Left-drag |
+| Pan | Shift + left-drag, or middle-drag |
+| Zoom | Right-drag, or mouse wheel |
+| Spin (roll) | Ctrl + left-drag |
+| Zoom (from key) | Ctrl + Shift + left-drag |
+| Environment rotate | Shift + right-drag |
+
+Alt is not used for view navigation. Horizontal-wheel input rolls the
+Plotly view; on PyVista it has no effect.
+
+Select
+
+| Select | Left-click a node or element |
+| Add / toggle | Ctrl+click or Shift+click |
+
+A drag rotates the view; only a press-and-release without movement counts
+as a click, so panning never changes the selection.
+
+View presets
+
+| Isometric | Ctrl+1 |
+| Top (XY) | Ctrl+2 |
+| Front (XZ) | Ctrl+3 |
+| Right (YZ) | Ctrl+4 |
+| Zoom Extents | Ctrl+E |
+
+"""
+
+
+class MouseControlsDialog(QDialog):
+ """Modeless reference for viewport mouse navigation and selection."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Mouse Controls")
+ self.setMinimumSize(460, 520)
+ layout = QVBoxLayout(self)
+ browser = QTextBrowser(self)
+ browser.setReadOnly(True)
+ browser.setOpenExternalLinks(False)
+ browser.setHtml(_MOUSE_HTML)
+ layout.addWidget(browser)
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
+ buttons.rejected.connect(self.close)
+ layout.addWidget(buttons)
diff --git a/src/otko/views/dialogs/quick_guide.py b/src/otko/views/dialogs/quick_guide.py
index 9b0c424..3e1af3e 100644
--- a/src/otko/views/dialogs/quick_guide.py
+++ b/src/otko/views/dialogs/quick_guide.py
@@ -50,6 +50,10 @@ or Assign → Frame: Section…, Material…,
Show Force Diagram… and the other plot actions;
Display → Show Undeformed Shape, Clear Display or
Back to Model View (Ctrl+Shift+B) to return to the model.
+
+8 — Navigate the 3D view
+Left-drag rotates, Shift+left-drag (or middle-drag) pans, right-drag or the
+wheel zooms, Ctrl+left-drag spins. Full list under Help → Mouse Controls.
"""
diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py
index cd34449..118da67 100644
--- a/src/otko/views/dock_manager.py
+++ b/src/otko/views/dock_manager.py
@@ -197,11 +197,44 @@ class DockManager:
animator.frameChanged.connect(_apply)
animator.closed.connect(self._on_back_to_model)
+ if self._canvas.capabilities.in_page_animation:
+ animator.playToggled.connect(
+ lambda playing: self._animate_mode_in_page(animator, playing)
+ )
animator.exportRequested.connect(
lambda: self._on_export_mode_shape(animator, _apply),
)
# Animator emits an initial frame in its constructor; nothing to do.
+ def _animate_mode_in_page(self, animator: object, playing: bool) -> None:
+ """Play the current mode shape with plotly's own frame animation."""
+ if not playing:
+ self._canvas.stop_animation()
+ return
+ if not isinstance(self._latest_results, ModalResults) or self._vm.project is None:
+ return
+ import math
+ from dataclasses import replace
+
+ n_frames = 36
+ period = max(float(animator.period_seconds()), 0.1) # type: ignore[attr-defined]
+ mode = int(animator.current_mode()) # type: ignore[attr-defined]
+ scale = float(animator.current_scale()) # type: ignore[attr-defined]
+ base = self._canvas.base_scene_options() # type: ignore[attr-defined]
+ options = []
+ for k in range(n_frames):
+ phase = math.sin(2.0 * math.pi * k / n_frames)
+ src = modal_to_deformation(
+ self._vm.project, self._latest_results, mode=mode, scale=scale, phase=phase
+ )
+ options.append(replace(base, deformation=src))
+ animator.set_in_page_mode(True) # type: ignore[attr-defined]
+ self._canvas.animate( # type: ignore[attr-defined]
+ options,
+ duration_ms=int(period * 1000 / n_frames),
+ loops=100,
+ )
+
def _on_export_mode_shape(self, animator, apply_callable) -> None: # type: ignore[no-untyped-def]
"""Capture one period of the current mode shape to MP4/GIF.
diff --git a/src/otko/views/docks/mode_shape_animator.py b/src/otko/views/docks/mode_shape_animator.py
index 4ecfc89..df40c93 100644
--- a/src/otko/views/docks/mode_shape_animator.py
+++ b/src/otko/views/docks/mode_shape_animator.py
@@ -28,6 +28,7 @@ class ModeShapeAnimator(QWidget):
"""
frameChanged = Signal(int, float, float)
+ playToggled = Signal(bool)
closed = Signal()
exportRequested = Signal()
@@ -41,6 +42,7 @@ class ModeShapeAnimator(QWidget):
self._n_modes = n_modes
self._freqs = frequencies_hz
self._t = 0.0
+ self._in_page = False
self._timer = QTimer(self)
self._timer.timeout.connect(self._on_tick)
self._timer.setInterval(int(1000 / self._FPS))
@@ -111,6 +113,17 @@ class ModeShapeAnimator(QWidget):
def current_scale(self) -> float:
return float(self._scale.value())
+ def period_seconds(self) -> float:
+ return float(self._period.value())
+
+ def set_in_page_mode(self, on: bool) -> None:
+ """While an in-page (plotly) animation plays, stop emitting frames.
+
+ The canvas animates itself; the Python timer keeps the scrubber in
+ sync but must not drive per-frame re-renders.
+ """
+ self._in_page = bool(on)
+
def set_results(self, results: object | None) -> None:
"""Rebind to a new modal run while the dock stays open.
@@ -174,6 +187,8 @@ class ModeShapeAnimator(QWidget):
self._play_btn.setText("▶ Play")
self._timer.stop()
self._scrubber.setEnabled(True)
+ self._in_page = False
+ self.playToggled.emit(playing)
def _on_stop(self) -> None:
self._timer.stop()
@@ -201,6 +216,8 @@ class ModeShapeAnimator(QWidget):
self._scrubber.blockSignals(True)
self._scrubber.setValue(int(round(phase * 100)))
self._scrubber.blockSignals(False)
+ if self._in_page:
+ return # the canvas is animating itself; don't re-render per tick
self.frameChanged.emit(self._mode_combo.currentData(), self._scale.value(), phase)
def _emit_static_frame(self) -> None:
diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py
index 5241869..6e7a054 100644
--- a/src/otko/views/main_window.py
+++ b/src/otko/views/main_window.py
@@ -230,6 +230,7 @@ class MainWindow(
# Re-apply the overlays whose state lives on the toolbar actions.
self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked())
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
+ self._canvas.set_show_undeformed(self._act_show_reference.isChecked())
self._canvas.set_display_options(
show_node_labels=self._show_node_labels,
show_element_labels=self._show_element_labels,
@@ -308,6 +309,7 @@ class MainWindow(
self._act_quit.triggered.connect(self.close)
self._act_about.triggered.connect(self._on_about)
self._act_quick_guide.triggered.connect(self._on_quick_guide)
+ self._act_mouse_controls.triggered.connect(self._on_mouse_controls)
self._act_set_units.triggered.connect(self._on_set_units)
self._act_plot_properties.triggered.connect(self._on_plot_properties)
@@ -386,6 +388,9 @@ class MainWindow(
self._act_toggle_parallel.toggled.connect(self._on_toggle_parallel)
self._act_show_extruded.toggled.connect(self._canvas.set_show_section_extrusions)
self._act_show_local_axes.toggled.connect(self._canvas.set_show_local_axes)
+ # Resolve the active canvas at signal time so a backend swap still
+ # routes to the canvas on screen.
+ self._act_show_reference.toggled.connect(lambda on: self._canvas.set_show_undeformed(on))
# ViewModel — projectChanged / modelMutated survive the split.
self._vm.projectChanged.connect(self._on_project_changed)
diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py
index 5e7a271..bd8fc69 100644
--- a/src/otko/views/menu_builder.py
+++ b/src/otko/views/menu_builder.py
@@ -141,6 +141,11 @@ class MenuBuilder:
"Exit any deformed / mode-shape / force-diagram view back to "
"the model geometry. Result data is kept."
)
+ self._act_show_reference = QAction("Show Undeformed &Reference", self, checkable=True)
+ self._act_show_reference.setToolTip(
+ "Faintly overlay the undeformed shape behind a deformed / mode-shape "
+ "view so displacements are easy to judge."
+ )
self._act_clear_display = QAction("&Clear Display", self)
self._act_clear_display.setToolTip(
"Neutral canvas: undeformed geometry, no overlays, selection "
@@ -191,6 +196,7 @@ class MenuBuilder:
self._act_about = QAction("&About OTKO…", self)
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
self._act_quick_guide = QAction("&Quick Guide", self)
+ self._act_mouse_controls = QAction("&Mouse Controls…", self)
self._act_set_units = QAction("Set Display &Units…", self)
self._act_plot_properties = QAction("&Plot Properties…", self)
self._act_plot_properties.setToolTip(
@@ -481,6 +487,7 @@ class MenuBuilder:
m_display.addSeparator()
m_display.addAction(self._act_back_to_model)
m_display.addAction(self._act_show_undeformed)
+ m_display.addAction(self._act_show_reference)
m_display.addAction(self._act_clear_display)
m_view = mb.addMenu("&View")
@@ -524,6 +531,7 @@ class MenuBuilder:
m_help = mb.addMenu("&Help")
m_help.addAction(self._act_quick_guide)
+ m_help.addAction(self._act_mouse_controls)
m_help.addAction(self._act_about)
def _build_view_toolbar(self) -> None:
diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py
index 06db479..01886d3 100644
--- a/src/otko/views/render_controls.py
+++ b/src/otko/views/render_controls.py
@@ -84,9 +84,22 @@ class RenderControls:
self._tear_down_post_dock()
if self._diagram_renderer is not None:
self._diagram_renderer.clear()
+ self._reset_reference_overlay()
+ stop_animation = getattr(self._canvas, "stop_animation", None)
+ if callable(stop_animation):
+ stop_animation()
self._canvas._renderer.set_mode(RendererMode.MODEL)
self._canvas.render()
+ def _reset_reference_overlay(self) -> None:
+ """Drop the undeformed-reference overlay and uncheck its action."""
+ self._act_show_reference.blockSignals(True)
+ try:
+ self._act_show_reference.setChecked(False)
+ finally:
+ self._act_show_reference.blockSignals(False)
+ self._canvas.set_show_undeformed(False)
+
def _on_clear_display(self) -> None:
"""Clear Display: neutral canvas — undeformed MODEL, no selection.
@@ -513,6 +526,7 @@ class RenderControls:
# Undeformed exits any post view (dock, renderer mode or diagram
# overlay); Clear just needs a project — both keep result data.
self._act_show_undeformed.setEnabled(has_project and self._in_post_view())
+ self._act_show_reference.setEnabled(has_project and self._in_post_view())
self._act_clear_display.setEnabled(has_project)
def _log(self, message: str) -> None:
diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py
index a57a00f..9a8d023 100644
--- a/tests/gui/test_main_window.py
+++ b/tests/gui/test_main_window.py
@@ -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
diff --git a/tests/gui/test_plotly_gestures.py b/tests/gui/test_plotly_gestures.py
new file mode 100644
index 0000000..e15d287
--- /dev/null
+++ b/tests/gui/test_plotly_gestures.py
@@ -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
+ )
diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py
index 3033886..f1ff273 100644
--- a/tests/gui/test_plotly_hover.py
+++ b/tests/gui/test_plotly_hover.py
@@ -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"
diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py
index 97ef3ee..4bed58a 100644
--- a/tests/gui/test_plotly_view_preservation.py
+++ b/tests/gui/test_plotly_view_preservation.py
@@ -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")
diff --git a/tests/gui/test_undeformed_reference.py b/tests/gui/test_undeformed_reference.py
new file mode 100644
index 0000000..0277548
--- /dev/null
+++ b/tests/gui/test_undeformed_reference.py
@@ -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
diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py
index 1d20e4a..a33eb8e 100644
--- a/tests/unit/test_plotly_trace_builder.py
+++ b/tests/unit/test_plotly_trace_builder.py
@@ -13,6 +13,7 @@ from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder,
SceneOptions,
+ framed_camera_distance,
)
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
@@ -38,6 +39,32 @@ def test_builds_grid_nodes_and_frames() -> None:
assert all(trace.get("meta", {}).get("kind") != "hover" for trace in scene.data)
+def test_data_bounds_are_unpadded_model_extents() -> None:
+ """Camera framing uses raw model bounds, so they must exclude the padding."""
+ scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions())
+ assert scene.data_bounds["x"] == pytest.approx((0.0, 5.0))
+ assert scene.data_bounds["y"] == pytest.approx((0.0, 0.0))
+ assert scene.data_bounds["z"] == pytest.approx((0.0, 0.0))
+ # Padded frame bounds stay wider than the raw model bounds.
+ assert scene.axis_bounds["x"][0] < scene.data_bounds["x"][0]
+
+
+def test_framed_camera_distance_is_scale_invariant() -> None:
+ """plotly's camera is in normalized scene units, so framing must not grow
+ with the model's data-unit size (the old bug rendered models as specks)."""
+ project = _load("space_frame_3d")
+ small = PlotlyTraceBuilder().build(project, SceneOptions())
+ scaled = project.model_copy(deep=True)
+ for node in scaled.nodes:
+ x, y, z = node.coords
+ node.coords = (x * 1000.0, y * 1000.0, z * 1000.0)
+ large = PlotlyTraceBuilder().build(scaled, SceneOptions())
+
+ assert framed_camera_distance(large) == pytest.approx(framed_camera_distance(small), rel=1e-9)
+ # A normalized eye distance stays small (plotly's default eye is 1.25).
+ assert 1.0 < framed_camera_distance(small) < 20.0
+
+
def test_nodes_carry_ids_as_customdata() -> None:
project = _load("basic_truss")
scene = PlotlyTraceBuilder().build(project, SceneOptions())
@@ -182,6 +209,9 @@ def test_payload_is_json_serialisable() -> None:
payload = json.dumps(scene.to_payload())
assert '"data"' in payload and '"layout"' in payload
assert scene.layout["scene"]["aspectmode"] == "data"
+ # Turntable keeps camera.up pinned to +Z (a level horizon); plotly's
+ # ``orbit`` would rotate the up vector and tip the model over.
+ assert scene.layout["scene"]["dragmode"] == "turntable"
def test_empty_project_yields_no_traces() -> None:
@@ -256,3 +286,86 @@ def test_axis_outline_flag_toggles_grid_and_ticks() -> None:
assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True
# The coloured axis lines stay visible either way (orientation cue).
assert off_axis["visible"] is True and on_axis["visible"] is True
+
+
+class _FakeDeformation:
+ """Minimal ``DeformationSource`` stand-in (keeps this test pyvista-free)."""
+
+ def __init__(self, rise: float = 0.0) -> None:
+ self._rise = rise
+
+ def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray:
+ out = points.copy()
+ out[:, 2] += self._rise
+ return out
+
+ def magnitudes(self, node_ids: list[int]) -> np.ndarray:
+ return np.linspace(0.0, 1.0, len(node_ids)) if node_ids else np.zeros(0)
+
+
+def test_loads_scale_with_magnitude_and_colour_by_pattern() -> None:
+ scene = PlotlyTraceBuilder().build(_load("space_frame_3d"), SceneOptions())
+ cones = [trace for trace in scene.data if trace.get("name") == "nodal-loads"]
+ assert len(cones) == 2, "one cone trace per load pattern"
+ lengths = sorted(
+ float(np.hypot(np.hypot(u, v), w))
+ for trace in cones
+ for u, v, w in zip(trace["u"], trace["v"], trace["w"], strict=True)
+ )
+ # 2.5e4 vs 5e4 kN → the second arrow is twice as long.
+ assert lengths[0] > 0.0
+ assert lengths[-1] == pytest.approx(2.0 * lengths[0], rel=1e-6)
+ assert cones[0]["colorscale"][0][1] != cones[1]["colorscale"][0][1]
+ assert "Pattern" in cones[0]["customdata"][0] or "|F|" in cones[0]["customdata"][0]
+
+
+def test_supports_use_dof_glyphs_not_markers() -> None:
+ for name in ("space_frame_3d", "basic_truss"):
+ scene = PlotlyTraceBuilder().build(_load(name), SceneOptions())
+ (supports,) = _traces(scene, "supports")
+ assert supports["type"] == "scatter3d"
+ assert supports["mode"] == "lines"
+ assert len(supports["x"]) > 0
+
+
+def test_scalar_colouring_adds_arrays_and_colorbar() -> None:
+ project = _load("cantilever")
+ scalars = np.linspace(0.0, 3.0, len(project.nodes))
+ scene = PlotlyTraceBuilder().build(project, SceneOptions(scalars=scalars, scalar_label="|u|"))
+
+ (nodes,) = _traces(scene, "nodes")
+ assert isinstance(nodes["marker"]["color"], list)
+ assert nodes["marker"]["showscale"] is True
+ assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
+ assert nodes["marker"]["cmin"] == pytest.approx(0.0)
+ assert nodes["marker"]["cmax"] == pytest.approx(3.0)
+
+ (frames,) = _traces(scene, "elements")
+ assert isinstance(frames["line"]["color"], list)
+ assert frames["line"]["cmin"] == pytest.approx(0.0)
+ # Scalar mode overrides the family palette.
+ assert "showscale" not in frames["line"]
+
+
+def test_deformation_auto_colours_by_magnitude() -> None:
+ project = _load("cantilever")
+ scene = PlotlyTraceBuilder().build(
+ project, SceneOptions(deformation=_FakeDeformation(rise=0.5))
+ )
+ (nodes,) = _traces(scene, "nodes")
+ assert isinstance(nodes["marker"]["color"], list)
+ assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
+
+
+def test_undeformed_reference_overlay_is_opt_in() -> None:
+ project = _load("cantilever")
+ deformation = _FakeDeformation(rise=0.5)
+ without = PlotlyTraceBuilder().build(project, SceneOptions(deformation=deformation))
+ assert not _traces(without, "undeformed-reference")
+
+ with_ghost = PlotlyTraceBuilder().build(
+ project, SceneOptions(deformation=deformation, show_undeformed=True)
+ )
+ (ghost,) = _traces(with_ghost, "undeformed-reference")
+ assert ghost["mode"] == "lines"
+ assert ghost["opacity"] < 1.0
diff --git a/vis_improvement.md b/vis_improvement.md
new file mode 100644
index 0000000..d1ef9a7
--- /dev/null
+++ b/vis_improvement.md
@@ -0,0 +1,307 @@
+# Plotly visualisation improvements — handoff / working brief
+
+Status: **implemented and verified; uncommitted** on branch `fix/plotly-orbit-lag`.
+This file is a self-contained brief so a fresh LLM (or human) can continue the
+work without re-deriving context. Read it top to bottom before touching code.
+
+---
+
+## 1. Objective
+
+Make otko's **Plotly canvas** (`src/otko/views/canvas_plotly/`) feel and look
+like opstool's plotly visualisation, which the user considers the gold
+standard. Earlier in this session we already fixed the two foundational bugs:
+
+1. **Camera framing** — `layout.scene.camera` is *not* in data units; it lives
+ in gl-plot3d's normalized scene space. We now compute a normalized eye
+ distance (`framed_camera_distance`) and frame on the **model** bounds
+ (grid excluded).
+2. **Orbit / horizon** — scene uses `dragmode: "turntable"` (Z-up), and a JS
+ gesture layer matches the PyVista/VTK mouse bindings.
+
+With those done, the user asked for 5 opstool-inspired improvements. **All 5
+were selected and implemented in this session.** This document records what
+landed, design decisions, and what still needs doing (verification, docs,
+possible polish).
+
+---
+
+## 2. Reference: opstool source (read-only clone)
+
+Local clone (do **not** edit):
+
+```
+.slim/clonedeps/repos/yexiang92__opstool/opstool/vis/plotly/
+```
+
+Key files and what to read:
+
+| File | Relevant content |
+| --- | --- |
+| `plot_utils.py` | `PLOT_ARGS_DEFAULT` (colours, sizes), `_plot_points_cmap` (~679), `_plot_lines_cmap` (~747), `_plot_unstru_cmap` (~837), `_make_lines_plotly` (~628) |
+| `plot_resp_base.py` | `_get_plotly_dim_scene` (~220, camera/eye/2D recipe), `_make_lines_arrows` (~361), `_plot_bc` (~305), `_get_bc_points_3d/_2d` (~438–527) |
+| `vis_model.py` | `plot_node_load` (~462), `plot_ele_load` (~515), `plot_beam_local_axes` (~406), `update_fig` (~633, theme/title) |
+| `vis_nodal_resp.py` | `_create_mesh` (scalar contour + `show_origin` undeformed ghost ~57–169), `plot_anim` (~248), `_update_antimate_layout` / `_update_slider_layout` (~94, ~140) |
+| `vis_frame_resp.py` | frame force contour + animation methods |
+
+Important facts learned from the opstool source (do not re-litigate):
+
+- plotly `scatter3d.marker` **and** `scatter3d.line` accept `coloraxis`,
+ `colorscale`, `cmin`, `cmax`, `showscale`, `colorbar`, and array `color`.
+ `layout.coloraxis` exists (plotly.py 7.1.0; it is a valid layout key even
+ though `Layout()._valid_props` doesn't list it directly).
+- `mesh3d` supports `intensity`/`colorscale` but **not** `coloraxis`.
+- opstool's contour colouring uses a shared `coloraxis` + `cmin/cmax` from the
+ response peak, with a `colorbar` carrying the component/unit title.
+- opstool scales load arrows by `|F| * (min+max bound)/20 / max|F|` and tints
+ each load pattern (matplotlib `winter` / `turbo_r`).
+- opstool draws supports as oriented line loops (plates/circles/triangles) per
+ restrained translation DOF.
+
+---
+
+## 3. What was implemented (the 5 changes)
+
+### #1 Scalar contour colouring + colorbar
+
+**Where:** `src/otko/views/canvas_plotly/trace_builder.py`
+
+- `SceneOptions` gained `scalars`, `scalar_label`, `scalar_clim`.
+- `build()` auto-derives scalars when a deformation source exposes
+ `magnitudes(node_ids)` (see `DeformationSource.magnitudes` in
+ `src/otko/services/deformation.py`), label `"|u|"`.
+- `_build_nodes` colours `marker.color` by scalar array with
+ `colorscale=self._style.response_colorscale()`, `cmin/cmax`,
+ `showscale=True`, and a `colorbar` titled `scalar_label`; hover shows the
+ value.
+- `_build_frames` colours `line.color` per endpoint by scalar array.
+- Helper `_scalar_clim()`.
+
+**Effect:** deformed/modal views now show a displacement-magnitude contour with
+a colourbar (verified by screenshot).
+
+### #2 Undeformed "ghost" reference overlay
+
+**Where:** `trace_builder.py` (`_build_undeformed_reference`),
+`SceneOptions.show_undeformed`; `plotly_canvas.py`
+(`set_show_undeformed`, `_show_undeformed`, applied in `_push_scene`);
+`render_controls.py`; `menu_builder.py` (`_act_show_reference`, checkable,
+Display menu); `main_window.py` (wiring + re-apply on canvas swap);
+`canvas_base.py` (protocol method).
+
+- Plotly: faint grey line trace behind the deformed shape.
+- PyVista: `model_renderer.py` `_refresh_undeformed_overlay()` builds a grey
+ `pv.PolyData` wireframe from `_node_original_points` + frame connectivity;
+ `model_canvas.py` `set_show_undeformed`. (This made the previously duplicated
+ `DeformationSource` class in `model_renderer.py` redundant — it now imports
+ the one from `otko.services.deformation`.)
+- **Display → Show Undeformed Reference** is enabled only in a post view and is
+ auto-cleared when returning to the model (`render_controls._reset_reference_overlay`).
+
+### #3 Load magnitude scaling + per-pattern colours + hover
+
+**Where:** `trace_builder.py` `_build_loads`, `_pattern_colors`,
+`_PATTERN_PALETTE`, `_cone_trace` (now takes `customdata` + hovertemplate).
+
+- Arrow length is proportional to `|F| / max|F|`, clamped to `[0.15·scale, scale]`
+ where `scale = 0.05 · model diagonal`. Previously all arrows were the same
+ length regardless of magnitude.
+- One cone trace per load pattern, tinted from `_PATTERN_PALETTE`. With a
+ single pattern (nothing to distinguish) the per-type style tint is kept
+ (`nodal_load_color` / `element_load_color`).
+- Hover: `" ·
|F| = "` via `customdata`.
+- Cone sizing uses `sizemode: "scaled"` with a small unitless `sizeref` (~0.12).
+ `"absolute"` interprets `sizeref` against the vector norm in *normalized*
+ scene units, which rendered oversized "fins" that hid the model on large
+ models (see the `snip4.png` report). `_cone_trace` docstring records this.
+
+### #4 DOF-accurate support glyphs
+
+**Where:** `trace_builder.py` `_build_supports`, `_support_loops`.
+
+- Ported/modified from opstool `_get_bc_points_3d` / `_get_bc_points_2d`
+ (GPL-3.0). Each restrained translation axis → a plate perpendicular to it;
+ 2D roller → circle; 2D ux&uy → triangle. Rotation-only supports fall back to
+ a square marker.
+- Emits one `scatter3d` lines trace named `"supports"`.
+- **NOTICE updated** to record this GPL port (already done).
+
+### #5 In-page plotly frame animation
+
+**Where:**
+- `html.py`: `window.otkoAnimate(payloadJson, durationMs, loops, preserveView)`
+ and `window.otkoStopAnimation()`. Builds a name list, repeats it `loops`
+ times, `Plotly.react` → `addFrames` → `animate` (with `mergeView` to preserve
+ camera/ranges).
+- `plotly_canvas.py`: `animate(options, duration_ms=40, loops=100)`,
+ `stop_animation()`, `base_scene_options()`;
+ `CanvasCapabilities(in_page_animation=True)`.
+- `canvas_base.py`: `CanvasCapabilities.in_page_animation` (default False).
+- `docks/mode_shape_animator.py`: new `playToggled(bool)` signal,
+ `period_seconds()`, `set_in_page_mode(bool)`; `_on_tick` updates the scrubber
+ but does **not** emit frames while `_in_page`.
+- `dock_manager.py`: `_animate_mode_in_page()` builds 36 phase frames from
+ `modal_to_deformation` and calls `canvas.animate(...)` when the capability is
+ present; play/pause map to animate/stop.
+- `render_controls._on_show_undeformed` calls `stop_animation()` if present.
+
+---
+
+## 4. Files changed (all uncommitted)
+
+```
+ M AGENTS.md
+ M NOTICE
+ M docs/QUICK_GUIDE.md
+ M src/otko/services/deformation.py # magnitudes()
+ M src/otko/views/action_handlers.py # (earlier) Mouse Controls dialog
+ M src/otko/views/canvas3d/model_canvas.py # set_show_undeformed
+ M src/otko/views/canvas3d/model_renderer.py # ghost overlay; import DeformationSource
+ M src/otko/views/canvas_base.py # protocol + capability
+ M src/otko/views/canvas_plotly/html.py # gestures, otkoAnimate
+ M src/otko/views/canvas_plotly/plotly_canvas.py # show_undeformed, animate
+ M src/otko/views/canvas_plotly/trace_builder.py # #1–#4
+ M src/otko/views/dialogs/__init__.py # MouseControlsDialog export
+ M src/otko/views/dialogs/quick_guide.py # nav hint
+ M src/otko/views/dock_manager.py # in-page animation
+ M src/otko/views/docks/mode_shape_animator.py # playToggled / in-page
+ M src/otko/views/main_window.py # reference toggle wiring
+ M src/otko/views/menu_builder.py # _act_show_reference
+ M src/otko/views/render_controls.py # enable/reset reference
+ M tests/gui/test_main_window.py
+ M tests/gui/test_plotly_hover.py
+ M tests/gui/test_plotly_view_preservation.py # animation push + deformed push tests
+ M tests/unit/test_plotly_trace_builder.py # #1–#4 tests
+?? src/otko/views/dialogs/mouse_controls.py # earlier: Help dialog
+?? tests/gui/test_plotly_gestures.py # earlier: gestures
+?? tests/gui/test_undeformed_reference.py # PyVista ghost overlay
+?? vis_improvement.md # this brief
+```
+
+---
+
+## 5. Tests already added and passing
+
+- `tests/unit/test_plotly_trace_builder.py`
+ - `test_loads_scale_with_magnitude_and_colour_by_pattern`
+ - `test_supports_use_dof_glyphs_not_markers`
+ - `test_scalar_colouring_adds_arrays_and_colorbar`
+ - `test_deformation_auto_colours_by_magnitude`
+ - `test_undeformed_reference_overlay_is_opt_in`
+ - (`_FakeDeformation` stand-in keeps the unit test free of pyvista/Qt)
+- `tests/gui/test_plotly_view_preservation.py`
+ - `test_animation_push_carries_prebuilt_frames` — parses the emitted
+ `window.otkoAnimate(...)` payload with `json.JSONDecoder().raw_decode`.
+- `tests/gui/test_plotly_gestures.py` — asserts `window.otkoAnimate` /
+ `window.otkoStopAnimation` exist in the page.
+
+Last full gate (after all of the above, except the final two tests below):
+`pytest -m "not slow"` → **100% pass, only pre-existing skips**; `ruff check`
+→ the pre-existing 15 findings only; `mypy core/services` → the 3 pre-existing
+environment errors.
+
+Additional tests added during the follow-up verification pass:
+
+- `tests/gui/test_undeformed_reference.py` — exercises the **PyVista** ghost
+ overlay (`_refresh_undeformed_overlay`) in MODEL vs DEFORMED mode and toggles
+ it off.
+- `tests/gui/test_plotly_view_preservation.py::test_deformed_push_colours_by_scalar_and_ghosts_the_reference`
+ — canvas wiring: a DEFORMED push with `show_undeformed` carries the
+ `undeformed-reference` trace and a node colourbar titled `|u|`.
+
+---
+
+## 6. What remains / known risks (DO THIS NEXT)
+
+1. ~~Run the full gate.~~ **Done** — all green (see §5). Note: `pytest`
+ segfaults at interpreter teardown in this env (QtWebEngine "profile still
+ not deleted") **on the clean baseline too**; it is pre-existing, not caused
+ by these changes. CI (xvfb) should be fine.
+2. **Manually eyeball in-page animation on a real display (only remaining
+ item).** Offscreen QtWebEngine throttles `requestAnimationFrame`, so
+ `Plotly.animate`'s promise may not resolve in tests. A dispatch probe
+ confirmed `addFrames(n)` and `animate(sequence)` are called with the right
+ sizes, but end-to-end playback (loop, pause, camera preservation) has **not**
+ been seen on-screen. Launch `python -m otko`, open a model with a modal
+ result, **Display → Animate Mode Shape → Play** on the Plotly backend and
+ confirm looping + Pause + Back-to-model. `loops=100` × 36 frames = 3600
+ frame names; reduce if it stutters.
+3. **Animation stop path — verified from source.** `Plotly.animate(gd, [],
+ {mode:'immediate'})` calls `discardExistingFrames()` (plotly.js
+ `plot_api.js` `animate`), so `otkoStopAnimation` does interrupt. Interrupt
+ rejects the previous animate promise with no reason; the JS error handler
+ only logs truthy errors to avoid console noise.
+4. **PyVista ghost overlay — tested** (see §5). Still worth a glance that it
+ does not interfere with node picking on-screen, but the actor is
+ `pickable=False`.
+5. **Docs — done** for the new features (`docs/QUICK_GUIDE.md` §2/§3,
+ `AGENTS.md`, `NOTICE`).
+6. **Optional polish (opstool parity not yet done)**
+ - scene title with model stats + `font.family` (opstool `update_fig`);
+ - **2D auto-scene for planar models — evaluated and deliberately skipped.**
+ Turntable's `updateFx` forces `camera.up = [0,0,1]`, so a top view's
+ screen-up is arbitrary (a planar XY truss renders rotated 90°: verified
+ via screenshot). Fixing it needs per-preset up handling that fights
+ turntable; not worth the risk.
+ - Max/Min response annotations (`show_max_min`);
+ - response component selector (currently only `|u|` magnitude);
+ - smoothing/interpolated beam displacement (opstool `interpolate_beam_disp`);
+ - colourbar units (currently label only, no unit string).
+
+---
+
+## 7. Architecture / constraints to respect
+
+- Layering: `views → viewmodels → services → core`. `core` = stdlib+numpy+
+ pydantic; `services` may use h5py/openseespy; `views` = Qt/PyVista/plotly.
+ `trace_builder.py` is **pure** (no Qt, no pyvista, no plotly import) and is
+ unit-tested headless — keep it that way.
+- Both canvas backends implement the `CanvasBackend` protocol
+ (`views/canvas_base.py`). If you add a consumer-facing method, add it to
+ **both** canvases and (ideally) the protocol.
+- opstool is GPL-3.0; any ported code must be recorded in `NOTICE` (already
+ done for `_support_loops` and the colour/load recipes).
+- Never commit `.osmodel` by hand (not relevant here).
+- Do not commit changes unless the user asks. Conventional Commits style.
+
+---
+
+## 8. Useful commands / environment
+
+```bash
+# Python lives in the repo venv (system pytest lacks otko on sys.path)
+.venv/bin/python -c "import otko"
+.venv/bin/pytest tests/unit/test_plotly_trace_builder.py -q
+.venv/bin/pytest tests/gui/test_plotly_gestures.py -q # opens QtWebEngine
+```
+
+Rendering screenshots for quick visual checks (used during development):
+use in-page `Plotly.toImage('plot', {format:'png', width:900, height:640})`
+(a promise; poll `window.__img` from Python and base64-decode). Offscreen
+`qtbot` widgets do not need `show()`; WebGL works but rAF is throttled.
+
+Quick smoke to inspect trace shapes without Qt:
+
+```python
+from otko.services import load_project
+from otko.views.canvas_plotly.trace_builder import PlotlyTraceBuilder, SceneOptions
+scene = PlotlyTraceBuilder().build(load_project("examples/space_frame_3d.osmodel"), SceneOptions())
+for t in scene.data:
+ print(t.get("name"), t["type"])
+```
+
+---
+
+## 9. One-paragraph recap for a fresh LLM
+
+The Plotly canvas now frames correctly (normalized camera, turntable, model
+bounds), has VTK-parity mouse gestures, and five opstool-inspired upgrades:
+scalar response colouring with a colourbar, an undeformed reference overlay,
+magnitude-scaled per-pattern load arrows with hover, DOF-accurate support
+glyphs, and in-page plotly frame animation. Everything is implemented and
+covered by focused unit/GUI tests; the remaining work is running the full gate,
+eyeballing the animation and PyVista ghost on a real display, and finishing
+docs. Start by reading `trace_builder.py` (`SceneOptions`, `build`,
+`_build_nodes`, `_build_frames`, `_build_supports`, `_build_loads`),
+`canvas_plotly/html.py` (gestures + `otkoAnimate`), and
+`canvas_plotly/plotly_canvas.py` (`_push_scene`, `_camera_dict`, `animate`).