Compare commits

...
Author SHA1 Message Date
17d2ed21d3 feat: opstool-derived element palette and single-trace frame colours
Ports opstool's per-family element colours and its diverging response
scale; RenderStyle.response_scale_colors now also drives the PyVista
force-diagram colouring instead of a hard-coded "coolwarm".

The frame renderer drops the two-trace normal/selected workaround: my
earlier assumption that plotly cannot colour segments individually was
wrong. Scatter3d.line.color accepts an array mapped through a colorscale,
so one trace now carries per-element colours (family + selection) and is
ready to be coloured by response value later.

Attribution recorded in NOTICE per GPLv3 section 5(a)/(b).
2026-09-16 19:15:55 -04:00
40a673c562 test: isolate QSettings so user preferences cannot fail GUI tests
MainWindow persists preferences (window layout, canvas backend) through
QSettings("OTKO", "OTKO"), so tests constructing it were reading the
developer's real settings: after switching to the Plotly backend the
PyVista-specific viewport-axis tests failed with "PlotlyCanvas has no
attribute renderer". A session fixture now redirects QSettings into a
temp dir and is skipped when Qt is not installed, keeping the headless
job Qt-free.
2026-09-16 19:15:48 -04:00
d09e72e95d chore: clone opstool for visualization reference
opstool v1.0.26 (GPL-3.0) is cloned read-only under
.slim/clonedeps/repos/ to inspect its PyVista/Plotly visualization
settings. GPLv3 section 13 permits combining it with this AGPL-3.0
project. The clone itself is git-ignored; the manifest and the AGENTS.md
pointer are committed.
2026-09-16 19:15:48 -04:00
9a1baa07c2 feat: live canvas backend switching (Options → Canvas Backend)
Both backends share one SelectionState owned by MainWindow and live
side by side in a QStackedWidget — switching is setCurrentWidget, so
no widget is destroyed mid-session (tearing a VTK window down leaves
dangling make-current callbacks). The choice persists in QSettings.
CanvasCapabilities declares per-backend gaps (force diagrams and
video export stay PyVista-only, both are documented and greyed out)
and the UI gates on capabilities rather than the backend name. The
architecture gate now allows the canvas_plotly package.
2026-09-16 18:43:59 -04:00
804bf22f61 feat: plotly.js canvas widget (WebEngine + QWebChannel)
PlotlyCanvas hosts plotly.js in a QWebEngineView driven over
QWebChannel: figures update with Plotly.react (the camera survives
unless a view preset asks for it), and clicks round-trip as
node/element picks or grid-snap clicks. Assets are written to a temp
dir and loaded from file:// because the ~5 MB bundle is past
setHtml's data-URL limit. render() forwards QWidget's overload so
grab() and painting keep working.
2026-09-16 18:43:59 -04:00
fc5971ad3d feat: pure Plotly trace builder for the 3D canvas
Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so
it is unit-tested in the headless job. Mirrors the PyVista renderer's
geometry (grid, nodes, frames, supports, loads, extrusions, local
axes, labels, deformation) while respecting plotly.js's medium:
pixel-sized markers, None-separated line segments, selection as a
second trace (per-segment line colours are impossible), cone traces
for arrows. Pickable traces carry meta.kind + customdata.
2026-09-16 18:43:53 -04:00
f99b3a3efd build: add plotly to the GUI extra
Supplies the offline plotly.js bundle for the new canvas backend. The
GUI CI job now installs the gui extra so the Qt/WebEngine tests run
instead of being import-skipped.
2026-09-16 18:43:53 -04:00
24 changed files with 2317 additions and 41 deletions

View file

@ -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 ".[dev]"
- run: pip install -e ".[gui,dev]"
- run: xvfb-run -a pytest tests/gui -m "not slow"
test-integration:

4
.gitignore vendored
View file

@ -61,3 +61,7 @@ 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

9
.ignore Normal file
View file

@ -0,0 +1,9 @@
# 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

15
.slim/clonedeps.json Normal file
View file

@ -0,0 +1,15 @@
{
"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)."
}
]
}

View file

@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
## Install
```bash
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
python -m otko # launch GUI (src/otko/app.py:run)
```
@ -19,6 +19,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
- `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.**
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless.
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
@ -56,3 +57,17 @@ 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.

15
NOTICE
View file

@ -45,6 +45,21 @@ 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

View file

@ -42,6 +42,7 @@ gui = [
"vtk>=9.3",
"pyqtgraph>=0.13",
"imageio[ffmpeg]>=2.34",
"plotly>=5.18",
]
dev = [
"pytest>=7.4",

View file

@ -31,6 +31,7 @@ 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")
@ -62,8 +63,9 @@ class DiagramRenderer:
- ``clear()`` removes the overlay.
"""
def __init__(self, plotter: Any) -> None:
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
self._actor: Any = None
self._label_actor: Any = None
@ -158,7 +160,9 @@ class DiagramRenderer:
self._actor = self._plotter.add_mesh(
mesh,
scalars="value",
cmap="coolwarm",
# Shared response palette (opstool's default diverging scale), so
# the PyVista and Plotly backends stay visually consistent.
cmap=list(self._style.response_scale_colors),
clim=(-vmax, vmax),
show_scalar_bar=True,
scalar_bar_args={"title": comp_label, "n_labels": 5},

View file

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

View file

@ -27,6 +27,31 @@ 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"
@ -49,3 +74,13 @@ 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)
]

View file

@ -0,0 +1,92 @@
"""Backend-agnostic contract for the central 3D canvas.
Two backends implement it:
- :class:`otko.views.canvas3d.ModelCanvas` — PyVista/VTK (native OpenGL, default)
- :class:`otko.views.canvas_plotly.PlotlyCanvas` — plotly.js in a QWebEngineView
The ``MainWindow`` swaps between them at runtime, so both must expose the same
public surface (signals, selection, working plane, view presets, display
toggles) and declare what optional overlay layers they support via
:class:`CanvasCapabilities`.
"""
from __future__ import annotations
import importlib.util
from dataclasses import dataclass
from typing import Any, Protocol
def plotly_available() -> bool:
"""True when the optional ``plotly`` package can be imported.
The Plotly backend needs plotly.js (shipped inside the ``plotly`` wheel)
to render; without it the backend menu entry is disabled.
"""
return importlib.util.find_spec("plotly") is not None
@dataclass(frozen=True)
class CanvasCapabilities:
"""Optional overlay layers a backend may or may not implement.
Consumers query these (see ``RenderControls._refresh_action_enablement``)
instead of assuming a feature works on every backend.
"""
#: Force-diagram ribbon overlay (N/V/M diagrams).
diagrams: bool = True
#: SAP2000-style section extrusion overlay.
extrusions: bool = True
#: Per-element local-axis triad overlay.
local_axes: bool = True
#: Node / element text labels.
labels: bool = True
#: Off-screen frame capture (mode-shape / time-history video export).
animation_export: bool = True
class CanvasBackend(Protocol):
"""Structural contract implemented by every canvas backend.
This mirrors :class:`ModelCanvas`; it exists so the swap path and the
consumers have one written-down interface to code against.
"""
capabilities: CanvasCapabilities
selection: Any
def show_project(self, project: Any) -> None: ...
def clear_model(self) -> None: ...
def render(self) -> None: ...
def reset_camera(self) -> None: ...
def view_isometric(self) -> None: ...
def view_xy(self) -> None: ...
def view_xz(self) -> None: ...
def view_yz(self) -> None: ...
def set_working_plane(self, plane: str, offset: float) -> None: ...
def clear_working_plane(self) -> None: ...
def working_plane_type(self) -> str | None: ...
def working_plane_offset(self) -> float | None: ...
def set_snap_preview_enabled(self, enabled: bool) -> None: ...
def set_show_section_extrusions(self, enabled: bool) -> None: ...
def set_show_local_axes(self, enabled: bool) -> None: ...
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ...
def set_default_selection_enabled(self, enabled: bool) -> None: ...

View file

@ -0,0 +1,26 @@
"""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}")

View file

@ -0,0 +1,28 @@
"""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))

View file

@ -0,0 +1,175 @@
"""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 = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
#plot { width: 100%; height: 100%; }
.modebar { display: none !important; }
</style>
</head>
<body>
<div id="plot"></div>
<script src="plotly.min.js"></script>
<script>
(function () {
var bridge = null;
var snapEnabled = false;
var handlersReady = false;
var hoverIndex = -1;
var config = {
responsive: true,
displayModeBar: false,
scrollZoom: true,
doubleClick: false,
displaylogo: false
};
new QWebChannel(qt.webChannelTransport, function (channel) {
bridge = channel.objects.otkoBridge;
window.otkoBridge = bridge;
});
function kindOf(point) {
var data = point && point.data;
return data && data.meta ? data.meta.kind : null;
}
function findHover() {
var gd = document.getElementById('plot');
hoverIndex = -1;
if (!gd || !gd.data) return;
for (var i = 0; i < gd.data.length; i++) {
var meta = gd.data[i] && gd.data[i].meta;
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
}
}
function setHover(x, y, z) {
if (hoverIndex < 0) return;
Plotly.restyle('plot', { x: [[x]], y: [[y]], z: [[z]] }, [hoverIndex]);
}
function clearHover() {
if (hoverIndex < 0) return;
Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]);
}
function installHandlers() {
if (handlersReady) return;
var gd = document.getElementById('plot');
if (!gd || !gd.on) return;
handlersReady = true;
gd.on('plotly_click', function (ev) {
var pts = ev.points || [];
if (!pts.length || !bridge) return;
var p = pts[0];
var kind = kindOf(p);
var additive = !!(ev.event && (ev.event.shiftKey || ev.event.ctrlKey || ev.event.metaKey));
if (kind === 'node' || kind === 'element') {
bridge.onPicked(kind, p.customdata, additive);
} else if (kind === 'snap') {
var c = p.customdata;
bridge.onSnapClicked(c[0], c[1], c[2]);
}
});
gd.on('plotly_hover', function (ev) {
if (!snapEnabled) return;
var pts = ev.points || [];
if (!pts.length) return;
var p = pts[0];
if (kindOf(p) !== 'snap') { clearHover(); return; }
var c = p.customdata;
setHover(c[0], c[1], c[2]);
});
gd.on('plotly_unhover', function () { clearHover(); });
}
window.otkoUpdate = function (payloadJson) {
var fig = JSON.parse(payloadJson);
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
findHover();
installHandlers();
});
};
window.otkoSetCamera = function (cameraJson) {
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
};
window.otkoSetSnapEnabled = function (on) {
snapEnabled = !!on;
if (!snapEnabled) clearHover();
};
})();
</script>
</body>
</html>
"""
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()

View file

@ -0,0 +1,353 @@
"""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"

View file

@ -0,0 +1,963 @@
"""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}"

View file

@ -210,6 +210,14 @@ class DockManager:
because the off-screen renderer must be touched from the GUI
thread (VTK's Qt-backed render window isn't thread-safe).
"""
if not self._canvas.capabilities.animation_export:
QMessageBox.information(
self,
"Export Mode Shape Animation",
"Animation export needs the PyVista backend "
"(Options → Canvas Backend → PyVista (Native)).",
)
return
from PySide6.QtWidgets import QFileDialog
path, _sel = QFileDialog.getSaveFileName(
@ -260,6 +268,14 @@ class DockManager:
"Run a Static analysis first; force diagrams visualise its element-force output.",
)
return
if not self._canvas.capabilities.diagrams:
QMessageBox.information(
self,
"Force Diagram",
"Force diagrams are not available on the Plotly backend yet.\n\n"
"Switch back via Options → Canvas Backend → PyVista (Native).",
)
return
self._tear_down_post_dock()
# Pick the component with the largest abs_max as the initial choice
@ -364,6 +380,14 @@ class DockManager:
"Run a Transient (time-history) analysis first.",
)
return
if not self._canvas.capabilities.animation_export:
QMessageBox.information(
self,
"Export Time-History Animation",
"Animation export needs the PyVista backend "
"(Options → Canvas Backend → PyVista (Native)).",
)
return
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QInputDialog

View file

@ -14,15 +14,18 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
from __future__ import annotations
from pathlib import Path
from typing import Any
from PySide6.QtCore import QSettings
from PySide6.QtGui import QCloseEvent, QIcon
from PySide6.QtWidgets import QMainWindow, QMessageBox
from PySide6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers
from otko.views.canvas3d import ModelCanvas
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas_base import plotly_available
from otko.views.dock_manager import DockManager
from otko.views.menu_builder import MenuBuilder
from otko.views.render_controls import RenderControls
@ -34,6 +37,9 @@ from otko.views.tools import (
ToolController,
)
#: Canvas backends, in menu order.
CANVAS_BACKENDS = ("pyvista", "plotly")
class MainWindow(
QMainWindow,
@ -44,7 +50,7 @@ class MainWindow(
):
"""Top-level application shell."""
def __init__(self) -> None:
def __init__(self, settings: QSettings | None = None) -> None:
super().__init__()
self.setWindowTitle("OTKO")
self.setWindowIcon(
@ -60,12 +66,20 @@ class MainWindow(
self._show_node_labels = False
self._show_element_labels = False
self._build_central_canvas()
self._tool_controller = ToolController(self._canvas, self._vm, self)
self._select_tool = SelectTool(self._canvas, self._vm, self)
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
self._draw_node_tool: DrawNodeTool | None = None
self._draw_truss_tool: DrawTrussTool | None = None
# One selection state shared by every canvas backend so a live swap
# keeps the current selection (canvas, tree, table, properties dock).
self._selection = SelectionState(self)
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
self._canvas_backend = self._stored_canvas_backend()
self._canvases: dict[str, Any] = {}
self._diagram_renderers: dict[str, DiagramRenderer] = {}
# A stack holds every backend's canvas: swapping is just
# setCurrentWidget, so no backend widget is ever destroyed mid-session.
self._canvas_stack = QStackedWidget(self)
self.setCentralWidget(self._canvas_stack)
self._activate_canvas(self._canvas_backend)
self._build_tooling()
self._build_docks()
self._build_actions()
@ -77,19 +91,120 @@ class MainWindow(
self._refresh_action_enablement()
self.restore_layout()
# ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None:
self._canvas = ModelCanvas(self)
self.setCentralWidget(self._canvas)
# 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)

View file

@ -193,8 +193,41 @@ class MenuBuilder:
self._act_quick_guide = QAction("&Quick Guide", self)
self._act_set_units = QAction("Set Display &Units…", self)
# Canvas backend — exclusive radio pair live-switching the 3D view.
from otko.views.canvas_base import plotly_available
self._canvas_backend_group = QActionGroup(self)
self._canvas_backend_group.setExclusive(True)
self._act_canvas_pyvista = QAction("&PyVista (Native)", self, checkable=True)
self._act_canvas_pyvista.setToolTip(
"Native VTK/OpenGL viewport — fastest for large models."
)
self._act_canvas_plotly = QAction("&Plotly (WebGL)", self, checkable=True)
self._act_canvas_plotly.setToolTip(
"plotly.js rendered in a WebEngine view — richer graphics, higher overhead."
)
self._canvas_backend_group.addAction(self._act_canvas_pyvista)
self._canvas_backend_group.addAction(self._act_canvas_plotly)
if not plotly_available():
self._act_canvas_plotly.setEnabled(False)
self._act_canvas_plotly.setToolTip(
'Install the optional GUI extra (pip install -e ".[gui]") to enable plotly.'
)
self._apply_toolbar_icons()
def _sync_canvas_backend_actions(self) -> None:
"""Mirror the active canvas backend onto the Options radio pair."""
active = getattr(
self,
"_act_canvas_plotly" if self._canvas_backend == "plotly" else "_act_canvas_pyvista",
)
self._canvas_backend_group.blockSignals(True)
try:
active.setChecked(True)
finally:
self._canvas_backend_group.blockSignals(False)
def _apply_toolbar_icons(self) -> None:
"""Assign icons + self-documenting tips to every toolbar action.
@ -479,6 +512,10 @@ class MenuBuilder:
m_options = mb.addMenu("&Options")
m_options.addAction(self._act_set_units)
m_backend = m_options.addMenu("Canvas &Backend")
m_backend.addAction(self._act_canvas_pyvista)
m_backend.addAction(self._act_canvas_plotly)
self._sync_canvas_backend_actions()
m_help = mb.addMenu("&Help")
m_help.addAction(self._act_quick_guide)

View file

@ -459,6 +459,7 @@ class RenderControls:
has_pushover = isinstance(self._latest_results, PushoverResults)
has_rs = isinstance(self._latest_results, ResponseSpectrumResults)
n_sel = len(self._canvas.selection.nodes)
caps = self._canvas.capabilities
self._act_save.setEnabled(has_project)
self._act_save_as.setEnabled(has_project)
self._act_export_opspy.setEnabled(has_project)
@ -482,9 +483,11 @@ class RenderControls:
self._act_tool_draw_truss.setEnabled(has_project)
self._act_show_deformed.setEnabled(has_static)
self._act_show_mode_shape.setEnabled(has_modal)
self._act_show_force_diagram.setEnabled(has_static)
# Force diagrams + video export are backend capabilities, not just
# data availability (Plotly cannot do either yet).
self._act_show_force_diagram.setEnabled(has_static and caps.diagrams)
self._act_show_time_history.setEnabled(has_transient)
self._act_export_th_animation.setEnabled(has_transient)
self._act_export_th_animation.setEnabled(has_transient and caps.animation_export)
self._act_show_hysteresis.setEnabled(has_transient)
self._act_show_pushover.setEnabled(has_pushover)
self._act_show_response_spectrum.setEnabled(has_rs)

View file

@ -11,6 +11,7 @@ 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
@ -19,6 +20,27 @@ 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."""

View file

@ -0,0 +1,122 @@
"""GUI tests for the live canvas-backend switch (PyVista ⇄ Plotly).
QSettings are injected per-test so a swap never leaks into the real user
configuration or the next test.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6")
pytest.importorskip("pyvistaqt")
pytest.importorskip("plotly")
from PySide6.QtCore import QSettings
def _make_window(tmp_path, qtbot): # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
window = MainWindow(settings=settings)
qtbot.addWidget(window)
return window
@pytest.mark.gui
def test_default_backend_is_pyvista(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d import ModelCanvas
window = _make_window(tmp_path, qtbot)
assert window.canvas_backend() == "pyvista"
assert isinstance(window._canvas, ModelCanvas)
assert window._diagram_renderer is not None
@pytest.mark.gui
def test_switch_to_plotly_preserves_selection(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas_plotly import PlotlyCanvas
window = _make_window(tmp_path, qtbot)
window._vm.new_project()
window._canvas.selection.select_node(2)
window.swap_canvas_backend("plotly")
assert window.canvas_backend() == "plotly"
assert isinstance(window._canvas, PlotlyCanvas)
# One shared SelectionState: the tree/table/properties stay bound.
assert window._canvas.selection is window._selection
assert window._canvas.selection.nodes == frozenset({2})
# Both canvases live in the stack; the PyVista one was not destroyed.
assert window._canvas_stack.count() == 2
@pytest.mark.gui
def test_plotly_gates_pyvista_only_features(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _make_window(tmp_path, qtbot)
assert window._canvas.capabilities.diagrams is True
window.swap_canvas_backend("plotly")
caps = window._canvas.capabilities
assert caps.diagrams is False
assert caps.animation_export is False
assert caps.extrusions is True and caps.labels is True
window._refresh_action_enablement()
assert window._act_show_force_diagram.isEnabled() is False
assert window._act_export_th_animation.isEnabled() is False
@pytest.mark.gui
def test_swapping_back_reuses_the_same_canvases(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _make_window(tmp_path, qtbot)
pyvista_canvas = window._canvas
window.swap_canvas_backend("plotly")
plotly_canvas = window._canvas
window.swap_canvas_backend("pyvista")
assert window._canvas is pyvista_canvas
assert window._diagram_renderer is not None
# No duplicate widgets accumulate across repeated swaps.
window.swap_canvas_backend("plotly")
assert window._canvas is plotly_canvas
assert window._diagram_renderer is None # diagrams are PyVista-only
assert window._canvas_stack.count() == 2
@pytest.mark.gui
def test_backend_choice_is_persisted(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _make_window(tmp_path, qtbot)
window.swap_canvas_backend("plotly")
assert window._settings.value("canvas/backend") == "plotly"
# A fresh window with the same settings boots straight into Plotly.
from otko.views.canvas_plotly import PlotlyCanvas
from otko.views.main_window import MainWindow
settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
second = MainWindow(settings=settings)
qtbot.addWidget(second)
assert second.canvas_backend() == "plotly"
assert isinstance(second._canvas, PlotlyCanvas)
@pytest.mark.gui
def test_working_plane_survives_the_swap(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _make_window(tmp_path, qtbot)
window._vm.open(_example("basic_truss"))
window._canvas.set_working_plane("XY", 0.0)
window.swap_canvas_backend("plotly")
assert window._canvas.working_plane_type() == "XY"
assert window._canvas.working_plane_offset() == 0.0
def _example(name: str) -> str:
from pathlib import Path
return str(Path(__file__).resolve().parents[2] / "examples" / f"{name}.osmodel")

View file

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

View file

@ -0,0 +1,208 @@
"""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"])