diff --git a/AGENTS.md b/AGENTS.md index bdc5f2e..53e073c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index b1b3417..2b69352 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -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) diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py new file mode 100644 index 0000000..0352961 --- /dev/null +++ b/src/otko/views/canvas_base.py @@ -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: ... diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index df4b70e..cd34449 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -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 diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index 958459e..cbd21e3 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -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) - # Diagram overlay paints onto the same plotter as the model. - self._diagram_renderer = DiagramRenderer(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. + 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) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index bd8af6a..0aa0b2e 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -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) diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 1d574c1..63d1f0f 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -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) diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py new file mode 100644 index 0000000..ed77be1 --- /dev/null +++ b/tests/gui/test_canvas_backend_switch.py @@ -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") diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py index 9281e67..7b3f95c 100644 --- a/tests/unit/test_architecture.py +++ b/tests/unit/test_architecture.py @@ -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 ``": "`` 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