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

307 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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