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

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