feat: live canvas backend switching (Options → Canvas Backend)
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.
This commit is contained in:
parent
804bf22f61
commit
9a1baa07c2
9 changed files with 442 additions and 38 deletions
|
|
@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
|
|||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools
|
||||
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
|
||||
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
|
||||
python -m otko # launch GUI (src/otko/app.py:run)
|
||||
```
|
||||
|
|
@ -19,6 +19,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
|
|||
- `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.**
|
||||
- `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.
|
||||
- `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).
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from otko.core import Project
|
|||
from otko.views.canvas3d.model_renderer import ModelRenderer
|
||||
from otko.views.canvas3d.selection import SelectionState
|
||||
from otko.views.canvas3d.style import RenderStyle
|
||||
from otko.views.canvas_base import CanvasCapabilities
|
||||
|
||||
PICK_DEBUG = False
|
||||
|
||||
|
|
@ -31,6 +32,9 @@ PICK_DEBUG = False
|
|||
class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||
"""The central 3D viewport widget."""
|
||||
|
||||
#: VTK supports every optional overlay layer.
|
||||
capabilities = CanvasCapabilities()
|
||||
|
||||
# Convenience signals re-emitted from SelectionState.
|
||||
nodePicked = Signal(int)
|
||||
elementPicked = Signal(int)
|
||||
|
|
|
|||
92
src/otko/views/canvas_base.py
Normal file
92
src/otko/views/canvas_base.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Backend-agnostic contract for the central 3D canvas.
|
||||
|
||||
Two backends implement it:
|
||||
|
||||
- :class:`otko.views.canvas3d.ModelCanvas` — PyVista/VTK (native OpenGL, default)
|
||||
- :class:`otko.views.canvas_plotly.PlotlyCanvas` — plotly.js in a QWebEngineView
|
||||
|
||||
The ``MainWindow`` swaps between them at runtime, so both must expose the same
|
||||
public surface (signals, selection, working plane, view presets, display
|
||||
toggles) and declare what optional overlay layers they support via
|
||||
:class:`CanvasCapabilities`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
def plotly_available() -> bool:
|
||||
"""True when the optional ``plotly`` package can be imported.
|
||||
|
||||
The Plotly backend needs plotly.js (shipped inside the ``plotly`` wheel)
|
||||
to render; without it the backend menu entry is disabled.
|
||||
"""
|
||||
return importlib.util.find_spec("plotly") is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanvasCapabilities:
|
||||
"""Optional overlay layers a backend may or may not implement.
|
||||
|
||||
Consumers query these (see ``RenderControls._refresh_action_enablement``)
|
||||
instead of assuming a feature works on every backend.
|
||||
"""
|
||||
|
||||
#: Force-diagram ribbon overlay (N/V/M diagrams).
|
||||
diagrams: bool = True
|
||||
#: SAP2000-style section extrusion overlay.
|
||||
extrusions: bool = True
|
||||
#: Per-element local-axis triad overlay.
|
||||
local_axes: bool = True
|
||||
#: Node / element text labels.
|
||||
labels: bool = True
|
||||
#: Off-screen frame capture (mode-shape / time-history video export).
|
||||
animation_export: bool = True
|
||||
|
||||
|
||||
class CanvasBackend(Protocol):
|
||||
"""Structural contract implemented by every canvas backend.
|
||||
|
||||
This mirrors :class:`ModelCanvas`; it exists so the swap path and the
|
||||
consumers have one written-down interface to code against.
|
||||
"""
|
||||
|
||||
capabilities: CanvasCapabilities
|
||||
selection: Any
|
||||
|
||||
def show_project(self, project: Any) -> None: ...
|
||||
|
||||
def clear_model(self) -> None: ...
|
||||
|
||||
def render(self) -> None: ...
|
||||
|
||||
def reset_camera(self) -> None: ...
|
||||
|
||||
def view_isometric(self) -> None: ...
|
||||
|
||||
def view_xy(self) -> None: ...
|
||||
|
||||
def view_xz(self) -> None: ...
|
||||
|
||||
def view_yz(self) -> None: ...
|
||||
|
||||
def set_working_plane(self, plane: str, offset: float) -> None: ...
|
||||
|
||||
def clear_working_plane(self) -> None: ...
|
||||
|
||||
def working_plane_type(self) -> str | None: ...
|
||||
|
||||
def working_plane_offset(self) -> float | None: ...
|
||||
|
||||
def set_snap_preview_enabled(self, enabled: bool) -> None: ...
|
||||
|
||||
def set_show_section_extrusions(self, enabled: bool) -> None: ...
|
||||
|
||||
def set_show_local_axes(self, enabled: bool) -> None: ...
|
||||
|
||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ...
|
||||
|
||||
def set_default_selection_enabled(self, enabled: bool) -> None: ...
|
||||
|
|
@ -210,6 +210,14 @@ class DockManager:
|
|||
because the off-screen renderer must be touched from the GUI
|
||||
thread (VTK's Qt-backed render window isn't thread-safe).
|
||||
"""
|
||||
if not self._canvas.capabilities.animation_export:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Export Mode Shape Animation",
|
||||
"Animation export needs the PyVista backend "
|
||||
"(Options → Canvas Backend → PyVista (Native)).",
|
||||
)
|
||||
return
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
path, _sel = QFileDialog.getSaveFileName(
|
||||
|
|
@ -260,6 +268,14 @@ class DockManager:
|
|||
"Run a Static analysis first; force diagrams visualise its element-force output.",
|
||||
)
|
||||
return
|
||||
if not self._canvas.capabilities.diagrams:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Force Diagram",
|
||||
"Force diagrams are not available on the Plotly backend yet.\n\n"
|
||||
"Switch back via Options → Canvas Backend → PyVista (Native).",
|
||||
)
|
||||
return
|
||||
self._tear_down_post_dock()
|
||||
|
||||
# Pick the component with the largest abs_max as the initial choice
|
||||
|
|
@ -364,6 +380,14 @@ class DockManager:
|
|||
"Run a Transient (time-history) analysis first.",
|
||||
)
|
||||
return
|
||||
if not self._canvas.capabilities.animation_export:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Export Time-History Animation",
|
||||
"Animation export needs the PyVista backend "
|
||||
"(Options → Canvas Backend → PyVista (Native)).",
|
||||
)
|
||||
return
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QFileDialog, QInputDialog
|
||||
|
|
|
|||
|
|
@ -14,15 +14,18 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
from PySide6.QtGui import QCloseEvent, QIcon
|
||||
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||
from PySide6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
|
||||
|
||||
from otko.viewmodels import AnalysisRunner, ProjectViewModel
|
||||
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.canvas_base import plotly_available
|
||||
from otko.views.dock_manager import DockManager
|
||||
from otko.views.menu_builder import MenuBuilder
|
||||
from otko.views.render_controls import RenderControls
|
||||
|
|
@ -34,6 +37,9 @@ from otko.views.tools import (
|
|||
ToolController,
|
||||
)
|
||||
|
||||
#: Canvas backends, in menu order.
|
||||
CANVAS_BACKENDS = ("pyvista", "plotly")
|
||||
|
||||
|
||||
class MainWindow(
|
||||
QMainWindow,
|
||||
|
|
@ -44,7 +50,7 @@ class MainWindow(
|
|||
):
|
||||
"""Top-level application shell."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, settings: QSettings | None = None) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("OTKO")
|
||||
self.setWindowIcon(
|
||||
|
|
@ -60,12 +66,20 @@ class MainWindow(
|
|||
self._show_node_labels = False
|
||||
self._show_element_labels = False
|
||||
|
||||
self._build_central_canvas()
|
||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
||||
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
||||
self._draw_node_tool: DrawNodeTool | None = None
|
||||
self._draw_truss_tool: DrawTrussTool | None = None
|
||||
# One selection state shared by every canvas backend so a live swap
|
||||
# keeps the current selection (canvas, tree, table, properties dock).
|
||||
self._selection = SelectionState(self)
|
||||
|
||||
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
||||
self._canvas_backend = self._stored_canvas_backend()
|
||||
self._canvases: dict[str, Any] = {}
|
||||
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
||||
# A stack holds every backend's canvas: swapping is just
|
||||
# setCurrentWidget, so no backend widget is ever destroyed mid-session.
|
||||
self._canvas_stack = QStackedWidget(self)
|
||||
self.setCentralWidget(self._canvas_stack)
|
||||
self._activate_canvas(self._canvas_backend)
|
||||
self._build_tooling()
|
||||
|
||||
self._build_docks()
|
||||
self._build_actions()
|
||||
|
|
@ -77,19 +91,120 @@ class MainWindow(
|
|||
self._refresh_action_enablement()
|
||||
self.restore_layout()
|
||||
|
||||
# ── construction ─────────────────────────────────────────────────
|
||||
def _build_central_canvas(self) -> None:
|
||||
self._canvas = ModelCanvas(self)
|
||||
self.setCentralWidget(self._canvas)
|
||||
# ── canvas backend ───────────────────────────────────────────────
|
||||
def _stored_canvas_backend(self) -> str:
|
||||
"""The backend chosen last session, falling back to PyVista."""
|
||||
stored = self._settings.value("canvas/backend", "pyvista")
|
||||
name = str(stored) if stored is not None else "pyvista"
|
||||
if name == "plotly" and plotly_available():
|
||||
return "plotly"
|
||||
return "pyvista"
|
||||
|
||||
def _ensure_canvas(self, backend: str) -> Any:
|
||||
"""Create a backend's canvas on first use and cache it.
|
||||
|
||||
Both canvases are kept alive and swapped with ``setCentralWidget``
|
||||
rather than destroyed: tearing a VTK render window down mid-session
|
||||
leaves dangling make-current callbacks, and the widgets are cheap to
|
||||
retain once built.
|
||||
"""
|
||||
canvas = self._canvases.get(backend)
|
||||
if canvas is not None:
|
||||
return canvas
|
||||
if backend == "plotly":
|
||||
# Imported lazily so a plotly-less install still boots PyVista.
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
canvas = PlotlyCanvas(self, selection=self._selection)
|
||||
else:
|
||||
canvas = ModelCanvas(self, selection=self._selection)
|
||||
self._canvases[backend] = canvas
|
||||
return canvas
|
||||
|
||||
def _activate_canvas(self, backend: str) -> None:
|
||||
"""Make the backend's canvas the central widget and rewire overlays."""
|
||||
if backend not in CANVAS_BACKENDS:
|
||||
backend = "pyvista"
|
||||
canvas = self._ensure_canvas(backend)
|
||||
if self._canvas_stack.indexOf(canvas) < 0:
|
||||
self._canvas_stack.addWidget(canvas)
|
||||
self._canvas_stack.setCurrentWidget(canvas)
|
||||
self._canvas = canvas
|
||||
self._canvas_backend = backend
|
||||
if backend == "pyvista":
|
||||
renderer = self._diagram_renderers.get("pyvista")
|
||||
if renderer is None:
|
||||
# Diagram overlay paints onto the same plotter as the model.
|
||||
self._diagram_renderer = DiagramRenderer(self._canvas)
|
||||
renderer = DiagramRenderer(canvas)
|
||||
self._diagram_renderers["pyvista"] = renderer
|
||||
self._diagram_renderer = renderer
|
||||
else:
|
||||
self._diagram_renderer = None
|
||||
|
||||
def canvas_backend(self) -> str:
|
||||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||
return self._canvas_backend
|
||||
|
||||
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)
|
||||
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
||||
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
||||
self._draw_node_tool: DrawNodeTool | None = None
|
||||
self._draw_truss_tool: DrawTrussTool | None = None
|
||||
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
||||
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
|
||||
|
||||
def swap_canvas_backend(self, backend: str) -> None:
|
||||
"""Switch the live canvas backend, preserving model/selection state.
|
||||
|
||||
The camera pose is intentionally re-framed rather than transferred:
|
||||
the two engines have different camera models, and a wrong pose is
|
||||
worse than a clean isometric reset.
|
||||
"""
|
||||
if backend not in CANVAS_BACKENDS or backend == self._canvas_backend:
|
||||
return
|
||||
if backend == "plotly" and not plotly_available():
|
||||
return
|
||||
|
||||
project = self._vm.project
|
||||
plane = self._canvas.working_plane_type()
|
||||
offset = self._canvas.working_plane_offset()
|
||||
active_name = getattr(self._tool_controller.active, "name", None)
|
||||
|
||||
self._activate_canvas(backend)
|
||||
self._build_tooling()
|
||||
|
||||
# Project → re-frames the camera; working plane rebuilds the grid.
|
||||
self._canvas.show_project(project)
|
||||
if plane is not None and offset is not None:
|
||||
self._canvas.set_working_plane(plane, offset)
|
||||
# Re-apply the overlays whose state lives on the toolbar actions.
|
||||
self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked())
|
||||
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
|
||||
self._canvas.set_display_options(
|
||||
show_node_labels=self._show_node_labels,
|
||||
show_element_labels=self._show_element_labels,
|
||||
)
|
||||
# Keep the active tool (and its snap preview) alive across the swap.
|
||||
# Re-entering through the action handlers recreates the tool lazily
|
||||
# against the new canvas.
|
||||
if active_name == "Draw Node":
|
||||
self._on_draw_node_tool()
|
||||
elif active_name == "Draw Frame":
|
||||
self._on_draw_frame_tool()
|
||||
elif active_name == "Draw Truss":
|
||||
self._on_draw_truss_tool()
|
||||
|
||||
self._settings.setValue("canvas/backend", self._canvas_backend)
|
||||
self._sync_canvas_backend_actions()
|
||||
self._refresh_action_enablement()
|
||||
|
||||
# ── layout persistence ───────────────────────────────────────────
|
||||
def save_layout(self) -> None:
|
||||
"""Persist window geometry and dock/toolbar state for next launch."""
|
||||
settings = QSettings("OTKO", "OTKO")
|
||||
settings.setValue("geometry", self.saveGeometry())
|
||||
settings.setValue("windowState", self.saveState())
|
||||
self._settings.setValue("geometry", self.saveGeometry())
|
||||
self._settings.setValue("windowState", self.saveState())
|
||||
|
||||
def restore_layout(self) -> None:
|
||||
"""Restore a previously saved geometry/dock layout, if any.
|
||||
|
|
@ -97,11 +212,10 @@ class MainWindow(
|
|||
On first run no values are stored, so the defaults from ``__init__``
|
||||
(``resize`` + dock construction) stay in effect.
|
||||
"""
|
||||
settings = QSettings("OTKO", "OTKO")
|
||||
geometry = settings.value("geometry")
|
||||
geometry = self._settings.value("geometry")
|
||||
if geometry is not None:
|
||||
self.restoreGeometry(geometry)
|
||||
state = settings.value("windowState")
|
||||
state = self._settings.value("windowState")
|
||||
if state is not None:
|
||||
self.restoreState(state)
|
||||
|
||||
|
|
@ -161,9 +275,12 @@ class MainWindow(
|
|||
self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool)
|
||||
self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_tool)
|
||||
self._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool)
|
||||
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
||||
# Keep the Tools toolbar checked state in sync with the controller.
|
||||
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
|
||||
# toolChanged → toolbar/menu sync is wired in _build_tooling(), which
|
||||
# also runs on a canvas-backend swap (then a fresh controller exists).
|
||||
|
||||
# Canvas backend
|
||||
self._act_canvas_pyvista.triggered.connect(lambda: self.swap_canvas_backend("pyvista"))
|
||||
self._act_canvas_plotly.triggered.connect(lambda: self.swap_canvas_backend("plotly"))
|
||||
|
||||
# Define
|
||||
self._act_grid.triggered.connect(self._on_grid_system)
|
||||
|
|
|
|||
|
|
@ -193,8 +193,41 @@ class MenuBuilder:
|
|||
self._act_quick_guide = QAction("&Quick Guide", self)
|
||||
self._act_set_units = QAction("Set Display &Units…", self)
|
||||
|
||||
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
||||
from otko.views.canvas_base import plotly_available
|
||||
|
||||
self._canvas_backend_group = QActionGroup(self)
|
||||
self._canvas_backend_group.setExclusive(True)
|
||||
self._act_canvas_pyvista = QAction("&PyVista (Native)", self, checkable=True)
|
||||
self._act_canvas_pyvista.setToolTip(
|
||||
"Native VTK/OpenGL viewport — fastest for large models."
|
||||
)
|
||||
self._act_canvas_plotly = QAction("&Plotly (WebGL)", self, checkable=True)
|
||||
self._act_canvas_plotly.setToolTip(
|
||||
"plotly.js rendered in a WebEngine view — richer graphics, higher overhead."
|
||||
)
|
||||
self._canvas_backend_group.addAction(self._act_canvas_pyvista)
|
||||
self._canvas_backend_group.addAction(self._act_canvas_plotly)
|
||||
if not plotly_available():
|
||||
self._act_canvas_plotly.setEnabled(False)
|
||||
self._act_canvas_plotly.setToolTip(
|
||||
'Install the optional GUI extra (pip install -e ".[gui]") to enable plotly.'
|
||||
)
|
||||
|
||||
self._apply_toolbar_icons()
|
||||
|
||||
def _sync_canvas_backend_actions(self) -> None:
|
||||
"""Mirror the active canvas backend onto the Options radio pair."""
|
||||
active = getattr(
|
||||
self,
|
||||
"_act_canvas_plotly" if self._canvas_backend == "plotly" else "_act_canvas_pyvista",
|
||||
)
|
||||
self._canvas_backend_group.blockSignals(True)
|
||||
try:
|
||||
active.setChecked(True)
|
||||
finally:
|
||||
self._canvas_backend_group.blockSignals(False)
|
||||
|
||||
def _apply_toolbar_icons(self) -> None:
|
||||
"""Assign icons + self-documenting tips to every toolbar action.
|
||||
|
||||
|
|
@ -479,6 +512,10 @@ class MenuBuilder:
|
|||
|
||||
m_options = mb.addMenu("&Options")
|
||||
m_options.addAction(self._act_set_units)
|
||||
m_backend = m_options.addMenu("Canvas &Backend")
|
||||
m_backend.addAction(self._act_canvas_pyvista)
|
||||
m_backend.addAction(self._act_canvas_plotly)
|
||||
self._sync_canvas_backend_actions()
|
||||
|
||||
m_help = mb.addMenu("&Help")
|
||||
m_help.addAction(self._act_quick_guide)
|
||||
|
|
|
|||
|
|
@ -459,6 +459,7 @@ class RenderControls:
|
|||
has_pushover = isinstance(self._latest_results, PushoverResults)
|
||||
has_rs = isinstance(self._latest_results, ResponseSpectrumResults)
|
||||
n_sel = len(self._canvas.selection.nodes)
|
||||
caps = self._canvas.capabilities
|
||||
self._act_save.setEnabled(has_project)
|
||||
self._act_save_as.setEnabled(has_project)
|
||||
self._act_export_opspy.setEnabled(has_project)
|
||||
|
|
@ -482,9 +483,11 @@ class RenderControls:
|
|||
self._act_tool_draw_truss.setEnabled(has_project)
|
||||
self._act_show_deformed.setEnabled(has_static)
|
||||
self._act_show_mode_shape.setEnabled(has_modal)
|
||||
self._act_show_force_diagram.setEnabled(has_static)
|
||||
# Force diagrams + video export are backend capabilities, not just
|
||||
# data availability (Plotly cannot do either yet).
|
||||
self._act_show_force_diagram.setEnabled(has_static and caps.diagrams)
|
||||
self._act_show_time_history.setEnabled(has_transient)
|
||||
self._act_export_th_animation.setEnabled(has_transient)
|
||||
self._act_export_th_animation.setEnabled(has_transient and caps.animation_export)
|
||||
self._act_show_hysteresis.setEnabled(has_transient)
|
||||
self._act_show_pushover.setEnabled(has_pushover)
|
||||
self._act_show_response_spectrum.setEnabled(has_rs)
|
||||
|
|
|
|||
122
tests/gui/test_canvas_backend_switch.py
Normal file
122
tests/gui/test_canvas_backend_switch.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""GUI tests for the live canvas-backend switch (PyVista ⇄ Plotly).
|
||||
|
||||
QSettings are injected per-test so a swap never leaks into the real user
|
||||
configuration or the next test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("pyvistaqt")
|
||||
pytest.importorskip("plotly")
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
|
||||
def _make_window(tmp_path, qtbot): # type: ignore[no-untyped-def]
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
||||
window = MainWindow(settings=settings)
|
||||
qtbot.addWidget(window)
|
||||
return window
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_default_backend_is_pyvista(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas3d import ModelCanvas
|
||||
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
assert window.canvas_backend() == "pyvista"
|
||||
assert isinstance(window._canvas, ModelCanvas)
|
||||
assert window._diagram_renderer is not None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_switch_to_plotly_preserves_selection(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
window._vm.new_project()
|
||||
window._canvas.selection.select_node(2)
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
|
||||
assert window.canvas_backend() == "plotly"
|
||||
assert isinstance(window._canvas, PlotlyCanvas)
|
||||
# One shared SelectionState: the tree/table/properties stay bound.
|
||||
assert window._canvas.selection is window._selection
|
||||
assert window._canvas.selection.nodes == frozenset({2})
|
||||
# Both canvases live in the stack; the PyVista one was not destroyed.
|
||||
assert window._canvas_stack.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_plotly_gates_pyvista_only_features(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
assert window._canvas.capabilities.diagrams is True
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
caps = window._canvas.capabilities
|
||||
assert caps.diagrams is False
|
||||
assert caps.animation_export is False
|
||||
assert caps.extrusions is True and caps.labels is True
|
||||
|
||||
window._refresh_action_enablement()
|
||||
assert window._act_show_force_diagram.isEnabled() is False
|
||||
assert window._act_export_th_animation.isEnabled() is False
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_swapping_back_reuses_the_same_canvases(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
pyvista_canvas = window._canvas
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
plotly_canvas = window._canvas
|
||||
window.swap_canvas_backend("pyvista")
|
||||
|
||||
assert window._canvas is pyvista_canvas
|
||||
assert window._diagram_renderer is not None
|
||||
# No duplicate widgets accumulate across repeated swaps.
|
||||
window.swap_canvas_backend("plotly")
|
||||
assert window._canvas is plotly_canvas
|
||||
assert window._diagram_renderer is None # diagrams are PyVista-only
|
||||
assert window._canvas_stack.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_backend_choice_is_persisted(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
window.swap_canvas_backend("plotly")
|
||||
assert window._settings.value("canvas/backend") == "plotly"
|
||||
|
||||
# A fresh window with the same settings boots straight into Plotly.
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
||||
second = MainWindow(settings=settings)
|
||||
qtbot.addWidget(second)
|
||||
assert second.canvas_backend() == "plotly"
|
||||
assert isinstance(second._canvas, PlotlyCanvas)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_working_plane_survives_the_swap(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
window._vm.open(_example("basic_truss"))
|
||||
window._canvas.set_working_plane("XY", 0.0)
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
|
||||
assert window._canvas.working_plane_type() == "XY"
|
||||
assert window._canvas.working_plane_offset() == 0.0
|
||||
|
||||
|
||||
def _example(name: str) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(__file__).resolve().parents[2] / "examples" / f"{name}.osmodel")
|
||||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
The documented dependency stack is ``views -> viewmodels -> services -> core``;
|
||||
each layer may only import from itself or a lower layer, ``core`` stays free of
|
||||
Qt/solver/plotting libraries, and the rendering backend is confined to
|
||||
``views/canvas3d``. These tests never import the application modules; they read
|
||||
the source tree with ``ast`` so the import discipline can be checked without
|
||||
executing Qt or the solver. Directories that do not exist yet simply have
|
||||
nothing to walk, which trivially satisfies a "forbidden import" rule.
|
||||
Qt/solver/plotting libraries, and the rendering backends are confined to the
|
||||
canvas packages (``views/canvas3d``, ``views/canvas_plotly``). These tests never
|
||||
import the application modules; they read the source tree with ``ast`` so the
|
||||
import discipline can be checked without executing Qt or the solver.
|
||||
Directories that do not exist yet simply have nothing to walk, which trivially
|
||||
satisfies a "forbidden import" rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,7 +17,10 @@ from pathlib import Path
|
|||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC = ROOT / "src" / "otko"
|
||||
CANVAS = SRC / "views" / "canvas3d"
|
||||
CANVAS_DIRS = (
|
||||
SRC / "views" / "canvas3d",
|
||||
SRC / "views" / "canvas_plotly",
|
||||
)
|
||||
|
||||
# Qt binding roots, in every spelling the project might use.
|
||||
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
|
||||
|
|
@ -32,7 +36,7 @@ CORE_FORBIDDEN = QT_ROOTS | {
|
|||
"matplotlib",
|
||||
}
|
||||
|
||||
# Only views/canvas3d/ may touch a 3D or plotting backend.
|
||||
# Only the canvas packages may touch a 3D or plotting backend.
|
||||
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
|
||||
|
||||
|
||||
|
|
@ -75,14 +79,14 @@ def _violations(
|
|||
forbidden: set[str],
|
||||
*,
|
||||
skip: tuple[Path, ...] = (),
|
||||
exclude_dir: Path | None = None,
|
||||
exclude_dirs: tuple[Path, ...] = (),
|
||||
) -> list[str]:
|
||||
"""Return ``"<path>: <module>"`` entries for every forbidden import found."""
|
||||
found: list[str] = []
|
||||
for path in _iter_python_files(root):
|
||||
if path in skip:
|
||||
continue
|
||||
if exclude_dir is not None and _is_within(path, exclude_dir):
|
||||
if any(_is_within(path, directory) for directory in exclude_dirs):
|
||||
continue
|
||||
for module in sorted(_root_imports(path) & forbidden):
|
||||
found.append(f"{path.relative_to(ROOT)}: {module}")
|
||||
|
|
@ -112,9 +116,9 @@ def test_architecture_views_no_solver() -> None:
|
|||
|
||||
|
||||
def test_no_vtk_import_outside_canvas() -> None:
|
||||
"""3D/plot backends stay under views/canvas3d/."""
|
||||
violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS)
|
||||
assert violations == [], f"backend imports outside views/canvas3d/: {violations}"
|
||||
"""3D/plot backends stay under the canvas packages."""
|
||||
violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS)
|
||||
assert violations == [], f"backend imports outside the canvas packages: {violations}"
|
||||
|
||||
|
||||
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
|
||||
|
|
|
|||
Loading…
Reference in a new issue