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
```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).

View file

@ -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)

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
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

View file

@ -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)

View file

@ -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)

View file

@ -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)

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``;
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