otko/vis_improvement.md
smillmorel 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

15 KiB
Raw Blame History

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

# 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).