diff --git a/AGENTS.md b/AGENTS.md index 6b63da2..bbe6d81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index 2b69352..3e00425 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -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( diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index e5af025..1e771d5 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -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, diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py index d2a3b45..e4dff02 100644 --- a/src/otko/views/dialogs/__init__.py +++ b/src/otko/views/dialogs/__init__.py @@ -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", diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py new file mode 100644 index 0000000..5240546 --- /dev/null +++ b/src/otko/views/dialogs/plot_properties.py @@ -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() diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index cbd21e3..cca1d7e 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -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) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index 0aa0b2e..5e7a271 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -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) diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 63d1f0f..06db479 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -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 diff --git a/tests/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py new file mode 100644 index 0000000..3ef3601 --- /dev/null +++ b/tests/gui/test_plot_properties.py @@ -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()