diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c834e6..f0a7165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \ libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb - - run: pip install -e ".[gui,dev]" + - run: pip install -e ".[dev]" - run: xvfb-run -a pytest tests/gui -m "not slow" test-integration: diff --git a/.gitignore b/.gitignore index eac3bc1..c82640e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,3 @@ examples/ozan*.osmodel examples/ozan_results/ /Ex*.csv /Ex*.png - -# BEGIN oh-my-opencode-slim clonedeps -.slim/clonedeps/repos/ -# END oh-my-opencode-slim clonedeps diff --git a/.ignore b/.ignore deleted file mode 100644 index 32dbf4f..0000000 --- a/.ignore +++ /dev/null @@ -1,9 +0,0 @@ -# BEGIN oh-my-opencode-slim clonedeps -!.slim/ -!.slim/clonedeps.json -!.slim/clonedeps/ -!.slim/clonedeps/repos/ -!.slim/clonedeps/repos/** -.slim/clonedeps/repos/**/.git/ -.slim/clonedeps/repos/**/.git/** -# END oh-my-opencode-slim clonedeps diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json deleted file mode 100644 index ddddcab..0000000 --- a/.slim/clonedeps.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "1.0.0", - "updatedAt": "2026-09-16T17:30:00.000Z", - "dependencies": [ - { - "name": "opstool", - "resolvedVersion": "1.0.26", - "repoUrl": "https://github.com/yexiang92/opstool.git", - "ref": "v1.0.26", - "path": ".slim/clonedeps/repos/yexiang92__opstool", - "packagePath": "opstool/vis", - "reason": "Read-only source for evaluating whether to port opstool's PyVista/Plotly visualization settings into otko's canvas backends, or to depend on opstool.vis directly (GPL-3.0, compatible with otko's AGPL-3.0 via GPLv3 section 13)." - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index 6b63da2..bdc5f2e 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 + plotly.js backend + dev tools +pip install -e ".[gui,dev]" # desktop: Qt + PyVista + 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,7 +19,6 @@ 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). @@ -57,17 +56,3 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git - `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip). - Projects persist as single Pydantic-validated JSON `.osmodel` (diffable); analysis output goes to `*.osresults.h5` (HDF5, one group per case). - Quick smoke: open `examples/cantilever.osmodel` → run `Tip-Load` → M3 peaks 50 kN·m at fixed end. - -## Cloned Dependency Source - -Read-only dependency source repositories are available under -`.slim/clonedeps/repos/` for inspection. Do not edit these clones. The -structured manifest is `.slim/clonedeps.json`. - -- `.slim/clonedeps/repos/yexiang92__opstool/` — `yexiang92/opstool` at `v1.0.26`; the OpenSeesPy - pre/post-processor whose PyVista and Plotly visualization settings - (`opstool/vis/{pyvista,plotly}/plot_utils.py`, `plot_resp_base.py`, `vis_model.py`) are the - reference for otko's canvas look-and-feel. GPL-3.0, and GPLv3 §13 explicitly permits combining - it with otko's AGPL-3.0. Any code actually ported from it must keep the opstool copyright notice - and record that it was modified (GPLv3 §5a/b) — add that entry to `NOTICE` when the port lands. - diff --git a/NOTICE b/NOTICE index 24b2dd1..10a928c 100644 --- a/NOTICE +++ b/NOTICE @@ -45,21 +45,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## Ported / adapted code: `opstool` - -The canvas element colour palette in `src/otko/views/canvas3d/style.py` -(per-family element colours and the diverging response colour scale) and the -resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were -adapted from the `opstool` project, which is distributed under the **GNU -General Public License v3.0**. opstool is Copyright © Yexiang Yan and -contributors. - -In accordance with GPLv3 §5(a)/(b) this notice records that the material was -modified and adapted for OTKO. Combining the GPLv3-covered material with -OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is -conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the -opstool-derived portions. - ## Runtime dependencies OTKO depends on third-party software that is not covered by OTKO's diff --git a/pyproject.toml b/pyproject.toml index d67c45f..ffab17d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ gui = [ "vtk>=9.3", "pyqtgraph>=0.13", "imageio[ffmpeg]>=2.34", - "plotly>=5.18", ] dev = [ "pytest>=7.4", diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py index 57c0a70..05c5f2c 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -31,7 +31,6 @@ import pyvista as pv from otko.core import Project from otko.services.element_forces import DiagramData, ForceComponent -from otko.views.canvas3d.style import RenderStyle _LOG = logging.getLogger("otko.diagram") @@ -63,9 +62,8 @@ class DiagramRenderer: - ``clear()`` removes the overlay. """ - def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: + def __init__(self, plotter: Any) -> None: self._plotter = plotter - self._style = style or RenderStyle() self._actor: Any = None self._label_actor: Any = None @@ -160,9 +158,7 @@ class DiagramRenderer: self._actor = self._plotter.add_mesh( mesh, scalars="value", - # Shared response palette (opstool's default diverging scale), so - # the PyVista and Plotly backends stay visually consistent. - cmap=list(self._style.response_scale_colors), + cmap="coolwarm", clim=(-vmax, vmax), show_scalar_bar=True, scalar_bar_args={"title": comp_label, "n_labels": 5}, diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index 2b69352..b1b3417 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -24,7 +24,6 @@ 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 @@ -32,9 +31,6 @@ 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/canvas3d/style.py b/src/otko/views/canvas3d/style.py index 3e1de73..f25d674 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -27,31 +27,6 @@ class RenderStyle: zerolength_color: str = "#a020f0" selected_color: str = "#00d4ff" - # ── element palette (adapted from opstool v1.0.26, GPL-3.0) ─────── - # opstool colours wireframe elements by *family* rather than painting - # every frame the same colour, which makes mixed beam/truss/link models - # readable at a glance. Values come from ``PLOT_ARGS_DEFAULT`` in - # ``opstool/vis/{pyvista,plotly}/plot_utils.py``; see ``NOTICE``. - element_beam_color: str = "#0652ff" # beam-column family - element_truss_color: str = "#FF8C00" # truss family - element_link_color: str = "#39FF14" # zero-length / link family - - #: Diverging scale for scalar response overlays (force diagrams today, - #: nodal / element response plots later): blue → red, evenly spaced. - #: This is opstool's ``default_cmap`` (RdYlBu reversed). - response_scale_colors: tuple[str, ...] = ( - "#313695", - "#4575b4", - "#74add1", - "#abd9e9", - "#e0f3f8", - "#fee090", - "#fdae61", - "#f46d43", - "#d73027", - "#a50026", - ) - fix_color: str = "#c0392b" # firebrick pin_color: str = "#c0392b" roller_color: str = "#e67e22" @@ -74,13 +49,3 @@ class RenderStyle: load_min_length: float = 0.5 selection_thickness_factor: float = 1.6 # multiplier for selected actors - - # ── helpers ────────────────────────────────────────────────────── - def response_colorscale(self) -> list[tuple[float, str]]: - """Plotly ``colorscale`` form of :attr:`response_scale_colors`.""" - count = len(self.response_scale_colors) - if count < 2: - return [(0.0, self.response_scale_colors[0])] - return [ - (index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) - ] diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py deleted file mode 100644 index 0352961..0000000 --- a/src/otko/views/canvas_base.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py deleted file mode 100644 index 7fabb6d..0000000 --- a/src/otko/views/canvas_plotly/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Plotly-backed canvas package (optional GUI backend). - -``PlotlyCanvas`` is exported lazily (PEP 562): the pure -:mod:`~otko.views.canvas_plotly.trace_builder` must stay importable in the -headless test job, which has no Qt at all. -""" - -from __future__ import annotations - -from typing import Any - -from otko.views.canvas_plotly.trace_builder import ( - PlotlyTraceBuilder, - Scene, - SceneOptions, -) - -__all__ = ["PlotlyCanvas", "PlotlyTraceBuilder", "Scene", "SceneOptions"] - - -def __getattr__(name: str) -> Any: - if name == "PlotlyCanvas": - from otko.views.canvas_plotly.plotly_canvas import PlotlyCanvas - - return PlotlyCanvas - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/otko/views/canvas_plotly/bridge.py b/src/otko/views/canvas_plotly/bridge.py deleted file mode 100644 index 9dafbce..0000000 --- a/src/otko/views/canvas_plotly/bridge.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Qt ↔ plotly.js bridge exposed through ``QWebChannel``. - -The page calls :meth:`_Bridge.picked` / :meth:`_Bridge.snapClicked`; every -call is re-emitted here and wired to the canvas' own Qt signals. -""" - -from __future__ import annotations - -from PySide6.QtCore import QObject, Signal, Slot - - -class _Bridge(QObject): - """Slot surface the JavaScript side addresses as ``otkoBridge``.""" - - #: (kind, entity id, additive modifier held) where kind is node|element. - picked = Signal(str, int, bool) - #: World-space coordinates of a clicked grid intersection. - snapClicked = Signal(float, float, float) - - @Slot(str, int, bool) - def onPicked(self, kind: str, entity_id: int, additive: bool) -> None: - """JS entry point: a model entity (or snap target) was clicked.""" - self.picked.emit(kind, int(entity_id), bool(additive)) - - @Slot(float, float, float) - def onSnapClicked(self, x: float, y: float, z: float) -> None: - """JS entry point: a grid-intersection target was clicked.""" - self.snapClicked.emit(float(x), float(y), float(z)) diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py deleted file mode 100644 index 278abba..0000000 --- a/src/otko/views/canvas_plotly/html.py +++ /dev/null @@ -1,175 +0,0 @@ -"""HTML/JS runtime for the Plotly canvas. - -The page is materialised once per process into a temp directory and loaded -from ``file://``: the plotly.js bundle is ~5 MB, which is past -``QWebEngineView.setHtml``'s data-URL limit, and writing it to disk also lets -the browser cache it across figure updates. - -The JS side exposes three entry points to Python (called via -``QWebEnginePage.runJavaScript``): - -- ``otkoUpdate(payloadJson)`` — replace data + layout with ``Plotly.react``, - which diffs client-side and leaves the interactive camera untouched. -- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets, - parallel-projection toggle). -- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview. - -Clicks travel the other way through the ``otkoBridge`` QWebChannel object: -entity picks carry the trace ``meta.kind`` and the point ``customdata``. -""" - -from __future__ import annotations - -import atexit -import shutil -import tempfile -from pathlib import Path - -#: Materialised runtime directory (plotly.min.js + index.html + qwebchannel.js). -_ASSETS_DIR: Path | None = None - -# ``qrc:///qtwebchannel/qwebchannel.js`` is served by QtWebEngine's internal -# resource scheme; it is not reachable through QFile. -_PAGE = """ - - - - - - - -
- - - - -""" - - -def runtime_url() -> str: - """Ensure the JS runtime is on disk and return the page's file path. - - Called once per :class:`~otko.views.canvas_plotly.PlotlyCanvas`; the - directory is reused and removed at interpreter exit. - """ - global _ASSETS_DIR - directory = _ensure_dir() - index = directory / "index.html" - if not index.exists(): - (directory / "index.html").write_text(_PAGE, encoding="utf-8") - (directory / "plotly.min.js").write_text(_plotly_js(), encoding="utf-8") - return str(index) - - -def _ensure_dir() -> Path: - global _ASSETS_DIR - if _ASSETS_DIR is None: - _ASSETS_DIR = Path(tempfile.mkdtemp(prefix="otko-plotly-")) - atexit.register(shutil.rmtree, _ASSETS_DIR, True) - return _ASSETS_DIR - - -def _plotly_js() -> str: - """The offline plotly.js bundle shipped inside the ``plotly`` package.""" - from plotly.offline import get_plotlyjs - - return get_plotlyjs() diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py deleted file mode 100644 index e5af025..0000000 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Plotly-backed 3D canvas. - -A ``QWebEngineView`` hosting plotly.js, driven over ``QWebChannel``. It -implements the same public surface as :class:`otko.views.canvas3d.ModelCanvas` -(signals, selection, working plane, view presets, display toggles) so -``MainWindow`` can swap the two at runtime. - -Update strategy: ``Plotly.react`` diffs client-side, and the layout only -carries ``scene.camera`` when a view preset or the projection toggle asks for -it — so re-rendering on a model edit or selection change never yanks the -camera the user is orbiting. -""" - -from __future__ import annotations - -import json -import math -from dataclasses import replace -from typing import Any - -from PySide6.QtCore import QUrl, Signal -from PySide6.QtWebChannel import QWebChannel -from PySide6.QtWebEngineWidgets import QWebEngineView -from PySide6.QtWidgets import QVBoxLayout, QWidget - -from otko.views.canvas3d.model_renderer import RendererMode -from otko.views.canvas3d.selection import SelectionState -from otko.views.canvas3d.style import RenderStyle -from otko.views.canvas_base import CanvasCapabilities -from otko.views.canvas_plotly import html as _html -from otko.views.canvas_plotly.bridge import _Bridge -from otko.views.canvas_plotly.trace_builder import ( - PlotlyTraceBuilder, - Scene, - SceneOptions, -) - -#: View preset directions (unit-ish vectors from the scene centre to the eye). -_VIEW_DIRECTIONS = { - "iso": (1.0, 1.0, 0.8), - "xy": (0.0, 0.0, 1.0), - "xz": (0.0, -1.0, 0.0), - "yz": (1.0, 0.0, 0.0), -} - - -class _CameraShim: - """Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere.""" - - def __init__(self, canvas: PlotlyCanvas) -> None: - self._canvas = canvas - - @property - def parallel_projection(self) -> bool: - return self._canvas._parallel - - @parallel_projection.setter - def parallel_projection(self, value: bool) -> None: - self._canvas.set_parallel_projection(bool(value)) - - -class _PlotlyRendererFacade: - """Stand-in for ``ModelRenderer`` covering the calls made on ``_renderer``. - - ``RenderControls`` and ``DockManager`` reach into ``canvas._renderer`` for - ``render`` / ``set_mode`` / ``_mode``; this keeps those code paths - backend-agnostic. - """ - - def __init__(self, canvas: PlotlyCanvas) -> None: - self._canvas = canvas - - @property - def _mode(self) -> RendererMode: - return self._canvas._mode - - @property - def _project(self) -> Any: - return self._canvas._project - - @property - def _working_plane(self) -> tuple[str, float] | None: - return self._canvas._working_plane - - def render(self, project: Any) -> None: - self._canvas.set_project(project) - - def set_mode(self, mode: RendererMode, deformation: Any = None) -> None: - self._canvas.set_mode(mode, deformation) - - def set_working_plane(self, plane: tuple[str, float] | None) -> None: - if plane is None: - self._canvas.set_working_plane(None, 0.0) - else: - self._canvas.set_working_plane(plane[0], plane[1]) - - def set_show_section_extrusions(self, on: bool) -> None: - self._canvas.set_show_section_extrusions(on) - - def set_show_local_axes(self, on: bool) -> None: - self._canvas.set_show_local_axes(on) - - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: - self._canvas.set_display_options( - show_node_labels=show_node_labels, - show_element_labels=show_element_labels, - ) - - -class PlotlyCanvas(QWidget): - """The central 3D viewport, rendered by plotly.js in a web view.""" - - nodePicked = Signal(int) - elementPicked = Signal(int) - emptyClicked = Signal(float, float, float) - - #: Force diagrams and off-screen video capture are not implemented on - #: this backend yet (both are PyVista-specific today). - capabilities = CanvasCapabilities(diagrams=False, animation_export=False) - - def __init__( - self, - parent: QWidget | None = None, - style: RenderStyle | None = None, - selection: SelectionState | None = None, - ) -> None: - super().__init__(parent) - self._style = style or RenderStyle() - self.selection = selection or SelectionState(self) - self._builder = PlotlyTraceBuilder(self._style) - self._project: Any = None - self._scene = Scene(data=[], layout={}) - self._options = SceneOptions() - self._mode = RendererMode.MODEL - self._parallel = False - self._view_preset = "iso" - self._snap_enabled = False - self._default_selection_enabled = True - self._working_plane: tuple[str, float] | None = None - self._camera = _CameraShim(self) - self._camera_dirty = True - self._renderer = _PlotlyRendererFacade(self) - self._ready = False - - self._build_ui() - self.selection.selectionChanged.connect(self._on_selection_changed) - - # ── construction ───────────────────────────────────────────────── - def _build_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self._web = QWebEngineView(self) - self._channel = QWebChannel(self._web.page()) - self._bridge = _Bridge(self) - self._channel.registerObject("otkoBridge", self._bridge) - self._web.page().setWebChannel(self._channel) - self._web.loadFinished.connect(self._on_loaded) - self._bridge.picked.connect(self._on_picked) - self._bridge.snapClicked.connect(self._on_snap_clicked) - layout.addWidget(self._web) - self._web.load(QUrl.fromLocalFile(_html.runtime_url())) - - def _on_loaded(self, ok: bool) -> None: - if not ok: - return - self._ready = True - self._push_scene() - self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})") - - # ── public API (mirrors ModelCanvas) ───────────────────────────── - def show_project(self, project: Any) -> None: - """Render (or clear) a project; frame the camera when nodes exist.""" - self.set_project(project) - if project is not None and project.nodes: - self._view_preset = "iso" - self._camera_dirty = True - self.render() - - def clear_model(self) -> None: - """Remove the model but keep the view/selection machinery.""" - self.selection.clear() - self.set_project(None) - self.render() - - def render(self, *args: Any, **kwargs: Any) -> None: - """Rebuild the figure and hand it to plotly.js. - - ``QWidget.render`` is overloaded for painting into a target; those - calls are forwarded untouched so the widget stays well-behaved, and - only the no-argument canvas idiom (shared with ``ModelCanvas``) - triggers a figure push. - """ - if args or kwargs: - super().render(*args, **kwargs) - return - self._push_scene() - - def set_project(self, project: Any) -> None: - """Bind a project without rendering (used by the ``_renderer`` facade).""" - self._project = project - - def set_mode(self, mode: RendererMode, deformation: Any = None) -> None: - """Set MODEL / DEFORMED / MODAL plus the displacement source to apply.""" - self._mode = mode - self._options = replace(self._options, deformation=deformation) - - def set_parallel_projection(self, on: bool) -> None: - self._parallel = bool(on) - self._camera_dirty = True - - def reset_camera(self) -> None: - self._view_preset = "iso" - self._camera_dirty = True - self.render() - - def view_isometric(self) -> None: - self._view_preset = "iso" - self._camera_dirty = True - self.render() - - def view_xy(self) -> None: - """Top view: the eye sits on +Z looking down.""" - self._view_preset = "xy" - self._camera_dirty = True - self.render() - - def view_xz(self) -> None: - """Front view: the eye sits on -Y.""" - self._view_preset = "xz" - self._camera_dirty = True - self.render() - - def view_yz(self) -> None: - """Right view: the eye sits on +X.""" - self._view_preset = "yz" - self._camera_dirty = True - self.render() - - # ── working plane ──────────────────────────────────────────────── - def set_working_plane(self, plane: str | None, offset: float) -> None: - """Filter the grid overlay to the active plan / elevation level.""" - if plane is None: - self._working_plane = None - else: - if plane not in ("XY", "XZ", "YZ"): - raise ValueError(f"Unsupported working plane: {plane!r}") - self._working_plane = (plane, float(offset)) - self._options = replace(self._options, working_plane=self._working_plane) - self.render() - - def clear_working_plane(self) -> None: - self.set_working_plane(None, 0.0) - - def working_plane_type(self) -> str | None: - return self._working_plane[0] if self._working_plane is not None else None - - def working_plane_offset(self) -> float | None: - return self._working_plane[1] if self._working_plane is not None else None - - # ── display toggles ────────────────────────────────────────────── - def set_snap_preview_enabled(self, enabled: bool) -> None: - """Toggle the hover snap-target preview (draw tools turn it on).""" - self._snap_enabled = bool(enabled) - self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})") - - def set_show_section_extrusions(self, enabled: bool) -> None: - self._options = replace(self._options, show_extrusions=bool(enabled)) - self.render() - - def set_show_local_axes(self, enabled: bool) -> None: - self._options = replace(self._options, show_local_axes=bool(enabled)) - self.render() - - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: - self._options = replace( - self._options, - show_node_labels=bool(show_node_labels), - show_element_labels=bool(show_element_labels), - ) - self.render() - - def set_default_selection_enabled(self, enabled: bool) -> None: - """When False, picks fire signals but do not touch :attr:`selection`.""" - self._default_selection_enabled = bool(enabled) - - # ── internals ─────────────────────────────────────────────────── - def _push_scene(self) -> None: - self._scene = self._builder.build(self._project, self._options) - layout = dict(self._scene.layout) - if self._camera_dirty: - layout["scene"] = { - **layout["scene"], - "camera": self._camera_dict(self._scene), - } - self._camera_dirty = False - if not self._ready: - return - payload = json.dumps({"data": self._scene.data, "layout": layout}) - self._web.page().runJavaScript(f"window.otkoUpdate({json.dumps(payload)})") - - def _camera_dict(self, scene: Scene) -> dict[str, Any]: - cx, cy, cz = scene.center - distance = max(scene.diagonal, 1e-6) * 1.6 - direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"]) - norm = math.sqrt(sum(component * component for component in direction)) or 1.0 - eye = ( - cx + direction[0] / norm * distance, - cy + direction[1] / norm * distance, - cz + direction[2] / norm * distance, - ) - # Looking straight down the Z axis needs a non-degenerate up vector. - up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0) - return { - "eye": {"x": eye[0], "y": eye[1], "z": eye[2]}, - "center": {"x": cx, "y": cy, "z": cz}, - "up": {"x": up[0], "y": up[1], "z": up[2]}, - "projection": {"type": "orthographic" if self._parallel else "perspective"}, - } - - def _eval(self, js: str) -> None: - if self._ready: - self._web.page().runJavaScript(js) - - def _on_selection_changed(self, nodes: frozenset[int], elements: frozenset[int]) -> None: - self._options = replace( - self._options, - selection_nodes=frozenset(nodes), - selection_elements=frozenset(elements), - ) - self.render() - - def _on_picked(self, kind: str, entity_id: int, additive: bool) -> None: - if kind == "node": - if self._default_selection_enabled: - if additive: - self.selection.toggle_node(entity_id) - else: - self.selection.select_node(entity_id) - self.nodePicked.emit(entity_id) - elif kind == "element": - if self._default_selection_enabled: - if additive: - self.selection.toggle_element(entity_id) - else: - self.selection.select_element(entity_id) - self.elementPicked.emit(entity_id) - - def _on_snap_clicked(self, x: float, y: float, z: float) -> None: - self.emptyClicked.emit(float(x), float(y), float(z)) - - -def _js_bool(value: bool) -> str: - return "true" if value else "false" diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py deleted file mode 100644 index 8c1538b..0000000 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ /dev/null @@ -1,963 +0,0 @@ -"""Project → Plotly figure dictionaries. - -Pure data transformation: no Qt, no pyvista, no plotly import. The -:class:`PlotlyCanvas` serialises the returned :class:`Scene` to JSON and -hands it to plotly.js; the unit tests exercise it headless. - -Geometry conventions mirror :mod:`otko.views.canvas3d.model_renderer` so the -two backends draw the same model, but the output medium differs: - -- Nodes / supports / labels are ``scatter3d`` markers, whose size is in - *pixels* (plotly.js does not scale markers with world units). -- Frames are ``scatter3d`` line traces. Plotly cannot colour individual - segments of one line trace, so selection is expressed as two traces - (normal + selected) whose segment lists are regrouped on every update. -- Loads and local axes are ``cone`` traces (plotly.js has no 3D arrow glyph). -- Section extrusions are ``mesh3d`` box sweeps from - :func:`otko.services.section_bbox.bbox_for_section` — the same bbox - fallback the PyVista backend uses when a shape hint is not an exact fit. - -Picking rides on ``customdata`` + trace ``meta``: every pickable trace is -tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads -``point.data.meta.kind`` to decide what the click meant. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import numpy as np - -from otko.core import ( - BeamWithHingesElement, - CorotTrussElement, - DispBeamColumn, - ElasticBeamColumn, - ForceBeamColumn, - NodalLoad, - PlainLoadPattern, - Project, - QuadElement, - TrussElement, - UniformElementLoad, - ZeroLengthElement, - ZeroLengthSectionElement, -) -from otko.views.canvas3d.style import RenderStyle - -_FRAME_CLASSES = ( - ElasticBeamColumn, - DispBeamColumn, - ForceBeamColumn, - BeamWithHingesElement, - TrussElement, - CorotTrussElement, - ZeroLengthElement, -) - -#: Element families that get their own palette entry (see ``RenderStyle``). -_TRUSS_FAMILY = (TrussElement, CorotTrussElement) - - -def _family_index(el: Any) -> int: - """Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length.""" - if isinstance(el, _TRUSS_FAMILY): - return 1 - if isinstance(el, ZeroLengthElement): - return 2 - return 0 - - -#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`. -_BOX_TRIS = ( - (0, 1, 2), - (0, 2, 3), - (4, 5, 6), - (4, 6, 7), - (0, 1, 5), - (0, 5, 4), - (3, 2, 6), - (3, 6, 7), - (0, 3, 7), - (0, 7, 4), - (1, 2, 6), - (1, 6, 5), -) - -#: Support kind → plotly 3D marker symbol. -_SUPPORT_SYMBOLS = { - "fix": "square", - "pin": "triangle-up", - "roller": "circle", - "custom": "diamond", -} - -_NODE_MARKER_SIZE = 7.0 -_SUPPORT_MARKER_SIZE = 11.0 -_SNAP_MARKER_SIZE = 8.0 -_LABEL_FONT_SIZE = 11 - - -@dataclass(frozen=True) -class SceneOptions: - """Everything the builder needs beyond the project itself.""" - - selection_nodes: frozenset[int] = frozenset() - selection_elements: frozenset[int] = frozenset() - #: Anything exposing ``shifted(points, node_ids)`` (e.g. ``DeformationSource``). - deformation: Any = None - working_plane: tuple[str, float] | None = None - show_node_labels: bool = False - show_element_labels: bool = False - show_extrusions: bool = False - show_local_axes: bool = False - - -@dataclass -class Scene: - """A transport-ready plotly figure plus framing metadata.""" - - data: list[dict[str, Any]] - layout: dict[str, Any] - center: tuple[float, float, float] = (0.0, 0.0, 0.0) - diagonal: float = 1.0 - #: Trace index (in ``data``) of the empty hover-snap marker, or -1. - hover_trace: int = -1 - - def to_payload(self) -> dict[str, Any]: - """Figure dict without the camera — camera is owned by the widget.""" - return {"data": self.data, "layout": self.layout} - - -@dataclass -class _Mesh: - """Accumulates a triangle soup for a single ``mesh3d`` trace.""" - - x: list[float] = field(default_factory=list) - y: list[float] = field(default_factory=list) - z: list[float] = field(default_factory=list) - i: list[int] = field(default_factory=list) - j: list[int] = field(default_factory=list) - k: list[int] = field(default_factory=list) - - def add_box(self, corners: np.ndarray) -> None: - base = len(self.x) - for cx, cy, cz in corners: - self.x.append(float(cx)) - self.y.append(float(cy)) - self.z.append(float(cz)) - for a, b, c in _BOX_TRIS: - self.i.append(base + a) - self.j.append(base + b) - self.k.append(base + c) - - @property - def is_empty(self) -> bool: - return not self.x - - def as_trace(self, *, color: str, opacity: float, name: str) -> dict[str, Any]: - return { - "type": "mesh3d", - "x": self.x, - "y": self.y, - "z": self.z, - "i": self.i, - "j": self.j, - "k": self.k, - "color": color, - "opacity": opacity, - "flatshading": True, - "hoverinfo": "skip", - "name": name, - "showscale": False, - } - - -# ── small geometry helpers ──────────────────────────────────────────────── -def _diag_of_points(pts: np.ndarray | None) -> float: - if pts is None or len(pts) == 0: - return 1.0 - mn, mx = pts.min(axis=0), pts.max(axis=0) - d = float(np.linalg.norm(mx - mn)) - return d if d > 0 else 1.0 - - -def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``.""" - x = x_local / float(np.linalg.norm(x_local)) - vecxz = getattr(el, "vecxz", None) - if vecxz is not None: - try: - v = np.asarray(vecxz, dtype=float) - z_local = v - float(np.dot(v, x)) * x - n = float(np.linalg.norm(z_local)) - if n > 1e-9: - z_local /= n - y_local = np.cross(z_local, x) - m = float(np.linalg.norm(y_local)) - if m > 1e-9: - return y_local / m, z_local - except (TypeError, ValueError): - pass - z_global = np.array([0.0, 0.0, 1.0]) - y_local = np.cross(z_global, x) - if float(np.linalg.norm(y_local)) < 1e-6: - y_local = np.cross(np.array([1.0, 0.0, 0.0]), x) - y_local /= float(np.linalg.norm(y_local)) - z_local = np.cross(x, y_local) - return y_local, z_local - - -def _dof_indices(ndf: int) -> tuple[int, ...]: - if ndf == 6: - return (0, 1, 2, 3, 4, 5) - if ndf == 3: - return (0, 1, 5) - if ndf == 2: - return (0, 1) - return tuple(range(ndf)) - - -def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) -> str: - flags = [restraint[i] for i in dof_idx] - if all(flags): - return "fix" - trans_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx < 3] - rot_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx >= 3] - if trans_flags and all(trans_flags) and not any(rot_flags): - return "pin" - if sum(flags) == 1: - return "roller" - return "custom" - - -def _line_trace( - segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], - *, - color: str, - width: float, - name: str, - opacity: float = 1.0, - meta: dict[str, Any] | None = None, - customdata: list[Any] | None = None, -) -> dict[str, Any]: - """One ``scatter3d`` line trace from None-separated segment endpoints.""" - x: list[float | None] = [] - y: list[float | None] = [] - z: list[float | None] = [] - for a, b in segments: - x.extend([float(a[0]), float(b[0]), None]) - y.extend([float(a[1]), float(b[1]), None]) - z.extend([float(a[2]), float(b[2]), None]) - trace: dict[str, Any] = { - "type": "scatter3d", - "mode": "lines", - "x": x, - "y": y, - "z": z, - "line": {"color": color, "width": width}, - "opacity": opacity, - "hoverinfo": "skip", - "name": name, - "showlegend": False, - } - if meta is not None: - trace["meta"] = meta - if customdata is not None: - trace["customdata"] = customdata - return trace - - -class PlotlyTraceBuilder: - """Builds the full figure for a project.""" - - def __init__(self, style: RenderStyle | None = None) -> None: - self._style = style or RenderStyle() - - # ── public ─────────────────────────────────────────────────────── - def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene: - opts = options or SceneOptions() - if project is None: - return Scene(data=[], layout=self._layout()) - - nodes = list(project.nodes) - node_ids = [n.id for n in nodes] - points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3)) - if opts.deformation is not None and len(points): - points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float) - node_row = {nid: i for i, nid in enumerate(node_ids)} - - data: list[dict[str, Any]] = [] - grid_pts = self._build_grid(project, data, opts) - self._build_extrusions(project, data, opts) - self._build_local_axes(project, data, opts) - self._build_loads(project, data, opts) - self._build_supports(project, data, opts) - self._build_frames(project, data, opts, points, node_row) - self._build_nodes(data, opts, points, node_ids) - self._build_labels(project, data, opts, points, node_row) - hover_trace = self._build_hover_marker(data) - - candidates = [points] if len(points) else [] - if grid_pts is not None: - candidates.append(grid_pts) - all_pts = np.vstack(candidates) if candidates else np.empty((0, 3)) - center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0) - return Scene( - data=data, - layout=self._layout(), - center=(float(center[0]), float(center[1]), float(center[2])), - diagonal=_diag_of_points(all_pts), - hover_trace=hover_trace, - ) - - # ── layout ─────────────────────────────────────────────────────── - def _layout(self) -> dict[str, Any]: - style = self._style - - def axis(color: str, title: str) -> dict[str, Any]: - return { - "title": {"text": title, "font": {"color": color, "size": 12}}, - "showgrid": False, - "showbackground": False, - "zeroline": False, - "showticklabels": False, - "showspikes": False, - "visible": True, - "linecolor": color, - "linewidth": 2, - } - - return { - "paper_bgcolor": style.background_bottom, - "plot_bgcolor": style.background_bottom, - "showlegend": False, - "margin": {"l": 0, "r": 0, "t": 0, "b": 0}, - "uirevision": "otko", - "scene": { - "bgcolor": style.background_bottom, - "aspectmode": "data", - "dragmode": "orbit", - "xaxis": axis(style.fix_color, "X"), - "yaxis": axis(style.load_color, "Y"), - "zaxis": axis(style.truss_color, "Z"), - }, - } - - # ── grid ───────────────────────────────────────────────────────── - def _build_grid( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> np.ndarray | None: - coord_systems = getattr(project, "coord_systems", None) or [] - palette = [ - ((0.08, 0.08, 0.08), (0.85, 0.55, 0.00)), - ((0.20, 0.35, 0.55), (0.85, 0.55, 0.00)), - ((0.20, 0.55, 0.30), (0.85, 0.55, 0.00)), - ((0.55, 0.20, 0.40), (0.85, 0.55, 0.00)), - ((0.35, 0.20, 0.55), (0.85, 0.55, 0.00)), - ] - all_dots: list[np.ndarray] = [] - - for idx, cs in enumerate(coord_systems): - grid = cs.grid - if not grid.visible or getattr(grid, "hide_all", False): - continue - xs = list(grid.x_lines) - ys = list(grid.y_lines) - zs = list(grid.z_lines) - if not (xs or ys or zs): - continue - palette_idx = 0 if cs.is_global() else (idx % (len(palette) - 1)) + 1 - grid_color, dot_color = palette[palette_idx] - grid_hex = _rgb_to_hex(grid_color) - dot_hex = _rgb_to_hex(dot_color) - - xmin, xmax = (min(xs), max(xs)) if xs else (-1.0, 1.0) - ymin, ymax = (min(ys), max(ys)) if ys else (-1.0, 1.0) - if xmin == xmax: - xmin, xmax = xmin - 1.0, xmax + 1.0 - if ymin == ymax: - ymin, ymax = ymin - 1.0, ymax + 1.0 - - plane_axis: int | None = None - plane_offset: float | None = None - if opts.working_plane is not None: - name, off = opts.working_plane - axis_idx = {"XY": 2, "XZ": 1, "YZ": 0}[name] - plane_axis = axis_idx - plane_offset = off - cs.coord.origin[axis_idx] - - def on_plane( - local: tuple[float, float, float], - _axis: int | None = plane_axis, - _offset: float | None = plane_offset, - ) -> bool: - if _axis is None or _offset is None: - return True - return abs(local[_axis] - _offset) < 1e-6 - - active: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - dim: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - - def add_seg( - p1: tuple[float, float, float], - p2: tuple[float, float, float], - _active: list = active, - _dim: list = dim, - _on_plane: Any = on_plane, - _cs: Any = cs, - ) -> None: - on = _on_plane(p1) and _on_plane(p2) - bucket = _active if on else _dim - bucket.append((_cs.coord.local_to_world(p1), _cs.coord.local_to_world(p2))) - - z_planes = zs if zs else [0.0] - for z in z_planes: - for x in xs: - add_seg((x, ymin, z), (x, ymax, z)) - for y in ys: - add_seg((xmin, y, z), (xmax, y, z)) - if opts.working_plane is None and zs and xs and ys: - for x in xs: - for y in ys: - add_seg((x, y, zs[0]), (x, y, zs[-1])) - - if dim: - data.append(_line_trace(dim, color=grid_hex, width=1, name="grid", opacity=0.18)) - if active: - data.append(_line_trace(active, color=grid_hex, width=2, name="grid-active")) - - active_dots: list[tuple[float, float, float]] = [] - dim_dots: list[tuple[float, float, float]] = [] - for z in z_planes: - for x in xs or [0.0]: - for y in ys or [0.0]: - pt = cs.coord.local_to_world((x, y, z)) - (active_dots if on_plane((x, y, z)) else dim_dots).append(pt) - if dim_dots: - data.append(self._dot_trace(dim_dots, color="#999999", opacity=0.35)) - if active_dots: - data.append( - self._dot_trace( - active_dots, - color=dot_hex, - opacity=1.0, - meta={"kind": "snap"}, - ) - ) - all_dots.extend(np.asarray(active_dots, dtype=float)) - - return np.vstack(all_dots) if all_dots else None - - @staticmethod - def _dot_trace( - points: list[tuple[float, float, float]], - *, - color: str, - opacity: float, - meta: dict[str, Any] | None = None, - ) -> dict[str, Any]: - customdata = ( - [[float(p[0]), float(p[1]), float(p[2])] for p in points] if meta is not None else None - ) - trace: dict[str, Any] = { - "type": "scatter3d", - "mode": "markers", - "x": [float(p[0]) for p in points], - "y": [float(p[1]) for p in points], - "z": [float(p[2]) for p in points], - "marker": {"color": color, "size": _SNAP_MARKER_SIZE, "line": {"width": 0}}, - "opacity": opacity, - "hoverinfo": "skip", - "name": "snap", - "showlegend": False, - } - if meta is not None: - trace["meta"] = meta - trace["customdata"] = customdata - return trace - - # ── model entities ─────────────────────────────────────────────── - def _build_nodes( - self, - data: list[dict[str, Any]], - opts: SceneOptions, - points: np.ndarray, - node_ids: list[int], - ) -> None: - if not len(points): - return - colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids] - data.append( - { - "type": "scatter3d", - "mode": "markers", - "x": [float(p[0]) for p in points], - "y": [float(p[1]) for p in points], - "z": [float(p[2]) for p in points], - "marker": { - "color": colors, - "size": _NODE_MARKER_SIZE, - "line": {"color": "#4d4d4d", "width": 1}, - }, - "customdata": list(node_ids), - "meta": {"kind": "node"}, - "hoverinfo": "skip", - "name": "nodes", - "showlegend": False, - } - ) - - def _build_frames( - self, - project: Project, - data: list[dict[str, Any]], - opts: SceneOptions, - points: np.ndarray, - node_row: dict[int, int], - ) -> None: - """One line trace whose per-point ``line.color`` carries the palette. - - plotly accepts an *array* for ``Scatter3d.line.color`` mapped through - a ``colorscale``, so a single trace can colour every element - individually — by family and by selection — instead of one trace per - colour. That also leaves room to colour by response value later. - Each element's two endpoints (and its ``None`` separator) share one - colour index, so each drawn segment is a solid colour. - """ - if not len(points): - return - scale_colors = [ - self._style.element_beam_color, - self._style.element_truss_color, - self._style.element_link_color, - self._style.selected_color, - ] - selected_index = len(scale_colors) - 1 - - x: list[float | None] = [] - y: list[float | None] = [] - z: list[float | None] = [] - color_index: list[float] = [] - customdata: list[Any] = [] - for el in project.elements: - if not isinstance(el, _FRAME_CLASSES): - continue - i = node_row.get(el.nodes[0]) - j = node_row.get(el.nodes[1]) - if i is None or j is None: - continue - index = float(selected_index if el.id in opts.selection_elements else _family_index(el)) - x.extend([float(points[i][0]), float(points[j][0]), None]) - y.extend([float(points[i][1]), float(points[j][1]), None]) - z.extend([float(points[i][2]), float(points[j][2]), None]) - color_index.extend([index, index, index]) - customdata.extend([el.id, el.id, None]) - if not x: - return - - count = len(scale_colors) - data.append( - { - "type": "scatter3d", - "mode": "lines", - "x": x, - "y": y, - "z": z, - "line": { - "color": color_index, - "colorscale": [ - (index / (count - 1), color) for index, color in enumerate(scale_colors) - ], - "cmin": 0, - "cmax": count - 1, - "width": 4, - }, - "customdata": customdata, - "meta": {"kind": "element"}, - "hoverinfo": "skip", - "name": "elements", - "showlegend": False, - } - ) - - def _build_supports( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not project.nodes: - return - dof_idx = _dof_indices(project.ndf) - groups: dict[str, list[Any]] = {} - for node in project.nodes: - if not any(node.restraint[i] for i in dof_idx): - continue - kind = _classify_support(node.restraint, dof_idx) - groups.setdefault(kind, []).append(node) - for kind, nodes in groups.items(): - data.append( - { - "type": "scatter3d", - "mode": "markers", - "x": [float(n.coords[0]) for n in nodes], - "y": [float(n.coords[1]) for n in nodes], - "z": [float(n.coords[2]) for n in nodes], - "marker": { - "color": "#ff8019", - "size": _SUPPORT_MARKER_SIZE, - "symbol": _SUPPORT_SYMBOLS[kind], - "line": {"color": "#7f3f00", "width": 1}, - }, - "hoverinfo": "skip", - "name": f"support-{kind}", - "showlegend": False, - } - ) - - # ── loads / axes / extrusions ──────────────────────────────────── - def _build_loads( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not project.load_patterns or not project.nodes: - return - node_by_id = {n.id: n for n in project.nodes} - elem_by_id = {e.id: e for e in project.elements} - pts = np.array([n.coords for n in project.nodes], dtype=float) - scale = max(_diag_of_points(pts) * 0.05, 1e-6) - - nodal_x: list[float] = [] - nodal_y: list[float] = [] - nodal_z: list[float] = [] - nodal_u: list[float] = [] - nodal_v: list[float] = [] - nodal_w: list[float] = [] - dist_x: list[float] = [] - dist_y: list[float] = [] - dist_z: list[float] = [] - dist_u: list[float] = [] - dist_v: list[float] = [] - dist_w: list[float] = [] - - for pattern in project.load_patterns: - if not isinstance(pattern, PlainLoadPattern): - continue - for nload in pattern.nodal_loads: - if not isinstance(nload, NodalLoad): - continue - node = node_by_id.get(nload.node_id) - if node is None: - continue - f = np.asarray(nload.forces[:3], dtype=float) - mag = float(np.linalg.norm(f)) - if mag < 1e-12: - continue - direction = f / mag - tail = np.asarray(node.coords, dtype=float) - direction * scale - nodal_x.append(float(tail[0])) - nodal_y.append(float(tail[1])) - nodal_z.append(float(tail[2])) - nodal_u.append(float(direction[0])) - nodal_v.append(float(direction[1])) - nodal_w.append(float(direction[2])) - - for eload in pattern.element_loads: - if not isinstance(eload, UniformElementLoad): - continue - elem = elem_by_id.get(eload.element_id) - if elem is None: - continue - node_i = node_by_id.get(elem.nodes[0]) - node_j = node_by_id.get(elem.nodes[1]) - if node_i is None or node_j is None: - continue - pi = np.asarray(node_i.coords, dtype=float) - pj = np.asarray(node_j.coords, dtype=float) - axis = pj - pi - length = float(np.linalg.norm(axis)) - if length < 1e-9: - continue - x_local = axis / length - z_global = np.array([0.0, 0.0, 1.0]) - y_local = np.cross(z_global, x_local) - if float(np.linalg.norm(y_local)) < 1e-6: - y_local = np.cross(np.array([0.0, 1.0, 0.0]), x_local) - y_local /= float(np.linalg.norm(y_local)) - z_local = np.cross(x_local, y_local) - load_vec = eload.wx * x_local + eload.wy * y_local + eload.wz * z_local - mag = float(np.linalg.norm(load_vec)) - if mag < 1e-12: - continue - direction = load_vec / mag - n_arrows = 5 - for k in range(n_arrows): - t = (k + 0.5) / n_arrows - tail = pi + t * axis - direction * (0.4 * scale) - dist_x.append(float(tail[0])) - dist_y.append(float(tail[1])) - dist_z.append(float(tail[2])) - dist_u.append(float(direction[0])) - dist_v.append(float(direction[1])) - dist_w.append(float(direction[2])) - - if nodal_x: - data.append( - _cone_trace( - nodal_x, - nodal_y, - nodal_z, - nodal_u, - nodal_v, - nodal_w, - color="#33d933", - name="nodal-loads", - size=scale, - ) - ) - if dist_x: - data.append( - _cone_trace( - dist_x, - dist_y, - dist_z, - dist_u, - dist_v, - dist_w, - color="#ff8c33", - name="element-loads", - size=0.6 * scale, - ) - ) - - def _build_local_axes( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not opts.show_local_axes or not project.nodes: - return - node_by_id = {n.id: n for n in project.nodes} - pts = np.array([n.coords for n in project.nodes], dtype=float) - cap = max(_diag_of_points(pts) * 0.08, 1e-6) - axes: dict[str, dict[str, list[float]]] = { - "x": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, - "y": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, - "z": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, - } - for el in project.elements: - if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement): - continue - if not isinstance( - el, - ElasticBeamColumn - | DispBeamColumn - | ForceBeamColumn - | BeamWithHingesElement - | TrussElement - | CorotTrussElement, - ): - continue - node_i = node_by_id.get(el.nodes[0]) - node_j = node_by_id.get(el.nodes[1]) - if node_i is None or node_j is None: - continue - pi = np.asarray(node_i.coords, dtype=float) - pj = np.asarray(node_j.coords, dtype=float) - axis = pj - pi - length = float(np.linalg.norm(axis)) - if length < 1e-9: - continue - x_local = axis / length - y_local, z_local = _frame_basis(el, x_local) - mid = (pi + pj) / 2.0 - for key, direction in (("x", x_local), ("y", y_local), ("z", z_local)): - bucket = axes[key] - bucket["x"].append(float(mid[0])) - bucket["y"].append(float(mid[1])) - bucket["z"].append(float(mid[2])) - bucket["u"].append(float(direction[0])) - bucket["v"].append(float(direction[1])) - bucket["w"].append(float(direction[2])) - for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")): - bucket = axes[key] - if bucket["x"]: - data.append( - _cone_trace( - bucket["x"], - bucket["y"], - bucket["z"], - bucket["u"], - bucket["v"], - bucket["w"], - color=color, - name=f"local-{key}", - size=cap, - ) - ) - - def _build_extrusions( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not opts.show_extrusions: - return - from otko.services.section_bbox import bbox_for_section - - node_by_id = {n.id: n for n in project.nodes} - section_by_id = {s.id: s for s in project.sections} - mesh = _Mesh() - for el in project.elements: - if not isinstance(el, _FRAME_CLASSES): - continue - if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement): - continue - section_id = getattr(el, "section_id", None) - if section_id is None: - continue - section = section_by_id.get(section_id) - if section is None: - continue - dims = bbox_for_section(section, project) - if dims is None: - continue - w_y, h_z = dims - if w_y <= 0 or h_z <= 0: - continue - node_i = node_by_id.get(el.nodes[0]) - node_j = node_by_id.get(el.nodes[1]) - if node_i is None or node_j is None: - continue - pi = np.asarray(node_i.coords, dtype=float) - pj = np.asarray(node_j.coords, dtype=float) - axis = pj - pi - length = float(np.linalg.norm(axis)) - if length < 1e-9: - continue - x_local = axis / length - y_local, z_local = _frame_basis(el, x_local) - mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z)) - if not mesh.is_empty: - data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions")) - - @staticmethod - def _box_corners( - pi: np.ndarray, - x_local: np.ndarray, - y_local: np.ndarray, - z_local: np.ndarray, - length: float, - w_y: float, - h_z: float, - ) -> np.ndarray: - hy, hz = w_y / 2.0, h_z / 2.0 - offsets = np.array( - [ - [0.0, -hy, -hz], - [length, -hy, -hz], - [length, +hy, -hz], - [0.0, +hy, -hz], - [0.0, -hy, +hz], - [length, -hy, +hz], - [length, +hy, +hz], - [0.0, +hy, +hz], - ] - ) - basis = np.column_stack([x_local, y_local, z_local]) - return pi + offsets @ basis.T - - # ── labels / hover marker ──────────────────────────────────────── - def _build_labels( - self, - project: Project, - data: list[dict[str, Any]], - opts: SceneOptions, - points: np.ndarray, - node_row: dict[int, int], - ) -> None: - if opts.show_node_labels and len(points): - labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes] - data.append(_text_trace(points, labels, name="node-labels")) - if opts.show_element_labels and len(points): - centers: list[np.ndarray] = [] - labels: list[str] = [] - for el in project.elements: - if len(el.nodes) != 2: - continue - i = node_row.get(el.nodes[0]) - j = node_row.get(el.nodes[1]) - if i is None or j is None: - continue - centers.append((points[i] + points[j]) / 2.0) - labels.append(el.name.strip() if el.name.strip() else f"E{el.id}") - if centers: - data.append(_text_trace(np.asarray(centers), labels, name="element-labels")) - - @staticmethod - def _build_hover_marker(data: list[dict[str, Any]]) -> int: - data.append( - { - "type": "scatter3d", - "mode": "markers", - "x": [], - "y": [], - "z": [], - "marker": { - "color": "#ffd900", - "size": 13, - "line": {"color": "#8a6d00", "width": 1}, - }, - "hoverinfo": "skip", - "name": "snap-hover", - "showlegend": False, - "meta": {"kind": "hover"}, - } - ) - return len(data) - 1 - - -def _cone_trace( - x: list[float], - y: list[float], - z: list[float], - u: list[float], - v: list[float], - w: list[float], - *, - color: str, - name: str, - size: float, -) -> dict[str, Any]: - return { - "type": "cone", - "x": x, - "y": y, - "z": z, - "u": u, - "v": v, - "w": w, - "anchor": "tail", - "sizemode": "absolute", - "sizeref": float(size), - "colorscale": [[0, color], [1, color]], - "showscale": False, - "hoverinfo": "skip", - "name": name, - "showlegend": False, - } - - -def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]: - return { - "type": "scatter3d", - "mode": "text", - "x": [float(p[0]) for p in points], - "y": [float(p[1]) for p in points], - "z": [float(p[2]) for p in points], - "text": labels, - "textposition": "top center", - "textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"}, - "hoverinfo": "skip", - "name": name, - "showlegend": False, - } - - -def _rgb_to_hex(rgb: tuple[float, float, float]) -> str: - r, g, b = (int(round(v * 255)) for v in rgb) - return f"#{r:02x}{g:02x}{b:02x}" diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index cd34449..df4b70e 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -210,14 +210,6 @@ 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( @@ -268,14 +260,6 @@ 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 @@ -380,14 +364,6 @@ 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 cbd21e3..958459e 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -14,18 +14,15 @@ 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, QStackedWidget +from PySide6.QtWidgets import QMainWindow, QMessageBox 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 @@ -37,9 +34,6 @@ from otko.views.tools import ( ToolController, ) -#: Canvas backends, in menu order. -CANVAS_BACKENDS = ("pyvista", "plotly") - class MainWindow( QMainWindow, @@ -50,7 +44,7 @@ class MainWindow( ): """Top-level application shell.""" - def __init__(self, settings: QSettings | None = None) -> None: + def __init__(self) -> None: super().__init__() self.setWindowTitle("OTKO") self.setWindowIcon( @@ -66,20 +60,12 @@ class MainWindow( self._show_node_labels = False self._show_element_labels = False - # 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_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 self._build_docks() self._build_actions() @@ -91,120 +77,19 @@ class MainWindow( self._refresh_action_enablement() self.restore_layout() - # ── 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() + # ── 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) # ── layout persistence ─────────────────────────────────────────── def save_layout(self) -> None: """Persist window geometry and dock/toolbar state for next launch.""" - self._settings.setValue("geometry", self.saveGeometry()) - self._settings.setValue("windowState", self.saveState()) + settings = QSettings("OTKO", "OTKO") + settings.setValue("geometry", self.saveGeometry()) + settings.setValue("windowState", self.saveState()) def restore_layout(self) -> None: """Restore a previously saved geometry/dock layout, if any. @@ -212,10 +97,11 @@ class MainWindow( On first run no values are stored, so the defaults from ``__init__`` (``resize`` + dock construction) stay in effect. """ - geometry = self._settings.value("geometry") + settings = QSettings("OTKO", "OTKO") + geometry = settings.value("geometry") if geometry is not None: self.restoreGeometry(geometry) - state = self._settings.value("windowState") + state = settings.value("windowState") if state is not None: self.restoreState(state) @@ -275,12 +161,9 @@ 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) - # 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")) + 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) # 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 0aa0b2e..bd8af6a 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -193,41 +193,8 @@ 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. @@ -512,10 +479,6 @@ 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 63d1f0f..1d574c1 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -459,7 +459,6 @@ 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) @@ -483,11 +482,9 @@ 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) - # 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_force_diagram.setEnabled(has_static) self._act_show_time_history.setEnabled(has_transient) - self._act_export_th_animation.setEnabled(has_transient and caps.animation_export) + self._act_export_th_animation.setEnabled(has_transient) 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/conftest.py b/tests/conftest.py index c91c064..82dd86a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ domain after every test. The import is inside the teardown so that from __future__ import annotations -import importlib.util import os from collections.abc import Iterator @@ -20,27 +19,6 @@ import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -@pytest.fixture(autouse=True, scope="session") -def _isolate_qsettings(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]: - """Redirect ``QSettings`` to a temp dir so tests never touch user state. - - ``MainWindow`` persists preferences (window layout, canvas backend) under - ``QSettings("OTKO", "OTKO")``. Without redirection a developer who, say, - switched to the Plotly backend would see PyVista-specific GUI tests fail - against their real settings. Skipped entirely when Qt is not installed, so - the headless job stays free of Qt. - """ - if importlib.util.find_spec("PySide6") is None: - yield - return - from PySide6.QtCore import QSettings - - settings_dir = tmp_path_factory.mktemp("qsettings") - for fmt in (QSettings.Format.NativeFormat, QSettings.Format.IniFormat): - QSettings.setPath(fmt, QSettings.Scope.UserScope, str(settings_dir)) - yield - - @pytest.fixture(autouse=True) def _wipe_opensees_domain() -> Iterator[None]: """Wipe the OpenseesPy domain after a test if the solver was imported.""" diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py deleted file mode 100644 index ed77be1..0000000 --- a/tests/gui/test_canvas_backend_switch.py +++ /dev/null @@ -1,122 +0,0 @@ -"""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 7b3f95c..9281e67 100644 --- a/tests/unit/test_architecture.py +++ b/tests/unit/test_architecture.py @@ -2,12 +2,11 @@ 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 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. +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. """ from __future__ import annotations @@ -17,10 +16,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SRC = ROOT / "src" / "otko" -CANVAS_DIRS = ( - SRC / "views" / "canvas3d", - SRC / "views" / "canvas_plotly", -) +CANVAS = SRC / "views" / "canvas3d" # Qt binding roots, in every spelling the project might use. QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"} @@ -36,7 +32,7 @@ CORE_FORBIDDEN = QT_ROOTS | { "matplotlib", } -# Only the canvas packages may touch a 3D or plotting backend. +# Only views/canvas3d/ may touch a 3D or plotting backend. VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"} @@ -79,14 +75,14 @@ def _violations( forbidden: set[str], *, skip: tuple[Path, ...] = (), - exclude_dirs: tuple[Path, ...] = (), + exclude_dir: Path | None = None, ) -> 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 any(_is_within(path, directory) for directory in exclude_dirs): + if exclude_dir is not None and _is_within(path, exclude_dir): continue for module in sorted(_root_imports(path) & forbidden): found.append(f"{path.relative_to(ROOT)}: {module}") @@ -116,9 +112,9 @@ def test_architecture_views_no_solver() -> None: def test_no_vtk_import_outside_canvas() -> None: - """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}" + """3D/plot backends stay under views/canvas3d/.""" + violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS) + assert violations == [], f"backend imports outside views/canvas3d/: {violations}" # The documented dependency stack; ``commands`` sits beside viewmodels (a VM diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py deleted file mode 100644 index ae960a9..0000000 --- a/tests/unit/test_plotly_trace_builder.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Unit tests for the pure Plotly trace builder (no Qt, no plotly import).""" - -from __future__ import annotations - -import json -from pathlib import Path - -import numpy as np -import pytest - -from otko.services import load_project -from otko.views.canvas3d.style import RenderStyle -from otko.views.canvas_plotly.trace_builder import ( - PlotlyTraceBuilder, - SceneOptions, -) - -EXAMPLES = Path(__file__).resolve().parents[2] / "examples" - - -def _load(name: str): # type: ignore[no-untyped-def] - return load_project(EXAMPLES / f"{name}.osmodel") - - -def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def] - return [trace for trace in scene.data if trace.get("name") == name] - - -def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def] - return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data] - - -def test_builds_grid_nodes_and_frames() -> None: - scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) - names = {trace.get("name") for trace in scene.data} - assert "grid-active" in names - assert "nodes" in names - assert "elements" in names - assert scene.diagonal > 0 - # The hover-snap marker is always present so JS can restyle it. - assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover" - - -def test_nodes_carry_ids_as_customdata() -> None: - project = _load("basic_truss") - scene = PlotlyTraceBuilder().build(project, SceneOptions()) - (nodes,) = _traces(scene, "nodes") - assert nodes["meta"]["kind"] == "node" - assert nodes["customdata"] == [node.id for node in project.nodes] - assert len(nodes["marker"]["color"]) == len(project.nodes) - - -def _color_for_index(trace: dict, index: float) -> str: - """Map a ``line.color`` scalar index to its colour via the colorscale.""" - scale = sorted(trace["line"]["colorscale"], key=lambda item: item[0]) - return scale[int(index)][1] - - -def test_frames_are_one_trace_with_per_element_colours() -> None: - """A single line trace carries the palette through its ``line.color`` array.""" - project = _load("cantilever") - style = RenderStyle() - scene = PlotlyTraceBuilder(style).build( - project, SceneOptions(selection_elements=frozenset({2})) - ) - frames = _traces(scene, "elements") - assert len(frames) == 1, "selection must not split the frame trace" - line = frames[0]["line"] - assert isinstance(line["color"], list) - assert len(line["color"]) == len(frames[0]["x"]) - assert line["colorscale"] - - colors = line["color"] - customdata = frames[0]["customdata"] - - def index_of(element_id: int) -> set[float]: - return {colors[k] for k, value in enumerate(customdata) if value == element_id} - - selected = index_of(2) - unselected = index_of(1) - assert len(selected) == 1 and len(unselected) == 1 - assert selected != unselected - assert _color_for_index(frames[0], next(iter(selected))) == style.selected_color - assert _color_for_index(frames[0], next(iter(unselected))) == style.element_beam_color - - -def test_element_palette_distinguishes_beam_from_truss() -> None: - style = RenderStyle() - builder = PlotlyTraceBuilder(style) - beam = _traces(builder.build(_load("cantilever"), SceneOptions()), "elements")[0] - truss = _traces(builder.build(_load("basic_truss"), SceneOptions()), "elements")[0] - assert _color_for_index(beam, beam["line"]["color"][0]) == style.element_beam_color - assert _color_for_index(truss, truss["line"]["color"][0]) == style.element_truss_color - - -def test_selected_node_is_highlighted_by_colour() -> None: - project = _load("basic_truss") - target = project.nodes[1].id - scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_nodes=frozenset({target}))) - (nodes,) = _traces(scene, "nodes") - ids = nodes["customdata"] - assert nodes["marker"]["color"][ids.index(target)] != nodes["marker"]["color"][0] - - -def test_working_plane_filters_grid_segments() -> None: - project = _load("basic_truss") - builder = PlotlyTraceBuilder() - iso = builder.build(project, SceneOptions()) - plane = builder.build(project, SceneOptions(working_plane=("XY", 0.0))) - (iso_grid,) = _traces(iso, "grid-active") - (plane_grid,) = _traces(plane, "grid-active") - assert len(plane_grid["x"]) <= len(iso_grid["x"]) - - -def test_snap_targets_carry_world_coordinates() -> None: - scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) - (snap,) = _traces(scene, "snap") - assert snap["meta"]["kind"] == "snap" - assert snap["customdata"], "expected at least one snappable intersection" - assert all(len(point) == 3 for point in snap["customdata"]) - assert all(isinstance(coord, float) for point in snap["customdata"] for coord in point) - - -def test_extrusions_add_a_mesh3d_trace() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions(show_extrusions=True)) - (mesh,) = _traces(scene, "extrusions") - assert mesh["type"] == "mesh3d" - assert mesh["i"] and mesh["j"] and mesh["k"] - assert max(mesh["i"]) < len(mesh["x"]) - - -def test_local_axes_add_coloured_cones() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions(show_local_axes=True)) - names = {trace.get("name") for trace in scene.data} - assert {"local-x", "local-y", "local-z"} <= names - for trace in scene.data: - if trace.get("name", "").startswith("local-"): - assert trace["type"] == "cone" - assert len(trace["u"]) == len(trace["x"]) > 0 - - -def test_labels_traces_are_text_only() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build( - project, SceneOptions(show_node_labels=True, show_element_labels=True) - ) - (node_labels,) = _traces(scene, "node-labels") - (element_labels,) = _traces(scene, "element-labels") - assert node_labels["mode"] == "text" - assert len(node_labels["text"]) == len(project.nodes) - assert element_labels["mode"] == "text" - - -def test_deformation_shifts_node_coordinates() -> None: - project = _load("cantilever") - - class _Shift: - def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray: - out = points.copy() - out[:, 2] += 1.0 - return out - - base = PlotlyTraceBuilder().build(project, SceneOptions()) - moved = PlotlyTraceBuilder().build(project, SceneOptions(deformation=_Shift())) - (base_nodes,) = _traces(base, "nodes") - (moved_nodes,) = _traces(moved, "nodes") - assert moved_nodes["z"] == pytest.approx([z + 1.0 for z in base_nodes["z"]]) - - -def test_payload_is_json_serialisable() -> None: - scene = PlotlyTraceBuilder().build( - _load("cantilever"), - SceneOptions( - selection_nodes=frozenset({1}), - selection_elements=frozenset({1}), - working_plane=("XY", 0.0), - show_extrusions=True, - show_local_axes=True, - show_node_labels=True, - show_element_labels=True, - ), - ) - payload = json.dumps(scene.to_payload()) - assert '"data"' in payload and '"layout"' in payload - assert scene.layout["scene"]["aspectmode"] == "data" - - -def test_empty_project_yields_only_the_hover_marker() -> None: - scene = PlotlyTraceBuilder().build(None, SceneOptions()) - assert scene.data == [] - assert scene.hover_trace == -1 - - from otko.core import Project - - empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions()) - assert _kinds(empty) == ["hover"] - - -def test_frame_trace_meta_marks_elements_pickable() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions()) - (frames,) = _traces(scene, "elements") - assert frames["meta"]["kind"] == "element" - # None separators break the line into per-element segments. - assert None in frames["x"] - assert len(frames["customdata"]) == len(frames["x"])