feat/plotly-canvas #5

Merged
smill merged 15 commits from feat/plotly-canvas into main 2026-09-16 20:37:40 -04:00
Owner

Summary

Linked issue

Closes #

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

Architectural checklist

  • No from PySide6 in core/ or services/
  • No import openseespy in core/ or views/
  • Public functions have type hints + docstrings
  • Heavy work runs off the GUI thread

Testing

## Summary <!-- One sentence: what does this change? --> ## Linked issue Closes # ## Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / cleanup - [ ] Documentation - [ ] CI / tooling ## Architectural checklist - [ ] No `from PySide6` in `core/` or `services/` - [ ] No `import openseespy` in `core/` or `views/` - [ ] Public functions have type hints + docstrings - [ ] Heavy work runs off the GUI thread ## Testing <!-- How was this verified? Reference tests added or analytical checks. -->
Supplies the offline plotly.js bundle for the new canvas backend. The
GUI CI job now installs the gui extra so the Qt/WebEngine tests run
instead of being import-skipped.
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
PlotlyCanvas hosts plotly.js in a QWebEngineView driven over
QWebChannel: figures update with Plotly.react (the camera survives
unless a view preset asks for it), and clicks round-trip as
node/element picks or grid-snap clicks. Assets are written to a temp
dir and loaded from file:// because the ~5 MB bundle is past
setHtml's data-URL limit. render() forwards QWidget's overload so
grab() and painting keep working.
Both backends share one SelectionState owned by MainWindow and live
side by side in a QStackedWidget — switching is setCurrentWidget, so
no widget is destroyed mid-session (tearing a VTK window down leaves
dangling make-current callbacks). The choice persists in QSettings.
CanvasCapabilities declares per-backend gaps (force diagrams and
video export stay PyVista-only, both are documented and greyed out)
and the UI gates on capabilities rather than the backend name. The
architecture gate now allows the canvas_plotly package.
opstool v1.0.26 (GPL-3.0) is cloned read-only under
.slim/clonedeps/repos/ to inspect its PyVista/Plotly visualization
settings. GPLv3 section 13 permits combining it with this AGPL-3.0
project. The clone itself is git-ignored; the manifest and the AGENTS.md
pointer are committed.
MainWindow persists preferences (window layout, canvas backend) through
QSettings("OTKO", "OTKO"), so tests constructing it were reading the
developer's real settings: after switching to the Plotly backend the
PyVista-specific viewport-axis tests failed with "PlotlyCanvas has no
attribute renderer". A session fixture now redirects QSettings into a
temp dir and is skipped when Qt is not installed, keeping the headless
job Qt-free.
Ports opstool's per-family element colours and its diverging response
scale; RenderStyle.response_scale_colors now also drives the PyVista
force-diagram colouring instead of a hard-coded "coolwarm".

The frame renderer drops the two-trace normal/selected workaround: my
earlier assumption that plotly cannot colour segments individually was
wrong. Scatter3d.line.color accepts an array mapped through a colorscale,
so one trace now carries per-element colours (family + selection) and is
ready to be coloured by response value later.

Attribution recorded in NOTICE per GPLv3 section 5(a)/(b).
Colours that were hard-coded in the renderers now come from the shared
style: nodes, supports, nodal/element loads, section extrusions (+
opacity) and label font size. The PyVista frame LUT becomes a four-slot
palette [beam, truss, link, selected] with the cell scalar carrying the
family slot, so VTK matches the Plotly backend's per-family colouring
that landed earlier. Adds style helpers shared by both backends
(element_family_index, family_palette) and an immutable with_updates().

The style also gains the editable field table (STYLE_FIELDS) the Plot
Properties dialog is built from.
Adds a table-driven dialog over RenderStyle.STYLE_FIELDS (colour swatches,
an opacity spin and a label font size), reachable from Options. Edits
preview immediately on every canvas, Cancel restores the style the dialog
opened with, and only OK persists — as JSON under QSettings plot/props,
reloaded on the next launch. Both canvases gain set_style(); the dialog
deliberately exposes plot_style() rather than style() so QWidget.style()
keeps its Qt meaning.
Frames the view deterministically: Scene now carries padded per-axis bounds
(opstool's pad_ratio 0.15, with unit slack on degenerate axes) plus
axis_overrides() pinning range + autorange=False, so the model is never
flush against the viewport edge. Adds entity hover templates (Node #n /
Element #n with an empty extra tag), and an optional axis outline (grid +
ticks) - off by default so the SAP2000-like clean viewport is unchanged;
the coloured X/Y/Z axis lines always stay as the orientation cue.

The outline is exposed as a bool in the Plot Properties table, so the
table-driven dialog now builds a checkbox for kind=bool.
Plotly.react resets any scene attribute the incoming layout omits, so every
selection change snapped the camera back to the default and re-fit the
ranges - the pinned framing was meaningless. Data-only pushes now pass
preserveView and html.py merges the live camera and axis ranges into the
incoming layout; only an explicit re-frame sends computed framing.

Also fixes the ordering bug where the framing flag was consumed before the
not-ready early return, which dropped the very first framing on the floor
(show_project runs before loadFinished).

Verified in the browser: user orbit + zoom survive selection and working
plane updates, while reset_camera re-frames. Regression-tested headlessly
by asserting the emitted preserveView flag.
Three defects in the view-preservation path could leave the plot frozen
(no updates, no orbit, stale colours):

1. The merge injected raw _fullLayout objects - including undefined when a
   push landed before the previous react resolved - and plotly validates
   layouts, so one bad value made every later react fail permanently.
   currentView() now deep-copies and validates eye/center/up and each
   range, and mergeView() is wrapped so it can never block an update.
2. Updates were not serialized: overlapping Plotly.react calls on one graph
   div left it unresponsive. otkoUpdate now queues and coalesces (one react
   at a time, latest payload wins), and logs instead of failing silently.
3. A style change re-framed the camera, so re-colouring yanked a view the
   user had orbited. Colour/opacity ride the traces and background/outline
   ride the layout, so set_style is now a non-framing push.

Verified in the browser: a user orbit survives colour changes, 30 rapid
preview updates land on the final value and stay responsive, and deleting
the live camera no longer wedges the plot.
A plotly failure inside the WebEngine page was invisible from Python - the
react promise just rejected and the canvas looked frozen with no evidence.
PlotlyCanvas now installs a QWebEnginePage that forwards
javaScriptConsoleMessage into the Console dock with the right severity
(error/warning/info), so the next silent failure is diagnosable.
plotly_hover/plotly_unhover fire continuously while the mouse moves, and
the unhover handler restyled the snap marker unconditionally - even with
snapping off and nothing visible. That meant a full Plotly.restyle plus
redraw per mouse event, which re-fired hover until the page died with
'RangeError: Maximum call stack size exceeded' and the canvas froze
(reported live after opening a model).

The marker is now updated only when its visible state actually changes: no
marker when snapping is off, and a restyle only when the snapped point
differs from the one already shown. Deliberately synchronous - timers are
throttled to about a second by WebEngine when the page is not compositing,
which stalled the preview. Measured: 200 mouse-move events went from 200
restyles to 0; 200 hovers on one grid dot cost a single restyle.
docs: repair AGENTS.md NUL corruption and record the canvas contracts
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / type (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.12) (pull_request) Has been cancelled
CI / test-gui (pull_request) Has been cancelled
CI / test-integration (macos-latest) (pull_request) Has been cancelled
CI / test-integration (ubuntu-latest) (pull_request) Has been cancelled
CI / test-integration (windows-latest) (pull_request) Has been cancelled
429ebc0790
The previous AGENTS.md edit wrote 478 NUL bytes instead of the intended
sentence, so git and grep treated the file as binary. Restored the clean
UTF-8 text and documented both Plotly canvas invariants: data-only pushes
must pass preserveView (never re-send the camera), and the hover/snap
marker must only be restyled when its visible state changes.
smill merged commit 7839f05384 into main 2026-09-16 20:37:40 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
smill/otko!5
No description provided.