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.
15 KiB
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:
- Camera framing —
layout.scene.camerais 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). - 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.markerandscatter3d.lineacceptcoloraxis,colorscale,cmin,cmax,showscale,colorbar, and arraycolor.layout.coloraxisexists (plotly.py 7.1.0; it is a valid layout key even thoughLayout()._valid_propsdoesn't list it directly). mesh3dsupportsintensity/colorscalebut notcoloraxis.- opstool's contour colouring uses a shared
coloraxis+cmin/cmaxfrom the response peak, with acolorbarcarrying the component/unit title. - opstool scales load arrows by
|F| * (min+max bound)/20 / max|F|and tints each load pattern (matplotlibwinter/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
SceneOptionsgainedscalars,scalar_label,scalar_clim.build()auto-derives scalars when a deformation source exposesmagnitudes(node_ids)(seeDeformationSource.magnitudesinsrc/otko/services/deformation.py), label"|u|"._build_nodescoloursmarker.colorby scalar array withcolorscale=self._style.response_colorscale(),cmin/cmax,showscale=True, and acolorbartitledscalar_label; hover shows the value._build_framescoloursline.colorper 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 greypv.PolyDatawireframe from_node_original_points+ frame connectivity;model_canvas.pyset_show_undeformed. (This made the previously duplicatedDeformationSourceclass inmodel_renderer.pyredundant — it now imports the one fromotko.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]wherescale = 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>"viacustomdata. - Cone sizing uses
sizemode: "scaled"with a small unitlesssizeref(~0.12)."absolute"interpretssizerefagainst the vector norm in normalized scene units, which rendered oversized "fins" that hid the model on large models (see thesnip4.pngreport)._cone_tracedocstring 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
scatter3dlines 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)andwindow.otkoStopAnimation(). Builds a name list, repeats itloopstimes,Plotly.react→addFrames→animate(withmergeViewto 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: newplayToggled(bool)signal,period_seconds(),set_in_page_mode(bool);_on_tickupdates the scrubber but does not emit frames while_in_page.dock_manager.py:_animate_mode_in_page()builds 36 phase frames frommodal_to_deformationand callscanvas.animate(...)when the capability is present; play/pause map to animate/stop.render_controls._on_show_undeformedcallsstop_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.pytest_loads_scale_with_magnitude_and_colour_by_patterntest_supports_use_dof_glyphs_not_markerstest_scalar_colouring_adds_arrays_and_colorbartest_deformation_auto_colours_by_magnitudetest_undeformed_reference_overlay_is_opt_in- (
_FakeDeformationstand-in keeps the unit test free of pyvista/Qt)
tests/gui/test_plotly_view_preservation.pytest_animation_push_carries_prebuilt_frames— parses the emittedwindow.otkoAnimate(...)payload withjson.JSONDecoder().raw_decode.
tests/gui/test_plotly_gestures.py— assertswindow.otkoAnimate/window.otkoStopAnimationexist 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 withshow_undeformedcarries theundeformed-referencetrace and a node colourbar titled|u|.
6. What remains / known risks (DO THIS NEXT)
Run the full gate.Done — all green (see §5). Note:pytestsegfaults 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.- Manually eyeball in-page animation on a real display (only remaining
item). Offscreen QtWebEngine throttles
requestAnimationFrame, soPlotly.animate's promise may not resolve in tests. A dispatch probe confirmedaddFrames(n)andanimate(sequence)are called with the right sizes, but end-to-end playback (loop, pause, camera preservation) has not been seen on-screen. Launchpython -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. - Animation stop path — verified from source.
Plotly.animate(gd, [], {mode:'immediate'})callsdiscardExistingFrames()(plotly.jsplot_api.jsanimate), sootkoStopAnimationdoes interrupt. Interrupt rejects the previous animate promise with no reason; the JS error handler only logs truthy errors to avoid console noise. - 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. - Docs — done for the new features (
docs/QUICK_GUIDE.md§2/§3,AGENTS.md,NOTICE). - Optional polish (opstool parity not yet done)
- scene title with model stats +
font.family(opstoolupdate_fig); - 2D auto-scene for planar models — evaluated and deliberately skipped.
Turntable's
updateFxforcescamera.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).
- scene title with model stats +
7. Architecture / constraints to respect
- Layering:
views → viewmodels → services → core.core= stdlib+numpy+ pydantic;servicesmay use h5py/openseespy;views= Qt/PyVista/plotly.trace_builder.pyis pure (no Qt, no pyvista, no plotly import) and is unit-tested headless — keep it that way. - Both canvas backends implement the
CanvasBackendprotocol (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_loopsand the colour/load recipes). - Never commit
.osmodelby hand (not relevant here). - Do not commit changes unless the user asks. Conventional Commits style.
8. Useful commands / environment
# 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:
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).