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:
smillmorel 2026-09-16 18:43:59 -04:00
commit 9a1baa07c2
9 changed files with 442 additions and 38 deletions

View file

@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
## Install ## Install
```bash ```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) pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
python -m otko # launch GUI (src/otko/app.py:run) 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.** - `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.** - `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/`: 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. - `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). - 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

@ -24,6 +24,7 @@ from otko.core import Project
from otko.views.canvas3d.model_renderer import ModelRenderer from otko.views.canvas3d.model_renderer import ModelRenderer
from otko.views.canvas3d.selection import SelectionState from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_base import CanvasCapabilities
PICK_DEBUG = False PICK_DEBUG = False
@ -31,6 +32,9 @@ PICK_DEBUG = False
class ModelCanvas(QtInteractor): # type: ignore[misc] class ModelCanvas(QtInteractor): # type: ignore[misc]
"""The central 3D viewport widget.""" """The central 3D viewport widget."""
#: VTK supports every optional overlay layer.
capabilities = CanvasCapabilities()
# Convenience signals re-emitted from SelectionState. # Convenience signals re-emitted from SelectionState.
nodePicked = Signal(int) nodePicked = Signal(int)
elementPicked = Signal(int) elementPicked = Signal(int)

View 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: ...

View file

@ -210,6 +210,14 @@ class DockManager:
because the off-screen renderer must be touched from the GUI because the off-screen renderer must be touched from the GUI
thread (VTK's Qt-backed render window isn't thread-safe). 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 from PySide6.QtWidgets import QFileDialog
path, _sel = QFileDialog.getSaveFileName( path, _sel = QFileDialog.getSaveFileName(
@ -260,6 +268,14 @@ class DockManager:
"Run a Static analysis first; force diagrams visualise its element-force output.", "Run a Static analysis first; force diagrams visualise its element-force output.",
) )
return 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() self._tear_down_post_dock()
# Pick the component with the largest abs_max as the initial choice # 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.", "Run a Transient (time-history) analysis first.",
) )
return 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 pathlib import Path
from PySide6.QtWidgets import QFileDialog, QInputDialog from PySide6.QtWidgets import QFileDialog, QInputDialog

View file

@ -14,15 +14,18 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from PySide6.QtCore import QSettings from PySide6.QtCore import QSettings
from PySide6.QtGui import QCloseEvent, QIcon 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.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers from otko.views.action_handlers import ActionHandlers
from otko.views.canvas3d import ModelCanvas from otko.views.canvas3d import ModelCanvas
from otko.views.canvas3d.diagram_renderer import DiagramRenderer 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.dock_manager import DockManager
from otko.views.menu_builder import MenuBuilder from otko.views.menu_builder import MenuBuilder
from otko.views.render_controls import RenderControls from otko.views.render_controls import RenderControls
@ -34,6 +37,9 @@ from otko.views.tools import (
ToolController, ToolController,
) )
#: Canvas backends, in menu order.
CANVAS_BACKENDS = ("pyvista", "plotly")
class MainWindow( class MainWindow(
QMainWindow, QMainWindow,
@ -44,7 +50,7 @@ class MainWindow(
): ):
"""Top-level application shell.""" """Top-level application shell."""
def __init__(self) -> None: def __init__(self, settings: QSettings | None = None) -> None:
super().__init__() super().__init__()
self.setWindowTitle("OTKO") self.setWindowTitle("OTKO")
self.setWindowIcon( self.setWindowIcon(
@ -60,12 +66,20 @@ class MainWindow(
self._show_node_labels = False self._show_node_labels = False
self._show_element_labels = False self._show_element_labels = False
self._build_central_canvas() # One selection state shared by every canvas backend so a live swap
self._tool_controller = ToolController(self._canvas, self._vm, self) # keeps the current selection (canvas, tree, table, properties dock).
self._select_tool = SelectTool(self._canvas, self._vm, self) self._selection = SelectionState(self)
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
self._draw_node_tool: DrawNodeTool | None = None self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
self._draw_truss_tool: DrawTrussTool | None = None 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_docks()
self._build_actions() self._build_actions()
@ -77,19 +91,120 @@ class MainWindow(
self._refresh_action_enablement() self._refresh_action_enablement()
self.restore_layout() self.restore_layout()
# ── construction ───────────────────────────────────────────────── # ── canvas backend ───────────────────────────────────────────────
def _build_central_canvas(self) -> None: def _stored_canvas_backend(self) -> str:
self._canvas = ModelCanvas(self) """The backend chosen last session, falling back to PyVista."""
self.setCentralWidget(self._canvas) stored = self._settings.value("canvas/backend", "pyvista")
# Diagram overlay paints onto the same plotter as the model. name = str(stored) if stored is not None else "pyvista"
self._diagram_renderer = DiagramRenderer(self._canvas) 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.
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 ─────────────────────────────────────────── # ── layout persistence ───────────────────────────────────────────
def save_layout(self) -> None: def save_layout(self) -> None:
"""Persist window geometry and dock/toolbar state for next launch.""" """Persist window geometry and dock/toolbar state for next launch."""
settings = QSettings("OTKO", "OTKO") self._settings.setValue("geometry", self.saveGeometry())
settings.setValue("geometry", self.saveGeometry()) self._settings.setValue("windowState", self.saveState())
settings.setValue("windowState", self.saveState())
def restore_layout(self) -> None: def restore_layout(self) -> None:
"""Restore a previously saved geometry/dock layout, if any. """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__`` On first run no values are stored, so the defaults from ``__init__``
(``resize`` + dock construction) stay in effect. (``resize`` + dock construction) stay in effect.
""" """
settings = QSettings("OTKO", "OTKO") geometry = self._settings.value("geometry")
geometry = settings.value("geometry")
if geometry is not None: if geometry is not None:
self.restoreGeometry(geometry) self.restoreGeometry(geometry)
state = settings.value("windowState") state = self._settings.value("windowState")
if state is not None: if state is not None:
self.restoreState(state) 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_node.triggered.connect(self._on_draw_node_tool)
self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_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._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool)
self._tool_controller.toolChanged.connect(self._on_tool_changed) # toolChanged → toolbar/menu sync is wired in _build_tooling(), which
# Keep the Tools toolbar checked state in sync with the controller. # also runs on a canvas-backend swap (then a fresh controller exists).
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
# 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 # Define
self._act_grid.triggered.connect(self._on_grid_system) self._act_grid.triggered.connect(self._on_grid_system)

View file

@ -193,8 +193,41 @@ class MenuBuilder:
self._act_quick_guide = QAction("&Quick Guide", self) self._act_quick_guide = QAction("&Quick Guide", self)
self._act_set_units = QAction("Set Display &Units…", 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() 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: def _apply_toolbar_icons(self) -> None:
"""Assign icons + self-documenting tips to every toolbar action. """Assign icons + self-documenting tips to every toolbar action.
@ -479,6 +512,10 @@ class MenuBuilder:
m_options = mb.addMenu("&Options") m_options = mb.addMenu("&Options")
m_options.addAction(self._act_set_units) 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 = mb.addMenu("&Help")
m_help.addAction(self._act_quick_guide) m_help.addAction(self._act_quick_guide)

View file

@ -459,6 +459,7 @@ class RenderControls:
has_pushover = isinstance(self._latest_results, PushoverResults) has_pushover = isinstance(self._latest_results, PushoverResults)
has_rs = isinstance(self._latest_results, ResponseSpectrumResults) has_rs = isinstance(self._latest_results, ResponseSpectrumResults)
n_sel = len(self._canvas.selection.nodes) n_sel = len(self._canvas.selection.nodes)
caps = self._canvas.capabilities
self._act_save.setEnabled(has_project) self._act_save.setEnabled(has_project)
self._act_save_as.setEnabled(has_project) self._act_save_as.setEnabled(has_project)
self._act_export_opspy.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_tool_draw_truss.setEnabled(has_project)
self._act_show_deformed.setEnabled(has_static) self._act_show_deformed.setEnabled(has_static)
self._act_show_mode_shape.setEnabled(has_modal) 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_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_hysteresis.setEnabled(has_transient)
self._act_show_pushover.setEnabled(has_pushover) self._act_show_pushover.setEnabled(has_pushover)
self._act_show_response_spectrum.setEnabled(has_rs) self._act_show_response_spectrum.setEnabled(has_rs)

View 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")

View file

@ -2,11 +2,12 @@
The documented dependency stack is ``views -> viewmodels -> services -> core``; The documented dependency stack is ``views -> viewmodels -> services -> core``;
each layer may only import from itself or a lower layer, ``core`` stays free of 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 Qt/solver/plotting libraries, and the rendering backends are confined to the
``views/canvas3d``. These tests never import the application modules; they read canvas packages (``views/canvas3d``, ``views/canvas_plotly``). These tests never
the source tree with ``ast`` so the import discipline can be checked without import the application modules; they read the source tree with ``ast`` so the
executing Qt or the solver. Directories that do not exist yet simply have import discipline can be checked without executing Qt or the solver.
nothing to walk, which trivially satisfies a "forbidden import" rule. Directories that do not exist yet simply have nothing to walk, which trivially
satisfies a "forbidden import" rule.
""" """
from __future__ import annotations from __future__ import annotations
@ -16,7 +17,10 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "src" / "otko" 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 binding roots, in every spelling the project might use.
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"} QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
@ -32,7 +36,7 @@ CORE_FORBIDDEN = QT_ROOTS | {
"matplotlib", "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"} VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
@ -75,14 +79,14 @@ def _violations(
forbidden: set[str], forbidden: set[str],
*, *,
skip: tuple[Path, ...] = (), skip: tuple[Path, ...] = (),
exclude_dir: Path | None = None, exclude_dirs: tuple[Path, ...] = (),
) -> list[str]: ) -> list[str]:
"""Return ``"<path>: <module>"`` entries for every forbidden import found.""" """Return ``"<path>: <module>"`` entries for every forbidden import found."""
found: list[str] = [] found: list[str] = []
for path in _iter_python_files(root): for path in _iter_python_files(root):
if path in skip: if path in skip:
continue 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 continue
for module in sorted(_root_imports(path) & forbidden): for module in sorted(_root_imports(path) & forbidden):
found.append(f"{path.relative_to(ROOT)}: {module}") 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: def test_no_vtk_import_outside_canvas() -> None:
"""3D/plot backends stay under views/canvas3d/.""" """3D/plot backends stay under the canvas packages."""
violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS) violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS)
assert violations == [], f"backend imports outside views/canvas3d/: {violations}" assert violations == [], f"backend imports outside the canvas packages: {violations}"
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM # The documented dependency stack; ``commands`` sits beside viewmodels (a VM