Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
d3878f23b3 feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.

Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
  bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
  Help → Mouse Controls

Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
  and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page

Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
1b0620a392 perf: replace the Plotly snap marker with a projected DOM overlay
Orbiting and the snap preview felt far behind the cursor. Measured in the
page: a single Plotly.restyle on a gl3d plot costs about 90 ms even for a
one-trace figure, so every marker update stalled the scene and the marker
trailed the mouse; each one also queued another redraw while the user was
dragging.

The marker is now a pointer-events-none div positioned by projecting the
snapped world point through glplot.cameraParams (validated: the camera
centre lands at the canvas centre), updated with one style write and
re-projected on plotly_relayout so it stays glued to the target during
orbit. No plotly calls at all on the hover path - 200 mouse-move events
now cost 0 restyles - and the empty hover trace is gone from the figure.

Also logs the WebGL renderer once (via the console bridge) since hardware
acceleration decides how smooth orbit feels and is otherwise invisible.
2026-09-16 20:52:40 -04:00
26 changed files with 1829 additions and 243 deletions

BIN
AGENTS.md

Binary file not shown.

7
NOTICE
View file

@ -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 (per-family element colours and the diverging response colour scale) and the
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
adapted from the `opstool` project, which is distributed under the **GNU adapted from the `opstool` project, which is distributed under the **GNU
General Public License v3.0**. opstool is Copyright © Yexiang Yan and General Public License v3.0**. The support (boundary-condition) glyph
contributors. 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 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 modified and adapted for OTKO. Combining the GPLv3-covered material with

View file

@ -66,7 +66,9 @@ correctly.
periods, frequencies, and participation factors per mode. periods, frequencies, and participation factors per mode.
4. **Display → Animate Mode Shape** to view each mode. Use the mode 4. **Display → Animate Mode Shape** to view each mode. Use the mode
selector and the animation controls, and **Export…** if you want a 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 5. Modal results also feed the response-spectrum case: define a response
spectrum, then run the SRSS or CQC combination and open spectrum, then run the SRSS or CQC combination and open
**Display → Show Response Spectrum**. **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 (static reactions and forces, modal periods, and so on). Use the display
actions to inspect the model visually: 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 - **Display → Show Force Diagram…** — axial (P), shear (V2/V3), moment
(M2/M3) diagrams. (M2/M3) diagrams.
- **Display → Show Pushover Curve**, **Show Time-History**, **Show - **Display → Show Pushover Curve**, **Show Time-History**, **Show
Hysteresis** as applicable. 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, To hand the analysis to someone else, or to archive exactly what was run,
export a script: 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 loads, analysis runs, and display-only changes are not model mutations and
are not undoable. 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 ## Where to go next
- [`architecture.md`](architecture.md) — MVVM layering and command order. - [`architecture.md`](architecture.md) — MVVM layering and command order.

View file

@ -36,6 +36,19 @@ class DeformationSource:
out[i] += self.scale * self.displacements[row] out[i] += self.scale * self.displacements[row]
return out 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( def static_to_deformation(
project: Project, project: Project,

View file

@ -53,6 +53,7 @@ from otko.views.dialogs import (
MaterialLibraryDialog, MaterialLibraryDialog,
MaterialTesterDialog, MaterialTesterDialog,
MirrorDialog, MirrorDialog,
MouseControlsDialog,
MoveDialog, MoveDialog,
PathTimeSeriesDialog, PathTimeSeriesDialog,
PatternLoadsDialog, PatternLoadsDialog,
@ -987,6 +988,19 @@ class ActionHandlers:
dlg.raise_() dlg.raise_()
dlg.activateWindow() 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: def _on_set_units(self) -> None:
"""Options → Set Display Units — SAP2000 parity. """Options → Set Display Units — SAP2000 parity.

View file

@ -342,6 +342,11 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._renderer.set_show_local_axes(enabled) self._renderer.set_show_local_axes(enabled)
self.render() 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: def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the scene. """Swap the visual style and repaint the scene.

View file

@ -12,7 +12,6 @@ from __future__ import annotations
import contextlib import contextlib
import enum import enum
from dataclasses import dataclass
from typing import Any from typing import Any
import numpy as np import numpy as np
@ -33,6 +32,7 @@ from otko.core import (
ZeroLengthElement, ZeroLengthElement,
ZeroLengthSectionElement, ZeroLengthSectionElement,
) )
from otko.services.deformation import DeformationSource
from otko.views.canvas3d.style import ( from otko.views.canvas3d.style import (
SELECTED_STATE, SELECTED_STATE,
RenderStyle, RenderStyle,
@ -165,23 +165,6 @@ def _dof_indices(ndf: int) -> tuple[int, ...]:
return tuple(range(ndf)) 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: class ModelRenderer:
"""Glyphed-PolyData renderer with mode-aware deformation support.""" """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._hover_actor: Any = None # single yellow-ring snap marker
self._show_section_extrusions: bool = False self._show_section_extrusions: bool = False
self._show_local_axes: 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 # SAP2000-style working plane: when set, the grid renders ONLY
# the lines / intersections lying on this plane so a user in # 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. # 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: if self._project is not None:
self.render(self._project) 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: def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and rebuild the scene it colours. """Swap the visual style and rebuild the scene it colours.
@ -1114,8 +1106,39 @@ class ModelRenderer:
if self._frame_pd is not None: if self._frame_pd is not None:
self._frame_pd.points = new_pts self._frame_pd.points = new_pts
self._frame_pd.Modified() self._frame_pd.Modified()
self._refresh_undeformed_overlay()
self._rebuild_labels() 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 ───────────────────────────────────────────────────── # ── helpers ─────────────────────────────────────────────────────
def _teardown_all(self) -> None: def _teardown_all(self) -> None:
self._clear_label_actors() self._clear_label_actors()
@ -1126,6 +1149,10 @@ class ModelRenderer:
for a in self._aux_actors: for a in self._aux_actors:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self._plotter.remove_actor(a, render=False) 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._node_actor = None
self._frame_actor = None self._frame_actor = None
self._aux_actors.clear() self._aux_actors.clear()

View file

@ -45,6 +45,9 @@ class CanvasCapabilities:
labels: bool = True labels: bool = True
#: Off-screen frame capture (mode-shape / time-history video export). #: Off-screen frame capture (mode-shape / time-history video export).
animation_export: bool = True 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): class CanvasBackend(Protocol):
@ -87,6 +90,8 @@ class CanvasBackend(Protocol):
def set_show_local_axes(self, enabled: bool) -> None: ... 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_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ...
def set_default_selection_enabled(self, enabled: bool) -> None: ... def set_default_selection_enabled(self, enabled: bool) -> None: ...

View file

@ -13,9 +13,21 @@ The JS side exposes three entry points to Python (called via
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets, - ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
parallel-projection toggle). parallel-projection toggle).
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview. - ``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: Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
entity picks carry the trace ``meta.kind`` and the point ``customdata``. 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 from __future__ import annotations
@ -38,6 +50,12 @@ _PAGE = """<!DOCTYPE html>
<style> <style>
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; } html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
#plot { width: 100%; height: 100%; } #plot { width: 100%; height: 100%; }
#otko-snap-marker {
position: fixed; display: none; width: 13px; height: 13px; z-index: 5;
margin: 0; pointer-events: none; border-radius: 50%;
background: #ffd900; border: 1px solid #8a6d00;
transform: translate(-50%, -50%);
}
.modebar { display: none !important; } .modebar { display: none !important; }
</style> </style>
</head> </head>
@ -49,7 +67,6 @@ _PAGE = """<!DOCTYPE html>
var bridge = null; var bridge = null;
var snapEnabled = false; var snapEnabled = false;
var handlersReady = false; var handlersReady = false;
var hoverIndex = -1;
var config = { var config = {
responsive: true, responsive: true,
@ -69,44 +86,78 @@ _PAGE = """<!DOCTYPE html>
return data && data.meta ? data.meta.kind : null; return data && data.meta ? data.meta.kind : null;
} }
function findHover() {
var gd = document.getElementById('plot');
hoverIndex = -1;
if (!gd || !gd.data) return;
for (var i = 0; i < gd.data.length; i++) {
var meta = gd.data[i] && gd.data[i].meta;
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
}
// A freshly pushed figure has an empty hover marker again.
hoverShown = false;
hoverPoint = null;
}
// --- snap-target marker ------------------------------------------------ // --- snap-target marker ------------------------------------------------
// Updated synchronously but only when the visual state actually changes. // A pointer-events-none DOM dot positioned by projecting the snapped world
// Restyling on every hover/unhover made the plot redraw per mouse move, // point through gl-plot3d's own camera matrices. It deliberately is *not* a
// which re-fired hover and ended in a runaway redraw loop (RangeError: // plotly trace: Plotly.restyle on a gl3d plot costs ~90 ms even for a single
// Maximum call stack size exceeded, frozen canvas). Timers were avoided // trace, so a trace marker lagged far behind the cursor and stalled orbiting.
// deliberately: WebEngine throttles them to ~1 s when the page is not // A projection plus one style write is a few microseconds.
// compositing, which made the preview lag. var snapPoint = null;
var hoverShown = false; var snapMarker = null;
var hoverPoint = null;
function samePoint(a, b) { function snapMarkerElement() {
return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; if (!snapMarker) {
snapMarker = document.createElement('div');
snapMarker.id = 'otko-snap-marker';
document.body.appendChild(snapMarker);
}
return snapMarker;
} }
function setHoverMarker(point) { function mat4Multiply(a, b) {
var want = snapEnabled ? point : null; var out = new Array(16);
if (samePoint(want, hoverPoint) && !!want === hoverShown) return; for (var col = 0; col < 4; col++) {
if (!want && !hoverShown) { hoverPoint = null; return; } for (var row = 0; row < 4; row++) {
hoverPoint = want; var sum = 0;
hoverShown = !!want; for (var k = 0; k < 4; k++) { sum += a[k * 4 + row] * b[col * 4 + k]; }
if (hoverIndex < 0) return; out[col * 4 + row] = sum;
var x = want ? [want[0]] : []; }
var y = want ? [want[1]] : []; }
var z = want ? [want[2]] : []; return out;
Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]); }
function projectToClient(point) {
var gd = document.getElementById('plot');
var scene = gd && gd._fullLayout ? gd._fullLayout.scene._scene : null;
var params = scene && scene.glplot ? scene.glplot.cameraParams : null;
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, 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];
if (!isFinite(clipW) || Math.abs(clipW) < 1e-9) return null;
var clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
var clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
var canvas = scene.glplot.canvas;
var ratio = scene.glplot.pixelRatio || 1;
var localX = ((clipX / clipW) * 0.5 + 0.5) * canvas.width / ratio;
var localY = (1 - ((clipY / clipW) * 0.5 + 0.5)) * canvas.height / ratio;
if (localX < 0 || localY < 0 || localX > canvas.clientWidth || localY > canvas.clientHeight) {
return null; // off-screen target
}
var rect = canvas.getBoundingClientRect();
return [rect.left + localX, rect.top + localY];
}
function setSnapMarker(point) {
var marker = snapMarkerElement();
snapPoint = snapEnabled ? point : null;
if (!snapPoint) { marker.style.display = 'none'; return; }
var position = projectToClient(snapPoint);
if (!position) { marker.style.display = 'none'; return; }
marker.style.left = position[0] + 'px';
marker.style.top = position[1] + 'px';
marker.style.display = 'block';
}
function refreshSnapMarker() {
if (snapPoint) setSnapMarker(snapPoint);
} }
// --- view preservation ------------------------------------------------- // --- view preservation -------------------------------------------------
@ -178,8 +229,8 @@ _PAGE = """<!DOCTYPE html>
mergeView(fig, job.preserve ? currentView() : null); mergeView(fig, job.preserve ? currentView() : null);
Plotly.react('plot', fig.data, fig.layout, config).then(function () { Plotly.react('plot', fig.data, fig.layout, config).then(function () {
findHover();
installHandlers(); installHandlers();
logRendererOnce();
}, function (err) { }, function (err) {
if (window.console) console.error('otko: react failed', err); if (window.console) console.error('otko: react failed', err);
}).then(function () { }).then(function () {
@ -188,6 +239,24 @@ _PAGE = """<!DOCTYPE html>
}); });
} }
// One-off diagnostic: whether WebGL is hardware-accelerated decides how
// smooth orbiting feels, and it is invisible from Python otherwise.
var loggedRenderer = false;
function logRendererOnce() {
if (loggedRenderer) return;
loggedRenderer = true;
try {
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) { if (window.console) console.warn('otko: WebGL unavailable'); return; }
var info = gl.getExtension('WEBGL_debug_renderer_info');
var name = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
if (window.console) console.warn('otko: WebGL renderer = ' + name);
} catch (err) {
if (window.console) console.warn('otko: WebGL probe failed: ' + err.message);
}
}
window.otkoUpdate = function (payloadJson, preserveView) { window.otkoUpdate = function (payloadJson, preserveView) {
pendingUpdate = { payload: payloadJson, preserve: !!preserveView }; pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
runUpdate(); runUpdate();
@ -218,26 +287,166 @@ _PAGE = """<!DOCTYPE html>
var pts = ev.points || []; var pts = ev.points || [];
if (!pts.length) return; if (!pts.length) return;
var p = pts[0]; var p = pts[0];
if (kindOf(p) !== 'snap') { setHoverMarker(null); return; } if (kindOf(p) !== 'snap') { setSnapMarker(null); return; }
var c = p.customdata; var c = p.customdata;
if (c) setHoverMarker([c[0], c[1], c[2]]); if (c) setSnapMarker([c[0], c[1], c[2]]);
}); });
// Orbit/zoom moves the world point on screen: re-project so the marker
// stays glued to the target without a plotly redraw.
gd.on('plotly_relayout', function () { refreshSnapMarker(); });
gd.on('plotly_unhover', function () { gd.on('plotly_unhover', function () {
// Nothing can be visible unless snapping is armed, so stay out of the // Nothing can be visible unless snapping is armed, so stay out of the
// redraw path entirely for ordinary mouse movement. // redraw path entirely for ordinary mouse movement.
if (!snapEnabled) return; if (!snapEnabled) return;
setHoverMarker(null); 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) { window.otkoSetCamera = function (cameraJson) {
Plotly.relayout('plot', { 'scene.camera': JSON.parse(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) { window.otkoSetSnapEnabled = function (on) {
snapEnabled = !!on; snapEnabled = !!on;
if (!snapEnabled) setHoverMarker(null); if (!snapEnabled) setSnapMarker(null);
}; };
})(); })();
</script> </script>

View file

@ -38,6 +38,7 @@ from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder, PlotlyTraceBuilder,
Scene, Scene,
SceneOptions, SceneOptions,
framed_camera_distance,
) )
#: View preset directions (unit-ish vectors from the scene centre to the eye). #: 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 #: Force diagrams and off-screen video capture are not implemented on
#: this backend yet (both are PyVista-specific today). #: 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__( def __init__(
self, self,
@ -170,6 +173,7 @@ class PlotlyCanvas(QWidget):
self._parallel = False self._parallel = False
self._view_preset = "iso" self._view_preset = "iso"
self._snap_enabled = False self._snap_enabled = False
self._show_undeformed = False
self._default_selection_enabled = True self._default_selection_enabled = True
self._working_plane: tuple[str, float] | None = None self._working_plane: tuple[str, float] | None = None
self._camera = _CameraShim(self) self._camera = _CameraShim(self)
@ -308,6 +312,45 @@ class PlotlyCanvas(QWidget):
self._options = replace(self._options, show_local_axes=bool(enabled)) self._options = replace(self._options, show_local_axes=bool(enabled))
self.render() 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: def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the figure. """Swap the visual style and repaint the figure.
@ -344,7 +387,10 @@ class PlotlyCanvas(QWidget):
``reset_camera``) sends the computed framing. Style changes are ``reset_camera``) sends the computed framing. Style changes are
non-framing: they re-colour in place. 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: if not self._ready:
# Nothing to push yet; crucially this must come *before* the # Nothing to push yet; crucially this must come *before* the
# framing flag is consumed, or the framing scheduled before # 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)})") self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
def _camera_dict(self, scene: Scene) -> dict[str, Any]: def _camera_dict(self, scene: Scene) -> dict[str, Any]:
cx, cy, cz = scene.center """A framing camera in plotly's normalized scene coordinates.
distance = max(scene.diagonal, 1e-6) * 1.6
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"]) direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
norm = math.sqrt(sum(component * component for component in direction)) or 1.0 norm = math.sqrt(sum(component * component for component in direction)) or 1.0
eye = ( eye = (
cx + direction[0] / norm * distance, direction[0] / norm * distance,
cy + direction[1] / norm * distance, direction[1] / norm * distance,
cz + direction[2] / norm * distance, direction[2] / norm * distance,
) )
# Looking straight down the Z axis needs a non-degenerate up vector. # Turntable dragmode pins up to +Z; the turntable controller derives a
up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0) # 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 { return {
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]}, "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]}, "up": {"x": up[0], "y": up[1], "z": up[2]},
"projection": {"type": "orthographic" if self._parallel else "perspective"}, "projection": {"type": "orthographic" if self._parallel else "perspective"},
} }

View file

@ -24,7 +24,9 @@ tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads
from __future__ import annotations 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 from typing import Any
import numpy as np import numpy as np
@ -76,7 +78,7 @@ _BOX_TRIS = (
(1, 6, 5), (1, 6, 5),
) )
#: Support kind → plotly 3D marker symbol. #: Support kind → plotly 3D marker symbol (fallback for rotation-only supports).
_SUPPORT_SYMBOLS = { _SUPPORT_SYMBOLS = {
"fix": "square", "fix": "square",
"pin": "triangle-up", "pin": "triangle-up",
@ -88,6 +90,18 @@ _NODE_MARKER_SIZE = 7.0
_SUPPORT_MARKER_SIZE = 11.0 _SUPPORT_MARKER_SIZE = 11.0
_SNAP_MARKER_SIZE = 8.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) @dataclass(frozen=True)
class SceneOptions: class SceneOptions:
@ -102,6 +116,13 @@ class SceneOptions:
show_element_labels: bool = False show_element_labels: bool = False
show_extrusions: bool = False show_extrusions: bool = False
show_local_axes: 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 @dataclass
@ -112,10 +133,11 @@ class Scene:
layout: dict[str, Any] layout: dict[str, Any]
center: tuple[float, float, float] = (0.0, 0.0, 0.0) center: tuple[float, float, float] = (0.0, 0.0, 0.0)
diagonal: float = 1.0 diagonal: float = 1.0
#: Trace index (in ``data``) of the empty hover-snap marker, or -1.
hover_trace: int = -1
#: Padded ``(min, max)`` per axis, used to frame the camera deterministically. #: Padded ``(min, max)`` per axis, used to frame the camera deterministically.
axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict) 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]: def to_payload(self) -> dict[str, Any]:
"""Figure dict without the camera — camera is owned by the widget.""" """Figure dict without the camera — camera is owned by the widget."""
@ -215,6 +237,54 @@ def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float:
return diagonal if diagonal > 0 else 1.0 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]: def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``.""" """Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
x = x_local / float(np.linalg.norm(x_local)) x = x_local / float(np.linalg.norm(x_local))
@ -264,6 +334,104 @@ def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) ->
return "custom" 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( def _line_trace(
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
*, *,
@ -320,12 +488,28 @@ class PlotlyTraceBuilder:
nodes = list(project.nodes) nodes = list(project.nodes)
node_ids = [n.id for n in 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)) 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): if opts.deformation is not None and len(points):
points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float) points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float)
node_row = {nid: i for i, nid in enumerate(node_ids)} 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]] = [] data: list[dict[str, Any]] = []
grid_pts = self._build_grid(project, data, opts) 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_extrusions(project, data, opts)
self._build_local_axes(project, data, opts) self._build_local_axes(project, data, opts)
self._build_loads(project, data, opts) self._build_loads(project, data, opts)
@ -333,21 +517,25 @@ class PlotlyTraceBuilder:
self._build_frames(project, data, opts, points, node_row) self._build_frames(project, data, opts, points, node_row)
self._build_nodes(data, opts, points, node_ids) self._build_nodes(data, opts, points, node_ids)
self._build_labels(project, data, opts, points, node_row) self._build_labels(project, data, opts, points, node_row)
hover_trace = self._build_hover_marker(data)
candidates = [points] if len(points) else [] # Frame the structure, not the (often much larger) reference grid:
if grid_pts is not None: # SAP-style "zoom extents" fits the model. The grid still draws, but
candidates.append(grid_pts) # does not shrink the model into a corner of the viewport.
all_pts = np.vstack(candidates) if candidates else np.empty((0, 3)) if len(points):
center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0) frame_pts = points
bounds = _padded_axis_bounds(all_pts) 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( return Scene(
data=data, data=data,
layout=self._layout(), layout=self._layout(),
center=(float(center[0]), float(center[1]), float(center[2])), center=(float(center[0]), float(center[1]), float(center[2])),
diagonal=_diag_of_bounds(bounds), diagonal=_diag_of_bounds(bounds),
hover_trace=hover_trace,
axis_bounds=bounds, axis_bounds=bounds,
data_bounds=_raw_axis_bounds(frame_pts),
) )
# ── layout ─────────────────────────────────────────────────────── # ── layout ───────────────────────────────────────────────────────
@ -380,7 +568,10 @@ class PlotlyTraceBuilder:
"scene": { "scene": {
"bgcolor": style.background_bottom, "bgcolor": style.background_bottom,
"aspectmode": "data", "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"), "xaxis": axis(style.fix_color, "X"),
"yaxis": axis(style.load_color, "Y"), "yaxis": axis(style.load_color, "Y"),
"zaxis": axis(style.truss_color, "Z"), "zaxis": axis(style.truss_color, "Z"),
@ -530,12 +721,41 @@ class PlotlyTraceBuilder:
) -> None: ) -> None:
if not len(points): if not len(points):
return return
colors = [ scalars = opts.scalars
self._style.node_selected_color if scalars is not None and len(scalars) == len(points):
if nid in opts.selection_nodes low, high = _scalar_clim(scalars, opts.scalar_clim)
else self._style.node_color marker: dict[str, Any] = {
for nid in node_ids "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]}<br>%{customdata[1]:.4g}<extra></extra>"
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}<extra></extra>"
data.append( data.append(
{ {
"type": "scatter3d", "type": "scatter3d",
@ -543,16 +763,12 @@ class PlotlyTraceBuilder:
"x": [float(p[0]) for p in points], "x": [float(p[0]) for p in points],
"y": [float(p[1]) for p in points], "y": [float(p[1]) for p in points],
"z": [float(p[2]) for p in points], "z": [float(p[2]) for p in points],
"marker": { "marker": marker,
"color": colors, "customdata": customdata,
"size": _NODE_MARKER_SIZE,
"line": {"color": "#4d4d4d", "width": 1},
},
"customdata": list(node_ids),
"meta": {"kind": "node"}, "meta": {"kind": "node"},
# Engineering-notation hover (opstool's trace recipe) so a # Engineering-notation hover (opstool's trace recipe) so a
# hover identifies the entity instead of showing the raw id. # hover identifies the entity instead of showing the raw id.
"hovertemplate": "Node #%{customdata}<extra></extra>", "hovertemplate": hovertemplate,
"name": "nodes", "name": "nodes",
"showlegend": False, "showlegend": False,
} }
@ -577,13 +793,16 @@ class PlotlyTraceBuilder:
""" """
if not len(points): if not len(points):
return 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) scale_colors = family_palette(self._style)
selected_index = len(scale_colors) - 1 selected_index = len(scale_colors) - 1
x: list[float | None] = [] x: list[float | None] = []
y: list[float | None] = [] y: list[float | None] = []
z: list[float | None] = [] z: list[float | None] = []
color_index: list[float] = [] color_value: list[float | None] = []
customdata: list[Any] = [] customdata: list[Any] = []
for el in project.elements: for el in project.elements:
if not isinstance(el, _FRAME_CLASSES): if not isinstance(el, _FRAME_CLASSES):
@ -592,18 +811,40 @@ class PlotlyTraceBuilder:
j = node_row.get(el.nodes[1]) j = node_row.get(el.nodes[1])
if i is None or j is None: if i is None or j is None:
continue 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]) x.extend([float(points[i][0]), float(points[j][0]), None])
y.extend([float(points[i][1]), float(points[j][1]), None]) y.extend([float(points[i][1]), float(points[j][1]), None])
z.extend([float(points[i][2]), float(points[j][2]), 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]) customdata.extend([el.id, el.id, None])
if not x: if not x:
return 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( data.append(
{ {
"type": "scatter3d", "type": "scatter3d",
@ -611,15 +852,7 @@ class PlotlyTraceBuilder:
"x": x, "x": x,
"y": y, "y": y,
"z": z, "z": z,
"line": { "line": line,
"color": color_index,
"colorscale": [
(index / (count - 1), color) for index, color in enumerate(scale_colors)
],
"cmin": 0,
"cmax": count - 1,
"width": 4,
},
"customdata": customdata, "customdata": customdata,
"meta": {"kind": "element"}, "meta": {"kind": "element"},
"hovertemplate": "Element #%{customdata}<extra></extra>", "hovertemplate": "Element #%{customdata}<extra></extra>",
@ -628,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( def _build_supports(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
) -> None: ) -> 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: if not project.nodes:
return 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) dof_idx = _dof_indices(project.ndf)
groups: dict[str, list[Any]] = {}
for node in project.nodes: 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 continue
kind = _classify_support(node.restraint, dof_idx) loops = _support_loops(
groups.setdefault(kind, []).append(node) (float(node.coords[0]), float(node.coords[1]), float(node.coords[2])),
for kind, nodes in groups.items(): 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( data.append(
{ {
"type": "scatter3d", "type": "scatter3d",
"mode": "markers", "mode": "markers",
"x": [float(n.coords[0]) for n in nodes], "x": [float(n.coords[0]) for n in markers],
"y": [float(n.coords[1]) for n in nodes], "y": [float(n.coords[1]) for n in markers],
"z": [float(n.coords[2]) for n in nodes], "z": [float(n.coords[2]) for n in markers],
"marker": { "marker": {
"color": self._style.support_color, "color": self._style.support_color,
"size": _SUPPORT_MARKER_SIZE, "size": _SUPPORT_MARKER_SIZE,
"symbol": _SUPPORT_SYMBOLS[kind], "symbol": "square",
"line": {"color": "#7f3f00", "width": 1}, "line": {"color": "#7f3f00", "width": 1},
}, },
"hoverinfo": "skip", "hoverinfo": "skip",
"name": f"support-{kind}", "name": "supports",
"showlegend": False, "showlegend": False,
} }
) )
@ -664,6 +966,12 @@ class PlotlyTraceBuilder:
def _build_loads( def _build_loads(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
) -> None: ) -> 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: if not project.load_patterns or not project.nodes:
return return
node_by_id = {n.id: n for n in project.nodes} node_by_id = {n.id: n for n in project.nodes}
@ -671,22 +979,16 @@ class PlotlyTraceBuilder:
pts = np.array([n.coords for n in project.nodes], dtype=float) pts = np.array([n.coords for n in project.nodes], dtype=float)
scale = max(_diag_of_points(pts) * 0.05, 1e-6) scale = max(_diag_of_points(pts) * 0.05, 1e-6)
nodal_x: list[float] = [] patterns = [
nodal_y: list[float] = [] pattern for pattern in project.load_patterns if isinstance(pattern, PlainLoadPattern)
nodal_z: list[float] = [] ]
nodal_u: list[float] = [] if not patterns:
nodal_v: list[float] = [] return
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] = []
for pattern in project.load_patterns: # (pattern index, tail, unit direction, magnitude, subject label)
if not isinstance(pattern, PlainLoadPattern): nodal: list[tuple[int, np.ndarray, np.ndarray, float, str]] = []
continue dist: list[tuple[int, np.ndarray, np.ndarray, float, str]] = []
for pat_idx, pattern in enumerate(patterns):
for nload in pattern.nodal_loads: for nload in pattern.nodal_loads:
if not isinstance(nload, NodalLoad): if not isinstance(nload, NodalLoad):
continue continue
@ -697,15 +999,15 @@ class PlotlyTraceBuilder:
mag = float(np.linalg.norm(f)) mag = float(np.linalg.norm(f))
if mag < 1e-12: if mag < 1e-12:
continue continue
direction = f / mag nodal.append(
tail = np.asarray(node.coords, dtype=float) - direction * scale (
nodal_x.append(float(tail[0])) pat_idx,
nodal_y.append(float(tail[1])) np.asarray(node.coords, dtype=float),
nodal_z.append(float(tail[2])) f / mag,
nodal_u.append(float(direction[0])) mag,
nodal_v.append(float(direction[1])) f"N{nload.node_id}",
nodal_w.append(float(direction[2])) )
)
for eload in pattern.element_loads: for eload in pattern.element_loads:
if not isinstance(eload, UniformElementLoad): if not isinstance(eload, UniformElementLoad):
continue continue
@ -734,45 +1036,60 @@ class PlotlyTraceBuilder:
if mag < 1e-12: if mag < 1e-12:
continue continue
direction = load_vec / mag direction = load_vec / mag
n_arrows = 5 for k in range(5):
for k in range(n_arrows): t = (k + 0.5) / 5.0
t = (k + 0.5) / n_arrows dist.append((pat_idx, pi + t * axis, direction, mag, f"E{eload.element_id}"))
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]))
if nodal_x: max_mag = max((entry[3] for entry in (*nodal, *dist)), default=0.0)
data.append( if max_mag <= 0.0:
_cone_trace( return
nodal_x, colors = self._pattern_colors(len(patterns))
nodal_y, # With a single pattern there is nothing to distinguish, so keep the
nodal_z, # per-type default tints (nodal vs element loads).
nodal_u, single = len(patterns) == 1
nodal_v,
nodal_w, def _arrow_len(mag: float) -> float:
color=self._style.nodal_load_color, return max(scale * mag / max_mag, 0.15 * scale)
name="nodal-loads",
size=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}<br>|F| = {mag:.4g}")
data.append(
_cone_trace(
x,
y,
z,
u,
v,
w,
color=color,
name=name,
size=width,
customdata=hovers,
)
) )
)
if dist_x: def _pattern_colors(self, count: int) -> list[str]:
data.append( """One palette colour per load pattern (used only when count > 1)."""
_cone_trace( palette = _PATTERN_PALETTE
dist_x, return [palette[index % len(palette)] for index in range(count)]
dist_y,
dist_z,
dist_u,
dist_v,
dist_w,
color=self._style.element_load_color,
name="element-loads",
size=0.6 * scale,
)
)
def _build_local_axes( def _build_local_axes(
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
@ -818,9 +1135,10 @@ class PlotlyTraceBuilder:
bucket["x"].append(float(mid[0])) bucket["x"].append(float(mid[0]))
bucket["y"].append(float(mid[1])) bucket["y"].append(float(mid[1]))
bucket["z"].append(float(mid[2])) bucket["z"].append(float(mid[2]))
bucket["u"].append(float(direction[0])) # Cone length = (u, v, w) magnitude: scale to the model.
bucket["v"].append(float(direction[1])) bucket["u"].append(float(direction[0] * cap))
bucket["w"].append(float(direction[2])) bucket["v"].append(float(direction[1] * cap))
bucket["w"].append(float(direction[2] * cap))
for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")): for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")):
bucket = axes[key] bucket = axes[key]
if bucket["x"]: if bucket["x"]:
@ -834,7 +1152,7 @@ class PlotlyTraceBuilder:
bucket["w"], bucket["w"],
color=color, color=color,
name=f"local-{key}", name=f"local-{key}",
size=cap, size=0.12,
) )
) )
@ -951,28 +1269,6 @@ class PlotlyTraceBuilder:
) )
) )
@staticmethod
def _build_hover_marker(data: list[dict[str, Any]]) -> int:
data.append(
{
"type": "scatter3d",
"mode": "markers",
"x": [],
"y": [],
"z": [],
"marker": {
"color": "#ffd900",
"size": 13,
"line": {"color": "#8a6d00", "width": 1},
},
"hoverinfo": "skip",
"name": "snap-hover",
"showlegend": False,
"meta": {"kind": "hover"},
}
)
return len(data) - 1
def _cone_trace( def _cone_trace(
x: list[float], x: list[float],
@ -984,9 +1280,17 @@ def _cone_trace(
*, *,
color: str, color: str,
name: str, name: str,
size: float, size: float = 0.4,
customdata: list[Any] | None = None,
) -> dict[str, Any]: ) -> 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", "type": "cone",
"x": x, "x": x,
"y": y, "y": y,
@ -995,7 +1299,7 @@ def _cone_trace(
"v": v, "v": v,
"w": w, "w": w,
"anchor": "tail", "anchor": "tail",
"sizemode": "absolute", "sizemode": "scaled",
"sizeref": float(size), "sizeref": float(size),
"colorscale": [[0, color], [1, color]], "colorscale": [[0, color], [1, color]],
"showscale": False, "showscale": False,
@ -1003,6 +1307,11 @@ def _cone_trace(
"name": name, "name": name,
"showlegend": False, "showlegend": False,
} }
if customdata is not None:
trace["customdata"] = customdata
trace["hovertemplate"] = "%{customdata}<extra></extra>"
trace["hoverinfo"] = "text"
return trace
def _text_trace( def _text_trace(

View file

@ -30,6 +30,7 @@ from otko.views.dialogs.locate_origin import (
from otko.views.dialogs.material_library import MaterialLibraryDialog from otko.views.dialogs.material_library import MaterialLibraryDialog
from otko.views.dialogs.material_tester import MaterialTesterDialog from otko.views.dialogs.material_tester import MaterialTesterDialog
from otko.views.dialogs.mirror import MirrorDialog 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.move import MoveDialog
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
from otko.views.dialogs.pattern_loads import PatternLoadsDialog from otko.views.dialogs.pattern_loads import PatternLoadsDialog
@ -66,6 +67,7 @@ __all__ = [
"MaterialLibraryDialog", "MaterialLibraryDialog",
"MaterialTesterDialog", "MaterialTesterDialog",
"MirrorDialog", "MirrorDialog",
"MouseControlsDialog",
"MoveDialog", "MoveDialog",
"PathTimeSeriesDialog", "PathTimeSeriesDialog",
"PatternLoadsDialog", "PatternLoadsDialog",

View file

@ -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 = """<h3>Mouse Controls</h3>
<p>The 3D viewport follows the VTK trackball convention on both canvas
backends (<b>Options &rarr; Canvas Backend</b>).</p>
<h4>Navigate the view</h4>
<table cellpadding="3" cellspacing="0">
<tr><td><b>Rotate</b></td><td>Left-drag</td></tr>
<tr><td><b>Pan</b></td><td>Shift + left-drag, or middle-drag</td></tr>
<tr><td><b>Zoom</b></td><td>Right-drag, or mouse wheel</td></tr>
<tr><td><b>Spin</b> (roll)</td><td>Ctrl + left-drag</td></tr>
<tr><td><b>Zoom</b> (from key)</td><td>Ctrl + Shift + left-drag</td></tr>
<tr><td><b>Environment rotate</b></td><td>Shift + right-drag</td></tr>
</table>
<p>Alt is not used for view navigation. Horizontal-wheel input rolls the
Plotly view; on PyVista it has no effect.</p>
<h4>Select</h4>
<table cellpadding="3" cellspacing="0">
<tr><td><b>Select</b></td><td>Left-click a node or element</td></tr>
<tr><td><b>Add / toggle</b></td><td>Ctrl+click or Shift+click</td></tr>
</table>
<p>A drag rotates the view; only a press-and-release without movement counts
as a click, so panning never changes the selection.</p>
<h4>View presets</h4>
<table cellpadding="3" cellspacing="0">
<tr><td><b>Isometric</b></td><td>Ctrl+1</td></tr>
<tr><td><b>Top (XY)</b></td><td>Ctrl+2</td></tr>
<tr><td><b>Front (XZ)</b></td><td>Ctrl+3</td></tr>
<tr><td><b>Right (YZ)</b></td><td>Ctrl+4</td></tr>
<tr><td><b>Zoom Extents</b></td><td>Ctrl+E</td></tr>
</table>
"""
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)

View file

@ -50,6 +50,10 @@ or <b>Assign → Frame</b>: <b>Section…</b>, <b>Material…</b>,
<b>Show Force Diagram…</b> and the other plot actions; <b>Show Force Diagram…</b> and the other plot actions;
<b>Display → Show Undeformed Shape</b>, <b>Clear Display</b> or <b>Display → Show Undeformed Shape</b>, <b>Clear Display</b> or
<b>Back to Model View</b> (Ctrl+Shift+B) to return to the model.</p> <b>Back to Model View</b> (Ctrl+Shift+B) to return to the model.</p>
<h4>8&nbsp;— Navigate the 3D view</h4>
<p>Left-drag rotates, Shift+left-drag (or middle-drag) pans, right-drag or the
wheel zooms, Ctrl+left-drag spins. Full list under <b>Help → Mouse Controls</b>.</p>
""" """

View file

@ -197,11 +197,44 @@ class DockManager:
animator.frameChanged.connect(_apply) animator.frameChanged.connect(_apply)
animator.closed.connect(self._on_back_to_model) 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( animator.exportRequested.connect(
lambda: self._on_export_mode_shape(animator, _apply), lambda: self._on_export_mode_shape(animator, _apply),
) )
# Animator emits an initial frame in its constructor; nothing to do. # 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] 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. """Capture one period of the current mode shape to MP4/GIF.

View file

@ -28,6 +28,7 @@ class ModeShapeAnimator(QWidget):
""" """
frameChanged = Signal(int, float, float) frameChanged = Signal(int, float, float)
playToggled = Signal(bool)
closed = Signal() closed = Signal()
exportRequested = Signal() exportRequested = Signal()
@ -41,6 +42,7 @@ class ModeShapeAnimator(QWidget):
self._n_modes = n_modes self._n_modes = n_modes
self._freqs = frequencies_hz self._freqs = frequencies_hz
self._t = 0.0 self._t = 0.0
self._in_page = False
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.timeout.connect(self._on_tick) self._timer.timeout.connect(self._on_tick)
self._timer.setInterval(int(1000 / self._FPS)) self._timer.setInterval(int(1000 / self._FPS))
@ -111,6 +113,17 @@ class ModeShapeAnimator(QWidget):
def current_scale(self) -> float: def current_scale(self) -> float:
return float(self._scale.value()) 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: def set_results(self, results: object | None) -> None:
"""Rebind to a new modal run while the dock stays open. """Rebind to a new modal run while the dock stays open.
@ -174,6 +187,8 @@ class ModeShapeAnimator(QWidget):
self._play_btn.setText("▶ Play") self._play_btn.setText("▶ Play")
self._timer.stop() self._timer.stop()
self._scrubber.setEnabled(True) self._scrubber.setEnabled(True)
self._in_page = False
self.playToggled.emit(playing)
def _on_stop(self) -> None: def _on_stop(self) -> None:
self._timer.stop() self._timer.stop()
@ -201,6 +216,8 @@ class ModeShapeAnimator(QWidget):
self._scrubber.blockSignals(True) self._scrubber.blockSignals(True)
self._scrubber.setValue(int(round(phase * 100))) self._scrubber.setValue(int(round(phase * 100)))
self._scrubber.blockSignals(False) 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) self.frameChanged.emit(self._mode_combo.currentData(), self._scale.value(), phase)
def _emit_static_frame(self) -> None: def _emit_static_frame(self) -> None:

View file

@ -230,6 +230,7 @@ class MainWindow(
# Re-apply the overlays whose state lives on the toolbar actions. # 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_section_extrusions(self._act_show_extruded.isChecked())
self._canvas.set_show_local_axes(self._act_show_local_axes.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( self._canvas.set_display_options(
show_node_labels=self._show_node_labels, show_node_labels=self._show_node_labels,
show_element_labels=self._show_element_labels, show_element_labels=self._show_element_labels,
@ -308,6 +309,7 @@ class MainWindow(
self._act_quit.triggered.connect(self.close) self._act_quit.triggered.connect(self.close)
self._act_about.triggered.connect(self._on_about) self._act_about.triggered.connect(self._on_about)
self._act_quick_guide.triggered.connect(self._on_quick_guide) 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_set_units.triggered.connect(self._on_set_units)
self._act_plot_properties.triggered.connect(self._on_plot_properties) 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_toggle_parallel.toggled.connect(self._on_toggle_parallel)
self._act_show_extruded.toggled.connect(self._canvas.set_show_section_extrusions) 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) 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. # ViewModel — projectChanged / modelMutated survive the split.
self._vm.projectChanged.connect(self._on_project_changed) self._vm.projectChanged.connect(self._on_project_changed)

View file

@ -141,6 +141,11 @@ class MenuBuilder:
"Exit any deformed / mode-shape / force-diagram view back to " "Exit any deformed / mode-shape / force-diagram view back to "
"the model geometry. Result data is kept." "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 = QAction("&Clear Display", self)
self._act_clear_display.setToolTip( self._act_clear_display.setToolTip(
"Neutral canvas: undeformed geometry, no overlays, selection " "Neutral canvas: undeformed geometry, no overlays, selection "
@ -191,6 +196,7 @@ class MenuBuilder:
self._act_about = QAction("&About OTKO…", self) self._act_about = QAction("&About OTKO…", self)
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run. # No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
self._act_quick_guide = QAction("&Quick Guide", self) 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_set_units = QAction("Set Display &Units…", self)
self._act_plot_properties = QAction("&Plot Properties…", self) self._act_plot_properties = QAction("&Plot Properties…", self)
self._act_plot_properties.setToolTip( self._act_plot_properties.setToolTip(
@ -481,6 +487,7 @@ class MenuBuilder:
m_display.addSeparator() m_display.addSeparator()
m_display.addAction(self._act_back_to_model) m_display.addAction(self._act_back_to_model)
m_display.addAction(self._act_show_undeformed) m_display.addAction(self._act_show_undeformed)
m_display.addAction(self._act_show_reference)
m_display.addAction(self._act_clear_display) m_display.addAction(self._act_clear_display)
m_view = mb.addMenu("&View") m_view = mb.addMenu("&View")
@ -524,6 +531,7 @@ class MenuBuilder:
m_help = mb.addMenu("&Help") m_help = mb.addMenu("&Help")
m_help.addAction(self._act_quick_guide) m_help.addAction(self._act_quick_guide)
m_help.addAction(self._act_mouse_controls)
m_help.addAction(self._act_about) m_help.addAction(self._act_about)
def _build_view_toolbar(self) -> None: def _build_view_toolbar(self) -> None:

View file

@ -84,9 +84,22 @@ class RenderControls:
self._tear_down_post_dock() self._tear_down_post_dock()
if self._diagram_renderer is not None: if self._diagram_renderer is not None:
self._diagram_renderer.clear() 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._renderer.set_mode(RendererMode.MODEL)
self._canvas.render() 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: def _on_clear_display(self) -> None:
"""Clear Display: neutral canvas — undeformed MODEL, no selection. """Clear Display: neutral canvas — undeformed MODEL, no selection.
@ -513,6 +526,7 @@ class RenderControls:
# Undeformed exits any post view (dock, renderer mode or diagram # Undeformed exits any post view (dock, renderer mode or diagram
# overlay); Clear just needs a project — both keep result data. # 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_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) self._act_clear_display.setEnabled(has_project)
def _log(self, message: str) -> None: def _log(self, message: str) -> None:

View file

@ -39,3 +39,21 @@ def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignor
assert not window.isVisible() assert not window.isVisible()
assert window.close() is True assert window.close() is True
assert window._vm.is_dirty # unchanged: the prompt was skipped assert window._vm.is_dirty # unchanged: the prompt was skipped
@pytest.mark.gui
def test_mouse_controls_dialog_is_wired_and_reused(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.mouse_controls import MouseControlsDialog
from otko.views.main_window import MainWindow
window = MainWindow()
qtbot.addWidget(window)
window._on_mouse_controls()
dialog = window._mouse_controls_dialog
assert isinstance(dialog, MouseControlsDialog)
assert dialog.windowTitle() == "Mouse Controls"
# Raising the action again reuses the same modeless window.
window._on_mouse_controls()
assert window._mouse_controls_dialog is dialog

View file

@ -0,0 +1,166 @@
"""Regression tests for the Plotly canvas mouse-gesture layer.
plotly's built-in drag bindings (shift+left rotates, ctrl+left pans, middle
zooms, right pans) disagree with the PyVista backend's VTK trackball style, so
the Plotly canvas re-implements them in JS to match VTK: shift+left pan,
ctrl+left spin, middle pan, right zoom.
The page runs offscreen under QtWebEngine, where gl-plot3d's render loop is
throttled and camera animations do not advance, so these tests spy on the
gl-plot3d camera's ``view.pan`` / ``view.rotate`` calls and assert the mapping
(and that plotly's own handler is suppressed for the gesture) rather than the
resulting camera position. A full-camera smoke check is unnecessary here;
plotly owns the maths.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
pytest.importorskip("PySide6")
pytest.importorskip("plotly")
from otko.services import load_project
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
#: Install spies on the live camera, run one synthetic drag, report the calls.
#: ``keyBindingMode`` distinguishes our layer (false) from plotly's native
#: handler ('rotate'), which calls the same ``view`` methods.
_SPY_DRAG = """(function(){
var gd = document.getElementById('plot');
var s = gd._fullLayout.scene._scene;
var cam = s.camera;
var el = s.glplot.canvas;
var r = el.getBoundingClientRect();
var x0 = r.left + r.width/2, y0 = r.top + r.height/2;
var calls = [];
var origPan = cam.view.pan;
var origRotate = cam.view.rotate;
cam.view.pan = function (t, dx, dy, dz) {
calls.push(['pan', cam.keyBindingMode, dx, dy, dz]);
};
cam.view.rotate = function (t, p, y, ro) {
calls.push(['rotate', cam.keyBindingMode, p, y, ro]);
};
function ev(t, x, y, b) {
el.dispatchEvent(new MouseEvent(t, {
bubbles: true, cancelable: true, view: window,
clientX: x, clientY: y, button: __BUTTON__, buttons: b,
shiftKey: __SHIFT__, ctrlKey: __CTRL__, altKey: __ALT__
}));
}
ev('mousedown', x0, y0, __BUTTONS__);
ev('mousemove', x0 + 60, y0 + 40, __BUTTONS__);
ev('mouseup', x0 + 60, y0 + 40, 0);
cam.view.pan = origPan;
cam.view.rotate = origRotate;
return JSON.stringify({calls: calls, kbm: cam.keyBindingMode});
})()"""
def _js_bool(value: bool) -> str:
return "true" if value else "false"
def _open_canvas(qtbot): # type: ignore[no-untyped-def]
from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas()
qtbot.addWidget(canvas)
canvas.show_project(load_project(EXAMPLES / "basic_truss.osmodel"))
qtbot.waitUntil(lambda: canvas._ready, timeout=30000)
qtbot.wait(1500) # let the first Plotly.react settle
return canvas
def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def]
box: dict[str, object] = {}
canvas._web.page().runJavaScript(script, lambda value: box.update(value=value))
qtbot.waitUntil(lambda: "value" in box, timeout=timeout)
return box["value"]
def _spy_drag(
canvas, # type: ignore[no-untyped-def]
qtbot,
*,
button: int,
buttons: int,
shift: bool = False,
ctrl: bool = False,
alt: bool = False,
) -> dict[str, Any]:
script = (
_SPY_DRAG.replace("__BUTTON__", str(button))
.replace("__BUTTONS__", str(buttons))
.replace("__SHIFT__", _js_bool(shift))
.replace("__CTRL__", _js_bool(ctrl))
.replace("__ALT__", _js_bool(alt))
)
raw = _run_js(canvas, qtbot, script)
assert isinstance(raw, str)
return json.loads(raw)
def _ours(calls: list[list[Any]], kind: str) -> list[list[Any]]:
return [c for c in calls if c[0] == kind and c[1] is False]
@pytest.mark.gui
def test_gesture_bindings_match_vtk(qtbot) -> None: # type: ignore[no-untyped-def]
"""One page for all gestures: each QWebEngineView costs a WebGL context."""
canvas = _open_canvas(qtbot)
# Shift+left pans (not plotly's rotate).
result = _spy_drag(canvas, qtbot, button=0, buttons=1, shift=True)
pans = _ours(result["calls"], "pan")
assert pans and pans[0][3] != 0, result
assert not _ours(result["calls"], "rotate"), result
assert result["kbm"] == "rotate", "binding must be restored after the drag"
# Middle drag pans, not zooms.
result = _spy_drag(canvas, qtbot, button=1, buttons=4)
pans = _ours(result["calls"], "pan")
assert pans and pans[0][3] != 0 and pans[0][4] == 0, result
assert result["kbm"] == "rotate", result
# Right drag dollies (z pan), not plotly's pan.
result = _spy_drag(canvas, qtbot, button=2, buttons=2)
zooms = [c for c in _ours(result["calls"], "pan") if c[2] == 0 and c[3] == 0]
assert zooms and zooms[0][4] != 0, result
assert result["kbm"] == "rotate", result
# Ctrl+left spins (pure roll).
result = _spy_drag(canvas, qtbot, button=0, buttons=1, ctrl=True)
rolls = [c for c in _ours(result["calls"], "rotate") if c[2] == 0 and c[3] == 0]
assert rolls and rolls[0][4] != 0, result
assert result["kbm"] == "rotate", result
# Alt is not a VTK modifier: it falls back to a plain rotate.
result = _spy_drag(canvas, qtbot, button=0, buttons=1, alt=True)
rots = [c for c in _ours(result["calls"], "rotate") if c[4] == 0]
assert rots and (rots[0][2] != 0 or rots[0][3] != 0), result
assert result["kbm"] == "rotate", result
# Plain left drag stays on plotly's native path (keyBindingMode 'rotate').
result = _spy_drag(canvas, qtbot, button=0, buttons=1)
assert not _ours(result["calls"], "pan"), result
assert not _ours(result["calls"], "rotate"), result
assert any(c[1] == "rotate" for c in result["calls"]), result
assert result["kbm"] == "rotate", result
# In-page animation entry points are exposed to Python.
assert (
_run_js(
canvas,
qtbot,
"typeof window.otkoAnimate === 'function' && "
"typeof window.otkoStopAnimation === 'function'",
)
is True
)

View file

@ -1,10 +1,10 @@
"""Regression tests for the Plotly hover/snap-marker contract. """Regression tests for the Plotly snap-hover marker.
``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves. ``Plotly.restyle`` on a gl3d plot costs ~90 ms even for a single trace (measured
Touching the plot on each one made it redraw per mouse move, re-firing hover in-page), so the marker used to lag far behind the cursor and stall orbiting.
until the stack blew (``RangeError: Maximum call stack size exceeded``) and the It is now a pointer-events-none DOM overlay positioned by projecting the snapped
canvas froze. The marker is therefore coalesced and only touched when its world point through gl-plot3d's own camera matrices, which costs microseconds
shown/hidden state actually changes. and issues no plotly calls at all.
""" """
from __future__ import annotations from __future__ import annotations
@ -35,10 +35,12 @@ _CHURN_PROBE = """(function(){
} finally { Plotly.restyle = original; } } finally { Plotly.restyle = original; }
})()""" })()"""
_HOVER_MARKER_X = ( _MARKER_STATE = """(function(){
"JSON.stringify((document.getElementById('plot').data" var marker = document.getElementById('otko-snap-marker');
".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)" if (!marker) return 'missing';
) return JSON.stringify({ display: marker.style.display, left: marker.style.left,
top: marker.style.top });
})()"""
_EMIT_SNAP_HOVER = """(function(){ _EMIT_SNAP_HOVER = """(function(){
var gd = document.getElementById('plot'); var gd = document.getElementById('plot');
@ -48,6 +50,8 @@ _EMIT_SNAP_HOVER = """(function(){
return true; return true;
})()""" })()"""
_EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true"
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def] def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
from otko.views.canvas_plotly import PlotlyCanvas from otko.views.canvas_plotly import PlotlyCanvas
@ -69,7 +73,7 @@ def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[n
@pytest.mark.gui @pytest.mark.gui
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def] def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
"""The unfixed version restyled once per hover/unhover event.""" """The original handler restyled on every hover/unhover event."""
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel") canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
restyles = _run_js(canvas, qtbot, _CHURN_PROBE) restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
@ -78,16 +82,45 @@ def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore
@pytest.mark.gui @pytest.mark.gui
def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def] def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def]
canvas = _open_canvas(qtbot, "basic_truss.osmodel") canvas = _open_canvas(qtbot, "basic_truss.osmodel")
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]" assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
# Nothing shows while snapping is off (the marker belongs to the draw tools).
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
qtbot.wait(150)
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
canvas.set_snap_preview_enabled(True) canvas.set_snap_preview_enabled(True)
qtbot.wait(100) qtbot.wait(150)
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER) _run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
qtbot.wait(250) qtbot.wait(200)
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown" shown = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
assert shown["display"] == "block", "snap target not shown"
assert float(shown["left"].rstrip("px")) > 0
assert float(shown["top"].rstrip("px")) > 0
_run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})") # Orbiting moves the target on screen without any plotly redraw. A top
qtbot.wait(250) # view is used because a point near the scene centre projects to nearly
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared" # 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"
assert (moved["left"], moved["top"]) != (shown["left"], shown["top"])
_run_js(canvas, qtbot, _EMIT_UNHOVER)
qtbot.wait(200)
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
def _json_state(raw: object) -> dict:
import json
assert isinstance(raw, str) and raw != "missing", f"marker element missing: {raw!r}"
return json.loads(raw)

View file

@ -55,6 +55,14 @@ def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignor
assert "camera" in scene assert "camera" in scene
assert "range" in scene["xaxis"] assert "range" in scene["xaxis"]
assert scene["xaxis"]["autorange"] is False 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 @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"), ("warning", "[web] plot.html:8 careful"),
("info", "[web] plotly:9 hello"), ("info", "[web] plotly:9 hello"),
] ]
@pytest.mark.gui
def test_deformed_push_colours_by_scalar_and_ghosts_the_reference(qtbot) -> None: # type: ignore[no-untyped-def]
"""The canvas wiring drives the builder's contour + ghost overlays."""
import numpy as np
from otko.services.deformation import DeformationSource
from otko.views.canvas3d.model_renderer import RendererMode
canvas, calls = _canvas_with_captured_js(qtbot)
project = load_project(EXAMPLES / "cantilever.osmodel")
canvas.set_project(project)
ids = [node.id for node in project.nodes]
source = DeformationSource(
displacements=np.zeros((len(ids), 3)) + np.linspace(0, 1, len(ids))[:, None],
node_id_to_row={nid: i for i, nid in enumerate(ids)},
)
canvas.set_mode(RendererMode.DEFORMED, source)
canvas.set_show_undeformed(True)
calls.clear()
canvas.render()
payload, _preserve = _parse_call(calls[-1])
names = {trace.get("name") for trace in payload["data"]}
assert "undeformed-reference" in names
nodes = next(t for t in payload["data"] if t.get("name") == "nodes")
assert nodes["marker"]["showscale"] is True
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
@pytest.mark.gui
def test_animation_push_carries_prebuilt_frames(qtbot) -> None: # type: ignore[no-untyped-def]
"""``animate`` must hand plotly a frame list, not drive Python re-renders."""
import numpy as np
from otko.services.deformation import DeformationSource
from otko.views.canvas_plotly.trace_builder import SceneOptions
canvas, calls = _canvas_with_captured_js(qtbot)
project = load_project(EXAMPLES / "cantilever.osmodel")
canvas.set_project(project)
canvas._scene = canvas._builder.build(project, SceneOptions())
ids = [node.id for node in project.nodes]
options = [
SceneOptions(
deformation=DeformationSource(
displacements=np.full((len(ids), 3), float(k)),
node_id_to_row={nid: i for i, nid in enumerate(ids)},
)
)
for k in (1.0, 2.0, 3.0)
]
canvas.animate(options, duration_ms=25, loops=4)
assert calls and calls[-1].startswith("window.otkoAnimate(")
body = calls[-1][len("window.otkoAnimate(") :]
literal, end = json.JSONDecoder().raw_decode(body)
payload = json.loads(literal)
assert len(payload["frames"]) == 3
assert payload["frames"][0]["data"], "frames must carry trace data"
assert body[end:].startswith(", 25, 4, true")

View file

@ -0,0 +1,61 @@
"""Smoke tests for the undeformed-reference overlay on both backends."""
from __future__ import annotations
import numpy as np
import pytest
pytest.importorskip("PySide6")
from otko.core import ElasticBeamColumn, ElasticSection, Node, Project
def _frame_project() -> Project:
b, h = 0.30, 0.50
return Project(
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, True, True, True, True)),
Node(id=2, coords=(6.0, 0.0, 0.0)),
],
sections=[
ElasticSection(
id=1,
name="Rect",
E=200e9,
A=b * h,
Iz=b * h**3 / 12.0,
Iy=h * b**3 / 12.0,
G=80e9,
J=1e-6,
)
],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
)
@pytest.mark.gui
def test_pyvista_undeformed_reference_overlay(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.services.deformation import DeformationSource
from otko.views.canvas3d.model_canvas import ModelCanvas
from otko.views.canvas3d.model_renderer import RendererMode
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_frame_project())
# In MODEL mode there is nothing to reference — the toggle must be a no-op.
canvas.set_show_undeformed(True)
assert canvas._renderer._undeformed_actor is None
# A deformed shape gets a ghost wireframe, removed again on toggle off.
src = DeformationSource(
displacements=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.5]]),
node_id_to_row={1: 0, 2: 1},
)
canvas._renderer.set_mode(RendererMode.DEFORMED, src)
canvas.render()
canvas.set_show_undeformed(True)
assert canvas._renderer._undeformed_actor is not None
canvas.set_show_undeformed(False)
assert canvas._renderer._undeformed_actor is None

View file

@ -13,6 +13,7 @@ from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_plotly.trace_builder import ( from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder, PlotlyTraceBuilder,
SceneOptions, SceneOptions,
framed_camera_distance,
) )
EXAMPLES = Path(__file__).resolve().parents[2] / "examples" EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
@ -26,10 +27,6 @@ def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def]
return [trace for trace in scene.data if trace.get("name") == name] return [trace for trace in scene.data if trace.get("name") == name]
def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def]
return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data]
def test_builds_grid_nodes_and_frames() -> None: def test_builds_grid_nodes_and_frames() -> None:
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
names = {trace.get("name") for trace in scene.data} names = {trace.get("name") for trace in scene.data}
@ -37,8 +34,35 @@ def test_builds_grid_nodes_and_frames() -> None:
assert "nodes" in names assert "nodes" in names
assert "elements" in names assert "elements" in names
assert scene.diagonal > 0 assert scene.diagonal > 0
# The hover-snap marker is always present so JS can restyle it. # The snap marker is a DOM overlay, not a trace (a gl3d restyle costs
assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover" # ~90 ms, which made a trace marker lag behind the cursor).
assert all(trace.get("meta", {}).get("kind") != "hover" for trace in scene.data)
def test_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: def test_nodes_carry_ids_as_customdata() -> None:
@ -185,17 +209,19 @@ def test_payload_is_json_serialisable() -> None:
payload = json.dumps(scene.to_payload()) payload = json.dumps(scene.to_payload())
assert '"data"' in payload and '"layout"' in payload assert '"data"' in payload and '"layout"' in payload
assert scene.layout["scene"]["aspectmode"] == "data" 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_only_the_hover_marker() -> None: def test_empty_project_yields_no_traces() -> None:
scene = PlotlyTraceBuilder().build(None, SceneOptions()) scene = PlotlyTraceBuilder().build(None, SceneOptions())
assert scene.data == [] assert scene.data == []
assert scene.hover_trace == -1
from otko.core import Project from otko.core import Project
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions()) empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
assert _kinds(empty) == ["hover"] assert empty.data == []
def test_frame_trace_meta_marks_elements_pickable() -> None: def test_frame_trace_meta_marks_elements_pickable() -> None:
@ -260,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 assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True
# The coloured axis lines stay visible either way (orientation cue). # The coloured axis lines stay visible either way (orientation cue).
assert off_axis["visible"] is True and on_axis["visible"] is True 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

307
vis_improvement.md Normal file
View file

@ -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: `"<pattern> · <N/E id><br>|F| = <mag>"` 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`).