From d3878f23b3b5744e4c5210c24f503bd44f8af572 Mon Sep 17 00:00:00 2001
From: smillmorel
Date: Wed, 16 Sep 2026 23:10:22 -0400
Subject: [PATCH] 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`).