feat/plotly-canvas #5
9 changed files with 348 additions and 2 deletions
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.
commit
bd365be77d
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
139
src/otko/views/dialogs/plot_properties.py
Normal file
139
src/otko/views/dialogs/plot_properties.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
131
tests/gui/test_plot_properties.py
Normal file
131
tests/gui/test_plot_properties.py
Normal 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()
|
||||
Loading…
Reference in a new issue