feat/plotly-canvas #5

Merged
smill merged 15 commits from feat/plotly-canvas into main 2026-09-16 20:37:40 -04:00
9 changed files with 348 additions and 2 deletions
Showing only changes of commit bd365be77d - Show all commits

feat: Options → Plot Properties dialog with live preview and persistence

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.
smillmorel 2026-09-16 19:31:38 -04:00

View file

@ -20,6 +20,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless.
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).

View file

@ -342,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._renderer.set_show_local_axes(enabled)
self.render()
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the scene.
Applies the background immediately (it lives outside the renderer)
and rebuilds the actors whose colours come from the style LUTs.
"""
self._style = style
self.set_background(style.background_bottom, top=style.background_top)
self._renderer.set_style(style)
self.render()
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
"""Toggle viewport labels such as node and element names."""
self._renderer.set_display_options(

View file

@ -271,6 +271,12 @@ class PlotlyCanvas(QWidget):
self._options = replace(self._options, show_local_axes=bool(enabled))
self.render()
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the figure."""
self._style = style
self._builder.set_style(style)
self.render()
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
self._options = replace(
self._options,

View file

@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
from otko.views.dialogs.plain_pattern import PlainPatternDialog
from otko.views.dialogs.plot_properties import PlotPropertiesDialog
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
from otko.views.dialogs.quick_guide import QuickGuideDialog
from otko.views.dialogs.replicate import ReplicateDialog
@ -69,6 +70,7 @@ __all__ = [
"PathTimeSeriesDialog",
"PatternLoadsDialog",
"PlainPatternDialog",
"PlotPropertiesDialog",
"QuickGridLinesDialog",
"QuickGuideDialog",
"ReplicateDialog",

View file

@ -0,0 +1,139 @@
"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
so adding an editable property is a one-line change there plus a default on
the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
so the canvas can preview them live; ``style()`` returns the composed result.
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QColorDialog,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
class _ColorButton(QPushButton):
"""A swatch that opens :class:`QColorDialog` and reports the hex value."""
colorChanged = Signal(str)
def __init__(self, color: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._color = color
self.setFixedWidth(96)
self._refresh()
self.clicked.connect(self._pick)
def color(self) -> str:
"""Current colour as a ``#rrggbb`` string."""
return self._color
def set_color(self, color: str) -> None:
"""Set the swatch colour (does not emit :attr:`colorChanged`)."""
self._color = color
self._refresh()
def _refresh(self) -> None:
self.setText(self._color.upper())
self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
def _text_color(self) -> str:
color = QColor(self._color)
luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
return "#000000" if luminance > 140 else "#ffffff"
def _pick(self) -> None:
chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
if chosen.isValid():
self.set_color(chosen.name())
self.colorChanged.emit(self._color)
class PlotPropertiesDialog(QDialog):
"""Modeless-free editor for the persisted plot style."""
#: Emitted with the composed :class:`RenderStyle` on every edit.
changed = Signal(object)
def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Plot Properties")
self._base = style
self._colors: dict[str, _ColorButton] = {}
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
layout = QVBoxLayout(self)
form = QFormLayout()
for name, label, kind in STYLE_FIELDS:
widget = self._build_widget(name, getattr(style, name), kind)
form.addRow(QLabel(f"{label}:"), widget)
layout.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
assert reset is not None
reset.clicked.connect(self._reset)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# ── public ──────────────────────────────────────────────────────
def plot_style(self) -> RenderStyle:
"""The composed style for the current widget values.
Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
already exists and must keep its Qt meaning.
"""
updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
updates.update({name: w.value() for name, w in self._numbers.items()})
return self._base.with_updates(**updates)
# ── internals ───────────────────────────────────────────────────
def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
if kind == "color":
button = _ColorButton(str(value))
button.colorChanged.connect(self._emit_changed)
self._colors[name] = button
return button
if kind == "float":
spin = QDoubleSpinBox()
spin.setRange(0.0, 1.0)
spin.setSingleStep(0.05)
spin.setDecimals(2)
spin.setValue(float(value)) # type: ignore[arg-type]
spin.valueChanged.connect(self._emit_changed)
self._numbers[name] = spin
return spin
int_spin = QSpinBox()
int_spin.setRange(6, 32)
int_spin.setValue(int(value)) # type: ignore[call-overload]
int_spin.valueChanged.connect(self._emit_changed)
self._numbers[name] = int_spin
return int_spin
def _emit_changed(self, *_args: object) -> None:
self.changed.emit(self.plot_style())
def _reset(self) -> None:
defaults = RenderStyle()
for name, button in self._colors.items():
button.set_color(getattr(defaults, name))
for name, spin in self._numbers.items():
spin.setValue(getattr(defaults, name))
self._emit_changed()

View file

@ -13,6 +13,7 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
@ -25,6 +26,7 @@ from otko.views.action_handlers import ActionHandlers
from otko.views.canvas3d import ModelCanvas
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_base import plotly_available
from otko.views.dock_manager import DockManager
from otko.views.menu_builder import MenuBuilder
@ -72,6 +74,8 @@ class MainWindow(
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
self._canvas_backend = self._stored_canvas_backend()
#: Shared, user-editable visual style (both backends read it).
self._style = self._load_plot_style()
self._canvases: dict[str, Any] = {}
self._diagram_renderers: dict[str, DiagramRenderer] = {}
# A stack holds every backend's canvas: swapping is just
@ -115,9 +119,9 @@ class MainWindow(
# Imported lazily so a plotly-less install still boots PyVista.
from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas(self, selection=self._selection)
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection)
else:
canvas = ModelCanvas(self, selection=self._selection)
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
self._canvases[backend] = canvas
return canvas
@ -145,6 +149,35 @@ class MainWindow(
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
return self._canvas_backend
# ── plot style ───────────────────────────────────────────────────
def _load_plot_style(self) -> RenderStyle:
"""Style from the last session, ignoring anything unrecognised."""
raw = self._settings.value("plot/props")
if not raw:
return RenderStyle()
try:
data = json.loads(str(raw))
except ValueError:
return RenderStyle()
if not isinstance(data, dict):
return RenderStyle()
allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
try:
return RenderStyle(**allowed)
except (TypeError, ValueError):
return RenderStyle()
def _save_plot_style(self) -> None:
self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
"""Apply a style to every canvas (live preview when ``persist`` is off)."""
self._style = style
for canvas in self._canvases.values():
canvas.set_style(style)
if persist:
self._save_plot_style()
def _build_tooling(self) -> None:
"""(Re)build the tool controller and tools against the current canvas."""
self._tool_controller = ToolController(self._canvas, self._vm, self)
@ -261,6 +294,7 @@ class MainWindow(
self._act_about.triggered.connect(self._on_about)
self._act_quick_guide.triggered.connect(self._on_quick_guide)
self._act_set_units.triggered.connect(self._on_set_units)
self._act_plot_properties.triggered.connect(self._on_plot_properties)
# Edit
self._act_delete.triggered.connect(self._on_delete)

View file

@ -192,6 +192,10 @@ class MenuBuilder:
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
self._act_quick_guide = QAction("&Quick Guide", self)
self._act_set_units = QAction("Set Display &Units…", self)
self._act_plot_properties = QAction("&Plot Properties…", self)
self._act_plot_properties.setToolTip(
"Colours and sizes used by both canvas backends (persisted)."
)
# Canvas backend — exclusive radio pair live-switching the 3D view.
from otko.views.canvas_base import plotly_available
@ -512,6 +516,7 @@ class MenuBuilder:
m_options = mb.addMenu("&Options")
m_options.addAction(self._act_set_units)
m_options.addAction(self._act_plot_properties)
m_backend = m_options.addMenu("Canvas &Backend")
m_backend.addAction(self._act_canvas_pyvista)
m_backend.addAction(self._act_canvas_plotly)

View file

@ -50,6 +50,23 @@ class RenderControls:
)
# ── SAP2000-style view switching + working level ───────────────
def _on_plot_properties(self) -> None:
"""Edit the shared visual style.
Edits preview live on both canvases; Cancel restores the style the
dialog opened with, and only OK persists to ``QSettings``.
"""
from otko.views.dialogs import PlotPropertiesDialog
original = self._style
dialog = PlotPropertiesDialog(original, self)
dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False))
if dialog.exec() == QDialog.DialogCode.Accepted:
self.set_plot_style(dialog.plot_style())
self._log("Plot properties updated.")
else:
self.set_plot_style(original, persist=False)
def _on_toggle_parallel(self, on: bool) -> None:
cam = self._canvas.camera
cam.parallel_projection = on

View file

@ -0,0 +1,131 @@
"""GUI tests for Options → Plot Properties.
Covers the dialog itself plus MainWindow's live-apply / persist / revert
wiring. QSettings are injected so nothing touches real user state.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6")
pytest.importorskip("pyvistaqt")
from PySide6.QtCore import QObject, QSettings, Signal
from PySide6.QtWidgets import QDialog
def _settings(tmp_path): # type: ignore[no-untyped-def]
return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
def _window(tmp_path, qtbot): # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
window = MainWindow(settings=_settings(tmp_path))
qtbot.addWidget(window)
return window
def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.style import RenderStyle
window = _window(tmp_path, qtbot)
assert window._style == RenderStyle()
def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
window.swap_canvas_backend("plotly") # build the second canvas too
window.set_plot_style(
window._style.with_updates(element_beam_color="#ff0000", label_font_size=20)
)
assert set(window._canvases) == {"pyvista", "plotly"}
for canvas in window._canvases.values():
assert canvas._style.element_beam_color == "#ff0000"
assert canvas._style.label_font_size == 20
# A fresh window sharing the settings file reloads the customisation.
from otko.views.main_window import MainWindow
fresh = MainWindow(settings=_settings(tmp_path))
qtbot.addWidget(fresh)
assert fresh._style.element_beam_color == "#ff0000"
assert fresh._style.label_font_size == 20
def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False)
assert window._canvas._style.node_color == "#010203"
assert window._settings.value("plot/props") is None
def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
original = window._style
preview = original.with_updates(node_color="#010203")
class _RejectingDialog(QObject):
changed = Signal(object)
def __init__(self, style: object, parent: QObject | None = None) -> None:
super().__init__(parent)
self._style = preview
def exec(self) -> QDialog.DialogCode:
self.changed.emit(preview) # live preview happens...
return QDialog.DialogCode.Rejected # ...but the user cancels
def plot_style(self): # type: ignore[no-untyped-def]
return self._style
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog)
window._on_plot_properties()
assert window._style == original
assert window._canvas._style.node_color == original.node_color
assert window._settings.value("plot/props") is None
def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
preview = window._style.with_updates(node_color="#010203")
class _AcceptingDialog(QObject):
changed = Signal(object)
def __init__(self, style: object, parent: QObject | None = None) -> None:
super().__init__(parent)
self._style = preview
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def plot_style(self): # type: ignore[no-untyped-def]
return self._style
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog)
window._on_plot_properties()
assert window._style.node_color == "#010203"
assert window._canvas._style.node_color == "#010203"
assert "#010203" in str(window._settings.value("plot/props"))
def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.style import RenderStyle
from otko.views.dialogs import PlotPropertiesDialog
dialog = PlotPropertiesDialog(RenderStyle())
qtbot.addWidget(dialog)
assert dialog.plot_style() == RenderStyle()
dialog._colors["node_color"].set_color("#abcdef")
assert dialog.plot_style().node_color == "#abcdef"
dialog._reset()
assert dialog.plot_style() == RenderStyle()