diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0a7165..2c834e6 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 ".[dev]"
+ - run: pip install -e ".[gui,dev]"
- run: xvfb-run -a pytest tests/gui -m "not slow"
test-integration:
diff --git a/.gitignore b/.gitignore
index c82640e..eac3bc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.ignore b/.ignore
new file mode 100644
index 0000000..32dbf4f
--- /dev/null
+++ b/.ignore
@@ -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
diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json
new file mode 100644
index 0000000..ddddcab
--- /dev/null
+++ b/.slim/clonedeps.json
@@ -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)."
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
index bdc5f2e..babac38 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
## Install
```bash
-pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools
+pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
python -m otko # launch GUI (src/otko/app.py:run)
```
@@ -19,6 +19,8 @@ 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. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`.
+- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
- `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 +58,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.
+
diff --git a/NOTICE b/NOTICE
index 10a928c..24b2dd1 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index ffab17d..d67c45f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ 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 05c5f2c..57c0a70 100644
--- a/src/otko/views/canvas3d/diagram_renderer.py
+++ b/src/otko/views/canvas3d/diagram_renderer.py
@@ -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},
diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py
index b1b3417..3e00425 100644
--- a/src/otko/views/canvas3d/model_canvas.py
+++ b/src/otko/views/canvas3d/model_canvas.py
@@ -24,6 +24,7 @@ from otko.core import Project
from otko.views.canvas3d.model_renderer import ModelRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
+from otko.views.canvas_base import CanvasCapabilities
PICK_DEBUG = False
@@ -31,6 +32,9 @@ PICK_DEBUG = False
class ModelCanvas(QtInteractor): # type: ignore[misc]
"""The central 3D viewport widget."""
+ #: VTK supports every optional overlay layer.
+ capabilities = CanvasCapabilities()
+
# Convenience signals re-emitted from SelectionState.
nodePicked = Signal(int)
elementPicked = Signal(int)
@@ -338,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._renderer.set_show_local_axes(enabled)
self.render()
+ def set_style(self, style: RenderStyle) -> None:
+ """Swap the visual style and repaint the scene.
+
+ Applies the background immediately (it lives outside the renderer)
+ and rebuilds the actors whose colours come from the style LUTs.
+ """
+ self._style = style
+ self.set_background(style.background_bottom, top=style.background_top)
+ self._renderer.set_style(style)
+ self.render()
+
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
"""Toggle viewport labels such as node and element names."""
self._renderer.set_display_options(
diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py
index 9eb9274..12c88cb 100644
--- a/src/otko/views/canvas3d/model_renderer.py
+++ b/src/otko/views/canvas3d/model_renderer.py
@@ -13,7 +13,7 @@ from __future__ import annotations
import contextlib
import enum
from dataclasses import dataclass
-from typing import Any, ClassVar
+from typing import Any
import numpy as np
import pyvista as pv
@@ -33,7 +33,12 @@ from otko.core import (
ZeroLengthElement,
ZeroLengthSectionElement,
)
-from otko.views.canvas3d.style import RenderStyle
+from otko.views.canvas3d.style import (
+ SELECTED_STATE,
+ RenderStyle,
+ element_family_index,
+ family_palette,
+)
class RendererMode(enum.Enum):
@@ -185,9 +190,6 @@ class ModelRenderer:
r, g, b = (int(round(x * 255)) for x in rgb)
return f"#{r:02x}{g:02x}{b:02x}"
- _NODE_LUT: ClassVar[list[str]] = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected
- _FRAME_LUT: ClassVar[list[str]] = ["#338cd9", "#00ffff"] # blue normal, cyan selected
-
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
@@ -206,6 +208,8 @@ class ModelRenderer:
self._frame_actor: Any = None
self._frame_ids_ordered: list[int] = []
self._frame_id_to_row: dict[int, int] = {}
+ #: Per-frame palette index (family), parallel to ``_frame_ids_ordered``.
+ self._frame_family: list[int] = []
self._node_label_actor: Any = None
self._element_label_actor: Any = None
self._show_node_labels: bool = False
@@ -278,6 +282,16 @@ class ModelRenderer:
if self._project is not None:
self.render(self._project)
+ def set_style(self, style: RenderStyle) -> None:
+ """Swap the visual style and rebuild the scene it colours.
+
+ Colours are baked into actor LUTs at build time, so a style change
+ needs a full re-render rather than a scalar-array tweak.
+ """
+ self._style = style
+ if self._project is not None:
+ self.render(self._project)
+
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
"""Update the working-plane filter and rebuild the grid overlay.
@@ -306,11 +320,12 @@ class ModelRenderer:
self._reglyph_nodes()
if self._frame_pd is not None and self._frame_ids_ordered:
- states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8)
+ # Restore each cell to its family slot, then flag the selected ones.
+ states = np.array(self._frame_family, dtype=np.int8)
for eid in element_ids:
row = self._frame_id_to_row.get(eid)
if row is not None:
- states[row] = 1
+ states[row] = SELECTED_STATE
self._frame_pd.cell_data["_oss_state"] = states
self._frame_pd.Modified()
@@ -379,7 +394,7 @@ class ModelRenderer:
self._node_actor = self._plotter.add_mesh(
glyph,
scalars="_oss_state",
- cmap=self._NODE_LUT,
+ cmap=[self._style.node_color, self._style.node_selected_color],
clim=[0, 1],
show_scalar_bar=False,
pickable=True,
@@ -392,6 +407,7 @@ class ModelRenderer:
return
cells: list[int] = []
ids: list[int] = []
+ families: list[int] = []
for el in frames:
try:
i = self._node_id_to_row[el.nodes[0]]
@@ -400,6 +416,7 @@ class ModelRenderer:
continue
cells.extend([2, i, j])
ids.append(el.id)
+ families.append(element_family_index(el))
if not ids:
return
pd = pv.PolyData()
@@ -407,16 +424,18 @@ class ModelRenderer:
pd.lines = np.array(cells, dtype=np.int64)
pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64)
pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object)
- pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8)
+ # Cell scalar carries the palette slot (family, or SELECTED_STATE).
+ pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8)
self._frame_pd = pd
self._frame_ids_ordered = ids
self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)}
+ self._frame_family = families
self._frame_actor = self._plotter.add_mesh(
pd,
scalars="_oss_state",
- cmap=self._FRAME_LUT,
- clim=[0, 1],
+ cmap=family_palette(self._style),
+ clim=[0, SELECTED_STATE],
show_scalar_bar=False,
line_width=3.0,
pickable=True,
@@ -761,8 +780,8 @@ class ModelRenderer:
"""Add one semi-transparent steel-blue non-pickable aux actor."""
actor = self._plotter.add_mesh(
mesh,
- color=(0.35, 0.60, 0.85), # cool steel-blue
- opacity=0.22,
+ color=self._style.extrusion_color,
+ opacity=self._style.extrusion_opacity,
show_edges=True,
edge_color=(0.15, 0.25, 0.45),
line_width=1.0,
@@ -982,7 +1001,7 @@ class ModelRenderer:
ndf = project.ndf
dof_idx = _dof_indices(ndf)
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
- support_color = (1.0, 0.5, 0.1)
+ support_color = self._style.support_color
for node in project.nodes:
if not any(node.restraint[i] for i in dof_idx):
continue
@@ -996,7 +1015,7 @@ class ModelRenderer:
if not project.load_patterns or self._node_original_points is None:
return
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
- load_color = (0.2, 0.85, 0.2)
+ load_color = self._style.nodal_load_color
node_by_id = {n.id: n for n in project.nodes}
elem_by_id = {e.id: e for e in project.elements}
for pattern in project.load_patterns:
@@ -1027,7 +1046,7 @@ class ModelRenderer:
# Draw N arrows along the element span, each perpendicular
# to the axis in the direction of the load. Uses the same
# orange-green palette as nodal loads but with shorter arrows.
- elem_load_color = (1.0, 0.55, 0.2) # orange
+ elem_load_color = self._style.element_load_color # distributed loads
n_arrows_per_elem = 5
for eload in pattern.element_loads:
if not isinstance(eload, UniformElementLoad):
@@ -1116,6 +1135,7 @@ class ModelRenderer:
self._node_ids_ordered = []
self._node_id_to_row = {}
self._frame_ids_ordered = []
+ self._frame_family = []
self._frame_id_to_row = {}
self._node_original_points = None
self._deformation = None
@@ -1148,7 +1168,7 @@ class ModelRenderer:
return self._plotter.add_point_labels(
pts,
labels,
- font_size=12,
+ font_size=self._style.label_font_size,
shape_opacity=0.15,
text_color="black",
point_color="white",
@@ -1178,7 +1198,7 @@ class ModelRenderer:
return self._plotter.add_point_labels(
np.asarray(centers),
labels,
- font_size=12,
+ font_size=self._style.label_font_size,
shape_opacity=0.15,
text_color="black",
point_color="white",
diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py
index f25d674..a55d43b 100644
--- a/src/otko/views/canvas3d/style.py
+++ b/src/otko/views/canvas3d/style.py
@@ -4,11 +4,64 @@ All colors, sizes, and glyph parameters live here. Changes apply
globally through the renderer; downstream code never hard-codes a
color or radius. A future "theme" feature can simply swap a different
RenderStyle instance.
+
+The subset in :attr:`RenderStyle.EDITABLE` is surfaced to the user through
+Options → Plot Properties… and persisted in ``QSettings`` (``plot/props``).
+Palette values marked "opstool" are adapted from opstool v1.0.26 (GPL-3.0);
+see ``NOTICE``.
"""
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
+from typing import Any, ClassVar
+
+from otko.core import CorotTrussElement, TrussElement, ZeroLengthElement
+
+#: Selected elements / nodes occupy the last slot of the palette LUT.
+SELECTED_STATE: int = 3
+
+
+def element_family_index(element: object) -> int:
+ """Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length."""
+ if isinstance(element, TrussElement | CorotTrussElement):
+ return 1
+ if isinstance(element, ZeroLengthElement):
+ return 2
+ return 0
+
+
+def family_palette(style: RenderStyle) -> list[str]:
+ """LUT shared by both backends: [beam, truss, link, selected]."""
+ return [
+ style.element_beam_color,
+ style.element_truss_color,
+ style.element_link_color,
+ style.selected_color,
+ ]
+
+
+#: Colours / sizes the Plot Properties dialog exposes, in display order.
+STYLE_FIELDS: tuple[tuple[str, str, str], ...] = (
+ ("element_beam_color", "Beam elements", "color"),
+ ("element_truss_color", "Truss elements", "color"),
+ ("element_link_color", "Link / zero-length", "color"),
+ ("selected_color", "Selection highlight", "color"),
+ ("node_color", "Nodes", "color"),
+ ("node_selected_color", "Selected nodes", "color"),
+ ("support_color", "Supports", "color"),
+ ("nodal_load_color", "Nodal loads", "color"),
+ ("element_load_color", "Element loads", "color"),
+ ("extrusion_color", "Section extrusions", "color"),
+ ("extrusion_opacity", "Extrusion opacity", "float"),
+ ("background_top", "Background (top)", "color"),
+ ("background_bottom", "Background (bottom)", "color"),
+ ("show_axis_outline", "Axis outline (grid + ticks)", "bool"),
+ ("label_font_size", "Label font size", "int"),
+)
+
+#: Field names the dialog edits and that are persisted between sessions.
+EDITABLE_FIELDS: tuple[str, ...] = tuple(name for name, _label, _kind in STYLE_FIELDS)
@dataclass(frozen=True, slots=True)
@@ -19,20 +72,58 @@ class RenderStyle:
background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb"
- node_color: str = "#f0a500" # warm gold
- node_selected_color: str = "#00d4ff" # bright cyan
+ node_color: str = "#d9d9d9" # normal node sphere / marker
+ node_selected_color: str = "#00ffff" # selected node
frame_color: str = "#1f1f1f"
- truss_color: str = "#2e5cb8"
+ truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad
zerolength_color: str = "#a020f0"
- selected_color: str = "#00d4ff"
+ selected_color: str = "#00ffff" # selected element highlight
+
+ # ── 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
+
+ # ── overlay colours ─────────────────────────────────────────────
+ support_color: str = "#ff8019"
+ nodal_load_color: str = "#33d933"
+ element_load_color: str = "#ff8c33"
+ extrusion_color: str = "#598cff"
+ extrusion_opacity: float = 0.22
+ label_font_size: int = 12
+
+ #: Draw the plotly scene grid + tick marks around the model (the "outline"
+ #: of opstool's scene recipe). Off by default to keep the SAP2000-like
+ #: clean viewport; the coloured X/Y/Z axis lines always stay visible.
+ show_axis_outline: bool = False
+
+ #: 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"
custom_support_color: str = "#7f8c8d"
- load_color: str = "#27ae60"
+ load_color: str = "#27ae60" # Y-axis colour of the view triad
mass_color: str = "#9b59b6"
# ── sizes (relative to bbox diagonal unless absolute) ────────────
@@ -49,3 +140,24 @@ class RenderStyle:
load_min_length: float = 0.5
selection_thickness_factor: float = 1.6 # multiplier for selected actors
+
+ #: Names of the fields the Plot Properties dialog edits.
+ EDITABLE: ClassVar[tuple[str, ...]] = EDITABLE_FIELDS
+
+ # ── 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)
+ ]
+
+ def with_updates(self, **changes: Any) -> RenderStyle:
+ """Return a copy with ``changes`` applied (frozen-dataclass safe)."""
+ return replace(self, **changes)
+
+ def editable_values(self) -> dict[str, Any]:
+ """The dialog-managed subset of this style, as a plain dict."""
+ return {name: getattr(self, name) for name in self.EDITABLE}
diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py
new file mode 100644
index 0000000..0352961
--- /dev/null
+++ b/src/otko/views/canvas_base.py
@@ -0,0 +1,92 @@
+"""Backend-agnostic contract for the central 3D canvas.
+
+Two backends implement it:
+
+- :class:`otko.views.canvas3d.ModelCanvas` — PyVista/VTK (native OpenGL, default)
+- :class:`otko.views.canvas_plotly.PlotlyCanvas` — plotly.js in a QWebEngineView
+
+The ``MainWindow`` swaps between them at runtime, so both must expose the same
+public surface (signals, selection, working plane, view presets, display
+toggles) and declare what optional overlay layers they support via
+:class:`CanvasCapabilities`.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+
+def plotly_available() -> bool:
+ """True when the optional ``plotly`` package can be imported.
+
+ The Plotly backend needs plotly.js (shipped inside the ``plotly`` wheel)
+ to render; without it the backend menu entry is disabled.
+ """
+ return importlib.util.find_spec("plotly") is not None
+
+
+@dataclass(frozen=True)
+class CanvasCapabilities:
+ """Optional overlay layers a backend may or may not implement.
+
+ Consumers query these (see ``RenderControls._refresh_action_enablement``)
+ instead of assuming a feature works on every backend.
+ """
+
+ #: Force-diagram ribbon overlay (N/V/M diagrams).
+ diagrams: bool = True
+ #: SAP2000-style section extrusion overlay.
+ extrusions: bool = True
+ #: Per-element local-axis triad overlay.
+ local_axes: bool = True
+ #: Node / element text labels.
+ labels: bool = True
+ #: Off-screen frame capture (mode-shape / time-history video export).
+ animation_export: bool = True
+
+
+class CanvasBackend(Protocol):
+ """Structural contract implemented by every canvas backend.
+
+ This mirrors :class:`ModelCanvas`; it exists so the swap path and the
+ consumers have one written-down interface to code against.
+ """
+
+ capabilities: CanvasCapabilities
+ selection: Any
+
+ def show_project(self, project: Any) -> None: ...
+
+ def clear_model(self) -> None: ...
+
+ def render(self) -> None: ...
+
+ def reset_camera(self) -> None: ...
+
+ def view_isometric(self) -> None: ...
+
+ def view_xy(self) -> None: ...
+
+ def view_xz(self) -> None: ...
+
+ def view_yz(self) -> None: ...
+
+ def set_working_plane(self, plane: str, offset: float) -> None: ...
+
+ def clear_working_plane(self) -> None: ...
+
+ def working_plane_type(self) -> str | None: ...
+
+ def working_plane_offset(self) -> float | None: ...
+
+ def set_snap_preview_enabled(self, enabled: bool) -> None: ...
+
+ def set_show_section_extrusions(self, enabled: bool) -> None: ...
+
+ def set_show_local_axes(self, enabled: bool) -> None: ...
+
+ def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ...
+
+ def set_default_selection_enabled(self, enabled: bool) -> None: ...
diff --git a/src/otko/views/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py
new file mode 100644
index 0000000..7fabb6d
--- /dev/null
+++ b/src/otko/views/canvas_plotly/__init__.py
@@ -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}")
diff --git a/src/otko/views/canvas_plotly/bridge.py b/src/otko/views/canvas_plotly/bridge.py
new file mode 100644
index 0000000..9dafbce
--- /dev/null
+++ b/src/otko/views/canvas_plotly/bridge.py
@@ -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))
diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py
new file mode 100644
index 0000000..46e145e
--- /dev/null
+++ b/src/otko/views/canvas_plotly/html.py
@@ -0,0 +1,276 @@
+"""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
new file mode 100644
index 0000000..ddecb15
--- /dev/null
+++ b/src/otko/views/canvas_plotly/plotly_canvas.py
@@ -0,0 +1,417 @@
+"""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: figures are pushed with ``Plotly.react``. Because react
+resets any scene attribute the incoming layout omits, a non-framing push is
+flagged ``preserveView`` and the JS side carries the live camera and axis
+ranges forward — so re-rendering on a model edit or selection change never
+yanks the camera the user is orbiting. Only an explicit re-frame (new
+project, view preset, ``reset_camera``, style change) sends the computed
+camera plus opstool-style padded axis ranges.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from collections.abc import Callable
+from dataclasses import replace
+from typing import Any
+
+from PySide6.QtCore import QObject, QUrl, Signal
+from PySide6.QtWebChannel import QWebChannel
+from PySide6.QtWebEngineCore import QWebEnginePage
+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 _LoggingPage(QWebEnginePage):
+ """A web page that forwards the JS console to the app's console dock.
+
+ A plotly failure inside the page is otherwise invisible from Python (the
+ ``react`` promise just rejects), which makes a frozen canvas very hard to
+ diagnose.
+ """
+
+ def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._log = log
+
+ def javaScriptConsoleMessage(
+ self,
+ level: QWebEnginePage.JavaScriptConsoleMessageLevel,
+ message: str,
+ line_number: int,
+ source_id: str,
+ ) -> None:
+ names = {
+ QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error",
+ QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning",
+ }
+ severity = names.get(level, "info")
+ source = source_id.rsplit("/", 1)[-1] or "plotly"
+ self._log(f"[web] {source}:{line_number} {message}", severity)
+
+
+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,
+ log: Callable[[str, str], None] | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self._log = log
+ 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._framing_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)
+ if self._log is not None:
+ self._web.setPage(_LoggingPage(self._log, self._web))
+ 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._framing_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._framing_dirty = True
+
+ def reset_camera(self) -> None:
+ self._view_preset = "iso"
+ self._framing_dirty = True
+ self.render()
+
+ def view_isometric(self) -> None:
+ self._view_preset = "iso"
+ self._framing_dirty = True
+ self.render()
+
+ def view_xy(self) -> None:
+ """Top view: the eye sits on +Z looking down."""
+ self._view_preset = "xy"
+ self._framing_dirty = True
+ self.render()
+
+ def view_xz(self) -> None:
+ """Front view: the eye sits on -Y."""
+ self._view_preset = "xz"
+ self._framing_dirty = True
+ self.render()
+
+ def view_yz(self) -> None:
+ """Right view: the eye sits on +X."""
+ self._view_preset = "yz"
+ self._framing_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_style(self, style: RenderStyle) -> None:
+ """Swap the visual style and repaint the figure.
+
+ Deliberately *not* a re-frame: colour/opacity are trace props and the
+ background/outline ride the layout, so a style change (including the
+ live preview while dragging a colour) updates without snapping the
+ camera the user has moved.
+ """
+ self._style = style
+ self._builder.set_style(style)
+ 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:
+ """Send the figure to plotly.js.
+
+ ``Plotly.react`` resets any scene attribute the incoming layout omits,
+ which used to snap the camera *and* the padded ranges back on every
+ selection change. So a non-framing push marks itself
+ ``preserveView`` and the JS side carries the live camera/ranges
+ forward; only an explicit re-frame (new project, view preset,
+ ``reset_camera``) sends the computed framing. Style changes are
+ non-framing: they re-colour in place.
+ """
+ self._scene = self._builder.build(self._project, self._options)
+ if not self._ready:
+ # Nothing to push yet; crucially this must come *before* the
+ # framing flag is consumed, or the framing scheduled before
+ # loadFinished would be dropped on the floor.
+ return
+ layout = dict(self._scene.layout)
+ re_framed = self._framing_dirty
+ if re_framed:
+ scene_layout = dict(layout["scene"])
+ scene_layout["camera"] = self._camera_dict(self._scene)
+ for axis, override in self._scene.axis_overrides().items():
+ scene_layout[axis] = {**scene_layout.get(axis, {}), **override}
+ layout["scene"] = scene_layout
+ self._framing_dirty = False
+ payload = json.dumps({"data": self._scene.data, "layout": layout})
+ self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
+
+ 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
new file mode 100644
index 0000000..fa5fc94
--- /dev/null
+++ b/src/otko/views/canvas_plotly/trace_builder.py
@@ -0,0 +1,1028 @@
+"""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,
+ element_family_index,
+ family_palette,
+)
+
+_FRAME_CLASSES = (
+ ElasticBeamColumn,
+ DispBeamColumn,
+ ForceBeamColumn,
+ BeamWithHingesElement,
+ TrussElement,
+ CorotTrussElement,
+ ZeroLengthElement,
+)
+
+#: 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
+
+
+@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
+ #: Padded ``(min, max)`` per axis, used to frame the camera deterministically.
+ axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
+
+ 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}
+
+ def axis_overrides(self) -> dict[str, Any]:
+ """Nested ``scene`` range pins for the framed data window.
+
+ Only pushed together with the camera (view presets / new project), so
+ the user's own zoom and pan survive ordinary data updates. The padding
+ comes from opstool's scene recipe (``pad_ratio`` 0.15).
+ """
+ return {
+ f"{name}axis": {"range": [low, high], "autorange": False}
+ for name, (low, high) in self.axis_bounds.items()
+ }
+
+
+@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
+
+
+#: Padding fraction added around the model when framing the view.
+#: From opstool's scene recipe (``pad_ratio=0.15`` for model views).
+_PAD_RATIO = 0.15
+
+
+def _padded_axis_bounds(pts: np.ndarray | None) -> dict[str, tuple[float, float]]:
+ """Padded ``(min, max)`` per axis; degenerate axes get unit slack."""
+ if pts is None or len(pts) == 0:
+ return {name: (-1.0, 1.0) for name in ("x", "y", "z")}
+ lower = np.asarray(pts, dtype=float).min(axis=0)
+ upper = np.asarray(pts, dtype=float).max(axis=0)
+ pad = (upper - lower) * _PAD_RATIO
+ bounds: dict[str, tuple[float, float]] = {}
+ for index, name in enumerate(("x", "y", "z")):
+ low = float(lower[index] - pad[index])
+ high = float(upper[index] + pad[index])
+ if high - low < 1e-9: # planar / single-point model
+ low, high = low - 1.0, high + 1.0
+ bounds[name] = (low, high)
+ return bounds
+
+
+def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float:
+ """Padded bounding-box diagonal — the framing distance yardstick."""
+ diagonal = float(np.linalg.norm([high - low for low, high in bounds.values()]))
+ return diagonal if diagonal > 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()
+
+ def set_style(self, style: RenderStyle) -> None:
+ """Swap the visual style; the next :meth:`build` picks it up."""
+ self._style = style
+
+ # ── 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)
+ bounds = _padded_axis_bounds(all_pts)
+ return Scene(
+ data=data,
+ layout=self._layout(),
+ center=(float(center[0]), float(center[1]), float(center[2])),
+ diagonal=_diag_of_bounds(bounds),
+ hover_trace=hover_trace,
+ axis_bounds=bounds,
+ )
+
+ # ── layout ───────────────────────────────────────────────────────
+ def _layout(self) -> dict[str, Any]:
+ style = self._style
+ outline = style.show_axis_outline
+
+ def axis(color: str, title: str) -> dict[str, Any]:
+ # Coloured axis lines + titles stay visible as the orientation cue
+ # (plotly has no corner triad); the grid/ticks are the optional
+ # "outline" from opstool's scene recipe.
+ return {
+ "title": {"text": title, "font": {"color": color, "size": 12}},
+ "showgrid": outline,
+ "showbackground": False,
+ "zeroline": outline,
+ "showticklabels": outline,
+ "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 = [
+ self._style.node_selected_color
+ if nid in opts.selection_nodes
+ else self._style.node_color
+ 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"},
+ # Engineering-notation hover (opstool's trace recipe) so a
+ # hover identifies the entity instead of showing the raw id.
+ "hovertemplate": "Node #%{customdata}",
+ "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 = family_palette(self._style)
+ 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 element_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"},
+ "hovertemplate": "Element #%{customdata}",
+ "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": self._style.support_color,
+ "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=self._style.nodal_load_color,
+ 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=self._style.element_load_color,
+ 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=self._style.extrusion_color,
+ opacity=self._style.extrusion_opacity,
+ 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", font_size=self._style.label_font_size
+ )
+ )
+ 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",
+ font_size=self._style.label_font_size,
+ )
+ )
+
+ @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, font_size: int
+) -> 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": 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/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py
index d2a3b45..e4dff02 100644
--- a/src/otko/views/dialogs/__init__.py
+++ b/src/otko/views/dialogs/__init__.py
@@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
from otko.views.dialogs.plain_pattern import PlainPatternDialog
+from otko.views.dialogs.plot_properties import PlotPropertiesDialog
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
from otko.views.dialogs.quick_guide import QuickGuideDialog
from otko.views.dialogs.replicate import ReplicateDialog
@@ -69,6 +70,7 @@ __all__ = [
"PathTimeSeriesDialog",
"PatternLoadsDialog",
"PlainPatternDialog",
+ "PlotPropertiesDialog",
"QuickGridLinesDialog",
"QuickGuideDialog",
"ReplicateDialog",
diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py
new file mode 100644
index 0000000..ae249ef
--- /dev/null
+++ b/src/otko/views/dialogs/plot_properties.py
@@ -0,0 +1,150 @@
+"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
+
+The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
+so adding an editable property is a one-line change there plus a default on
+the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
+so the canvas can preview them live; ``style()`` returns the composed result.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Signal
+from PySide6.QtGui import QColor
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QColorDialog,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QLabel,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
+
+
+class _ColorButton(QPushButton):
+ """A swatch that opens :class:`QColorDialog` and reports the hex value."""
+
+ colorChanged = Signal(str)
+
+ def __init__(self, color: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._color = color
+ self.setFixedWidth(96)
+ self._refresh()
+ self.clicked.connect(self._pick)
+
+ def color(self) -> str:
+ """Current colour as a ``#rrggbb`` string."""
+ return self._color
+
+ def set_color(self, color: str) -> None:
+ """Set the swatch colour (does not emit :attr:`colorChanged`)."""
+ self._color = color
+ self._refresh()
+
+ def _refresh(self) -> None:
+ self.setText(self._color.upper())
+ self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
+
+ def _text_color(self) -> str:
+ color = QColor(self._color)
+ luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
+ return "#000000" if luminance > 140 else "#ffffff"
+
+ def _pick(self) -> None:
+ chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
+ if chosen.isValid():
+ self.set_color(chosen.name())
+ self.colorChanged.emit(self._color)
+
+
+class PlotPropertiesDialog(QDialog):
+ """Modeless-free editor for the persisted plot style."""
+
+ #: Emitted with the composed :class:`RenderStyle` on every edit.
+ changed = Signal(object)
+
+ def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Plot Properties")
+ self._base = style
+ self._colors: dict[str, _ColorButton] = {}
+ self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
+ self._checks: dict[str, QCheckBox] = {}
+
+ layout = QVBoxLayout(self)
+ form = QFormLayout()
+ for name, label, kind in STYLE_FIELDS:
+ widget = self._build_widget(name, getattr(style, name), kind)
+ form.addRow(QLabel(f"{label}:"), widget)
+ layout.addLayout(form)
+
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
+ assert reset is not None
+ reset.clicked.connect(self._reset)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ # ── public ──────────────────────────────────────────────────────
+ def plot_style(self) -> RenderStyle:
+ """The composed style for the current widget values.
+
+ Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
+ already exists and must keep its Qt meaning.
+ """
+ updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
+ updates.update({name: w.value() for name, w in self._numbers.items()})
+ updates.update({name: w.isChecked() for name, w in self._checks.items()})
+ return self._base.with_updates(**updates)
+
+ # ── internals ───────────────────────────────────────────────────
+ def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
+ if kind == "color":
+ button = _ColorButton(str(value))
+ button.colorChanged.connect(self._emit_changed)
+ self._colors[name] = button
+ return button
+ if kind == "float":
+ spin = QDoubleSpinBox()
+ spin.setRange(0.0, 1.0)
+ spin.setSingleStep(0.05)
+ spin.setDecimals(2)
+ spin.setValue(float(value)) # type: ignore[arg-type]
+ spin.valueChanged.connect(self._emit_changed)
+ self._numbers[name] = spin
+ return spin
+ if kind == "bool":
+ check = QCheckBox()
+ check.setChecked(bool(value))
+ check.toggled.connect(self._emit_changed)
+ self._checks[name] = check
+ return check
+ int_spin = QSpinBox()
+ int_spin.setRange(6, 32)
+ int_spin.setValue(int(value)) # type: ignore[call-overload]
+ int_spin.valueChanged.connect(self._emit_changed)
+ self._numbers[name] = int_spin
+ return int_spin
+
+ def _emit_changed(self, *_args: object) -> None:
+ self.changed.emit(self.plot_style())
+
+ def _reset(self) -> None:
+ defaults = RenderStyle()
+ for name, button in self._colors.items():
+ button.set_color(getattr(defaults, name))
+ for name, spin in self._numbers.items():
+ spin.setValue(getattr(defaults, name))
+ for name, check in self._checks.items():
+ check.setChecked(getattr(defaults, name))
+ self._emit_changed()
diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py
index df4b70e..cd34449 100644
--- a/src/otko/views/dock_manager.py
+++ b/src/otko/views/dock_manager.py
@@ -210,6 +210,14 @@ class DockManager:
because the off-screen renderer must be touched from the GUI
thread (VTK's Qt-backed render window isn't thread-safe).
"""
+ if not self._canvas.capabilities.animation_export:
+ QMessageBox.information(
+ self,
+ "Export Mode Shape Animation",
+ "Animation export needs the PyVista backend "
+ "(Options → Canvas Backend → PyVista (Native)).",
+ )
+ return
from PySide6.QtWidgets import QFileDialog
path, _sel = QFileDialog.getSaveFileName(
@@ -260,6 +268,14 @@ class DockManager:
"Run a Static analysis first; force diagrams visualise its element-force output.",
)
return
+ if not self._canvas.capabilities.diagrams:
+ QMessageBox.information(
+ self,
+ "Force Diagram",
+ "Force diagrams are not available on the Plotly backend yet.\n\n"
+ "Switch back via Options → Canvas Backend → PyVista (Native).",
+ )
+ return
self._tear_down_post_dock()
# Pick the component with the largest abs_max as the initial choice
@@ -364,6 +380,14 @@ class DockManager:
"Run a Transient (time-history) analysis first.",
)
return
+ if not self._canvas.capabilities.animation_export:
+ QMessageBox.information(
+ self,
+ "Export Time-History Animation",
+ "Animation export needs the PyVista backend "
+ "(Options → Canvas Backend → PyVista (Native)).",
+ )
+ return
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QInputDialog
diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py
index 958459e..5241869 100644
--- a/src/otko/views/main_window.py
+++ b/src/otko/views/main_window.py
@@ -13,16 +13,21 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
from __future__ import annotations
+import json
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.canvas3d.style import RenderStyle
+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 +39,9 @@ from otko.views.tools import (
ToolController,
)
+#: Canvas backends, in menu order.
+CANVAS_BACKENDS = ("pyvista", "plotly")
+
class MainWindow(
QMainWindow,
@@ -44,7 +52,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 +68,22 @@ 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()
+ #: Shared, user-editable visual style (both backends read it).
+ self._style = self._load_plot_style()
+ 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 +95,164 @@ 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,
+ style=self._style,
+ selection=self._selection,
+ log=self._log_canvas_message,
+ )
+ else:
+ canvas = ModelCanvas(self, style=self._style, 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 _log_canvas_message(self, message: str, severity: str = "info") -> None:
+ """Route a JS console line from the Plotly page to the Console dock.
+
+ The dock may not exist yet while canvases are being constructed, so
+ the lookup is guarded.
+ """
+ dock = getattr(self, "_console_dock", None)
+ if dock is not None:
+ dock.log(message, severity=severity)
+
+ # ── plot style ───────────────────────────────────────────────────
+ def _load_plot_style(self) -> RenderStyle:
+ """Style from the last session, ignoring anything unrecognised."""
+ raw = self._settings.value("plot/props")
+ if not raw:
+ return RenderStyle()
+ try:
+ data = json.loads(str(raw))
+ except ValueError:
+ return RenderStyle()
+ if not isinstance(data, dict):
+ return RenderStyle()
+ allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
+ try:
+ return RenderStyle(**allowed)
+ except (TypeError, ValueError):
+ return RenderStyle()
+
+ def _save_plot_style(self) -> None:
+ self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
+
+ def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
+ """Apply a style to every canvas (live preview when ``persist`` is off)."""
+ self._style = style
+ for canvas in self._canvases.values():
+ canvas.set_style(style)
+ if persist:
+ self._save_plot_style()
+
+ 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 +260,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)
@@ -147,6 +309,7 @@ class MainWindow(
self._act_about.triggered.connect(self._on_about)
self._act_quick_guide.triggered.connect(self._on_quick_guide)
self._act_set_units.triggered.connect(self._on_set_units)
+ self._act_plot_properties.triggered.connect(self._on_plot_properties)
# Edit
self._act_delete.triggered.connect(self._on_delete)
@@ -161,9 +324,12 @@ class MainWindow(
self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool)
self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_tool)
self._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool)
- self._tool_controller.toolChanged.connect(self._on_tool_changed)
- # Keep the Tools toolbar checked state in sync with the controller.
- self._tool_controller.toolChanged.connect(self._sync_tool_actions)
+ # toolChanged → toolbar/menu sync is wired in _build_tooling(), which
+ # also runs on a canvas-backend swap (then a fresh controller exists).
+
+ # Canvas backend
+ self._act_canvas_pyvista.triggered.connect(lambda: self.swap_canvas_backend("pyvista"))
+ self._act_canvas_plotly.triggered.connect(lambda: self.swap_canvas_backend("plotly"))
# Define
self._act_grid.triggered.connect(self._on_grid_system)
diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py
index bd8af6a..5e7a271 100644
--- a/src/otko/views/menu_builder.py
+++ b/src/otko/views/menu_builder.py
@@ -192,9 +192,46 @@ class MenuBuilder:
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
self._act_quick_guide = QAction("&Quick Guide", self)
self._act_set_units = QAction("Set Display &Units…", self)
+ self._act_plot_properties = QAction("&Plot Properties…", self)
+ self._act_plot_properties.setToolTip(
+ "Colours and sizes used by both canvas backends (persisted)."
+ )
+
+ # 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 +516,11 @@ class MenuBuilder:
m_options = mb.addMenu("&Options")
m_options.addAction(self._act_set_units)
+ m_options.addAction(self._act_plot_properties)
+ m_backend = m_options.addMenu("Canvas &Backend")
+ m_backend.addAction(self._act_canvas_pyvista)
+ m_backend.addAction(self._act_canvas_plotly)
+ self._sync_canvas_backend_actions()
m_help = mb.addMenu("&Help")
m_help.addAction(self._act_quick_guide)
diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py
index 1d574c1..06db479 100644
--- a/src/otko/views/render_controls.py
+++ b/src/otko/views/render_controls.py
@@ -50,6 +50,23 @@ class RenderControls:
)
# ── SAP2000-style view switching + working level ───────────────
+ def _on_plot_properties(self) -> None:
+ """Edit the shared visual style.
+
+ Edits preview live on both canvases; Cancel restores the style the
+ dialog opened with, and only OK persists to ``QSettings``.
+ """
+ from otko.views.dialogs import PlotPropertiesDialog
+
+ original = self._style
+ dialog = PlotPropertiesDialog(original, self)
+ dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False))
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ self.set_plot_style(dialog.plot_style())
+ self._log("Plot properties updated.")
+ else:
+ self.set_plot_style(original, persist=False)
+
def _on_toggle_parallel(self, on: bool) -> None:
cam = self._canvas.camera
cam.parallel_projection = on
@@ -459,6 +476,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 +500,11 @@ class RenderControls:
self._act_tool_draw_truss.setEnabled(has_project)
self._act_show_deformed.setEnabled(has_static)
self._act_show_mode_shape.setEnabled(has_modal)
- self._act_show_force_diagram.setEnabled(has_static)
+ # Force diagrams + video export are backend capabilities, not just
+ # data availability (Plotly cannot do either yet).
+ self._act_show_force_diagram.setEnabled(has_static and caps.diagrams)
self._act_show_time_history.setEnabled(has_transient)
- self._act_export_th_animation.setEnabled(has_transient)
+ self._act_export_th_animation.setEnabled(has_transient and caps.animation_export)
self._act_show_hysteresis.setEnabled(has_transient)
self._act_show_pushover.setEnabled(has_pushover)
self._act_show_response_spectrum.setEnabled(has_rs)
diff --git a/tests/conftest.py b/tests/conftest.py
index 82dd86a..c91c064 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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."""
diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py
new file mode 100644
index 0000000..ed77be1
--- /dev/null
+++ b/tests/gui/test_canvas_backend_switch.py
@@ -0,0 +1,122 @@
+"""GUI tests for the live canvas-backend switch (PyVista ⇄ Plotly).
+
+QSettings are injected per-test so a swap never leaks into the real user
+configuration or the next test.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pyvistaqt")
+pytest.importorskip("plotly")
+
+from PySide6.QtCore import QSettings
+
+
+def _make_window(tmp_path, qtbot): # type: ignore[no-untyped-def]
+ from otko.views.main_window import MainWindow
+
+ settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
+ window = MainWindow(settings=settings)
+ qtbot.addWidget(window)
+ return window
+
+
+@pytest.mark.gui
+def test_default_backend_is_pyvista(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ from otko.views.canvas3d import ModelCanvas
+
+ window = _make_window(tmp_path, qtbot)
+ assert window.canvas_backend() == "pyvista"
+ assert isinstance(window._canvas, ModelCanvas)
+ assert window._diagram_renderer is not None
+
+
+@pytest.mark.gui
+def test_switch_to_plotly_preserves_selection(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ from otko.views.canvas_plotly import PlotlyCanvas
+
+ window = _make_window(tmp_path, qtbot)
+ window._vm.new_project()
+ window._canvas.selection.select_node(2)
+
+ window.swap_canvas_backend("plotly")
+
+ assert window.canvas_backend() == "plotly"
+ assert isinstance(window._canvas, PlotlyCanvas)
+ # One shared SelectionState: the tree/table/properties stay bound.
+ assert window._canvas.selection is window._selection
+ assert window._canvas.selection.nodes == frozenset({2})
+ # Both canvases live in the stack; the PyVista one was not destroyed.
+ assert window._canvas_stack.count() == 2
+
+
+@pytest.mark.gui
+def test_plotly_gates_pyvista_only_features(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ window = _make_window(tmp_path, qtbot)
+ assert window._canvas.capabilities.diagrams is True
+
+ window.swap_canvas_backend("plotly")
+ caps = window._canvas.capabilities
+ assert caps.diagrams is False
+ assert caps.animation_export is False
+ assert caps.extrusions is True and caps.labels is True
+
+ window._refresh_action_enablement()
+ assert window._act_show_force_diagram.isEnabled() is False
+ assert window._act_export_th_animation.isEnabled() is False
+
+
+@pytest.mark.gui
+def test_swapping_back_reuses_the_same_canvases(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ window = _make_window(tmp_path, qtbot)
+ pyvista_canvas = window._canvas
+
+ window.swap_canvas_backend("plotly")
+ plotly_canvas = window._canvas
+ window.swap_canvas_backend("pyvista")
+
+ assert window._canvas is pyvista_canvas
+ assert window._diagram_renderer is not None
+ # No duplicate widgets accumulate across repeated swaps.
+ window.swap_canvas_backend("plotly")
+ assert window._canvas is plotly_canvas
+ assert window._diagram_renderer is None # diagrams are PyVista-only
+ assert window._canvas_stack.count() == 2
+
+
+@pytest.mark.gui
+def test_backend_choice_is_persisted(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ window = _make_window(tmp_path, qtbot)
+ window.swap_canvas_backend("plotly")
+ assert window._settings.value("canvas/backend") == "plotly"
+
+ # A fresh window with the same settings boots straight into Plotly.
+ from otko.views.canvas_plotly import PlotlyCanvas
+ from otko.views.main_window import MainWindow
+
+ settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
+ second = MainWindow(settings=settings)
+ qtbot.addWidget(second)
+ assert second.canvas_backend() == "plotly"
+ assert isinstance(second._canvas, PlotlyCanvas)
+
+
+@pytest.mark.gui
+def test_working_plane_survives_the_swap(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ window = _make_window(tmp_path, qtbot)
+ window._vm.open(_example("basic_truss"))
+ window._canvas.set_working_plane("XY", 0.0)
+
+ window.swap_canvas_backend("plotly")
+
+ assert window._canvas.working_plane_type() == "XY"
+ assert window._canvas.working_plane_offset() == 0.0
+
+
+def _example(name: str) -> str:
+ from pathlib import Path
+
+ return str(Path(__file__).resolve().parents[2] / "examples" / f"{name}.osmodel")
diff --git a/tests/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py
new file mode 100644
index 0000000..3ef3601
--- /dev/null
+++ b/tests/gui/test_plot_properties.py
@@ -0,0 +1,131 @@
+"""GUI tests for Options → Plot Properties.
+
+Covers the dialog itself plus MainWindow's live-apply / persist / revert
+wiring. QSettings are injected so nothing touches real user state.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pyvistaqt")
+
+from PySide6.QtCore import QObject, QSettings, Signal
+from PySide6.QtWidgets import QDialog
+
+
+def _settings(tmp_path): # type: ignore[no-untyped-def]
+ return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
+
+
+def _window(tmp_path, qtbot): # type: ignore[no-untyped-def]
+ from otko.views.main_window import MainWindow
+
+ window = MainWindow(settings=_settings(tmp_path))
+ qtbot.addWidget(window)
+ return window
+
+
+def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ from otko.views.canvas3d.style import RenderStyle
+
+ window = _window(tmp_path, qtbot)
+ assert window._style == RenderStyle()
+
+
+def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ window = _window(tmp_path, qtbot)
+ window.swap_canvas_backend("plotly") # build the second canvas too
+
+ window.set_plot_style(
+ window._style.with_updates(element_beam_color="#ff0000", label_font_size=20)
+ )
+
+ assert set(window._canvases) == {"pyvista", "plotly"}
+ for canvas in window._canvases.values():
+ assert canvas._style.element_beam_color == "#ff0000"
+ assert canvas._style.label_font_size == 20
+
+ # A fresh window sharing the settings file reloads the customisation.
+ from otko.views.main_window import MainWindow
+
+ fresh = MainWindow(settings=_settings(tmp_path))
+ qtbot.addWidget(fresh)
+ assert fresh._style.element_beam_color == "#ff0000"
+ assert fresh._style.label_font_size == 20
+
+
+def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ window = _window(tmp_path, qtbot)
+ window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False)
+
+ assert window._canvas._style.node_color == "#010203"
+ assert window._settings.value("plot/props") is None
+
+
+def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
+ window = _window(tmp_path, qtbot)
+ original = window._style
+ preview = original.with_updates(node_color="#010203")
+
+ class _RejectingDialog(QObject):
+ changed = Signal(object)
+
+ def __init__(self, style: object, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._style = preview
+
+ def exec(self) -> QDialog.DialogCode:
+ self.changed.emit(preview) # live preview happens...
+ return QDialog.DialogCode.Rejected # ...but the user cancels
+
+ def plot_style(self): # type: ignore[no-untyped-def]
+ return self._style
+
+ monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog)
+ window._on_plot_properties()
+
+ assert window._style == original
+ assert window._canvas._style.node_color == original.node_color
+ assert window._settings.value("plot/props") is None
+
+
+def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
+ window = _window(tmp_path, qtbot)
+ preview = window._style.with_updates(node_color="#010203")
+
+ class _AcceptingDialog(QObject):
+ changed = Signal(object)
+
+ def __init__(self, style: object, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._style = preview
+
+ def exec(self) -> QDialog.DialogCode:
+ return QDialog.DialogCode.Accepted
+
+ def plot_style(self): # type: ignore[no-untyped-def]
+ return self._style
+
+ monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog)
+ window._on_plot_properties()
+
+ assert window._style.node_color == "#010203"
+ assert window._canvas._style.node_color == "#010203"
+ assert "#010203" in str(window._settings.value("plot/props"))
+
+
+def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
+ from otko.views.canvas3d.style import RenderStyle
+ from otko.views.dialogs import PlotPropertiesDialog
+
+ dialog = PlotPropertiesDialog(RenderStyle())
+ qtbot.addWidget(dialog)
+ assert dialog.plot_style() == RenderStyle()
+
+ dialog._colors["node_color"].set_color("#abcdef")
+ assert dialog.plot_style().node_color == "#abcdef"
+
+ dialog._reset()
+ assert dialog.plot_style() == RenderStyle()
diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py
new file mode 100644
index 0000000..78d5160
--- /dev/null
+++ b/tests/gui/test_plotly_hover.py
@@ -0,0 +1,93 @@
+"""Regression tests for the Plotly hover/snap-marker contract.
+
+``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves.
+Touching the plot on each one made it redraw per mouse move, re-firing hover
+until the stack blew (``RangeError: Maximum call stack size exceeded``) and the
+canvas froze. The marker is therefore coalesced and only touched when its
+shown/hidden state actually changes.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("plotly")
+
+from otko.services import load_project
+
+EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
+
+#: 200 mouse-move events over a *non-snap* point with snapping armed.
+_CHURN_PROBE = """(function(){
+ var gd = document.getElementById('plot');
+ var n = 0; var original = Plotly.restyle;
+ Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); };
+ var point = { data: gd.data[0], customdata: [0, 0, 0] };
+ try {
+ for (var i = 0; i < 200; i++) {
+ gd.emit('plotly_hover', { points: [point] });
+ gd.emit('plotly_unhover', {});
+ }
+ return n;
+ } finally { Plotly.restyle = original; }
+})()"""
+
+_HOVER_MARKER_X = (
+ "JSON.stringify((document.getElementById('plot').data"
+ ".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)"
+)
+
+_EMIT_SNAP_HOVER = """(function(){
+ var gd = document.getElementById('plot');
+ var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0];
+ var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] };
+ gd.emit('plotly_hover', { points: [point] });
+ return true;
+})()"""
+
+
+def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
+ from otko.views.canvas_plotly import PlotlyCanvas
+
+ canvas = PlotlyCanvas()
+ qtbot.addWidget(canvas)
+ canvas.show_project(load_project(EXAMPLES / name))
+ qtbot.waitUntil(lambda: canvas._ready, timeout=30000)
+ qtbot.wait(1500) # let the first Plotly.react settle
+ return canvas
+
+
+def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def]
+ box: dict[str, object] = {}
+ canvas._web.page().runJavaScript(script, lambda value: box.update(value=value))
+ qtbot.waitUntil(lambda: "value" in box, timeout=timeout)
+ return box["value"]
+
+
+@pytest.mark.gui
+def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
+ """The unfixed version restyled once per hover/unhover event."""
+ canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
+
+ restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
+
+ assert restyles == 0, f"mouse movement caused {restyles} plot restyles"
+
+
+@pytest.mark.gui
+def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def]
+ canvas = _open_canvas(qtbot, "basic_truss.osmodel")
+ assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]"
+
+ canvas.set_snap_preview_enabled(True)
+ qtbot.wait(100)
+ _run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
+ qtbot.wait(250)
+ assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown"
+
+ _run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})")
+ qtbot.wait(250)
+ assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared"
diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py
new file mode 100644
index 0000000..97ef3ee
--- /dev/null
+++ b/tests/gui/test_plotly_view_preservation.py
@@ -0,0 +1,123 @@
+"""Regression tests for the Plotly canvas push contract.
+
+``Plotly.react`` resets any scene attribute the incoming layout omits, so a
+data-only push must be flagged ``preserveView`` (the JS side then carries the
+live camera and axis ranges forward). Only explicit re-frames may send the
+computed framing. These tests capture the JavaScript the canvas emits instead
+of driving the browser, so they stay fast and deterministic.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("plotly")
+
+from otko.services import load_project
+
+EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
+
+
+def _canvas_with_captured_js(qtbot): # type: ignore[no-untyped-def]
+ from otko.views.canvas_plotly import PlotlyCanvas
+
+ canvas = PlotlyCanvas()
+ qtbot.addWidget(canvas)
+ # Pretend the page finished loading, then capture instead of running JS.
+ canvas._ready = True
+ calls: list[str] = []
+ canvas._eval = lambda js: calls.append(js) # type: ignore[method-assign]
+ return canvas, calls
+
+
+def _parse_call(call: str) -> tuple[dict, bool]:
+ """Split a captured ``window.otkoUpdate(, )`` call."""
+ body = call[len("window.otkoUpdate(") : -1]
+ literal, _sep, flag = body.rpartition(", ")
+ return json.loads(json.loads(literal)), flag == "true"
+
+
+@pytest.mark.gui
+def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignore[no-untyped-def]
+ canvas, calls = _canvas_with_captured_js(qtbot)
+
+ canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
+
+ assert calls, "show_project must push a figure"
+ payload, preserve = _parse_call(calls[-1])
+ assert preserve is False, "initial push must be a re-frame"
+ # The framing push carries the camera and the padded ranges.
+ scene = payload["layout"]["scene"]
+ assert "camera" in scene
+ assert "range" in scene["xaxis"]
+ assert scene["xaxis"]["autorange"] is False
+
+
+@pytest.mark.gui
+def test_data_update_preserves_the_view(qtbot) -> None: # type: ignore[no-untyped-def]
+ canvas, calls = _canvas_with_captured_js(qtbot)
+
+ canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
+ calls.clear()
+
+ canvas.selection.select_node(1) # data-only update
+
+ assert calls, "selection change must push"
+ payload, preserve = _parse_call(calls[-1])
+ assert preserve is True, "data update must preserve the view"
+ assert "camera" not in payload["layout"]["scene"], "must not re-send the camera"
+
+
+@pytest.mark.gui
+def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-untyped-def]
+ canvas, calls = _canvas_with_captured_js(qtbot)
+
+ canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
+
+ for action in (canvas.view_xy, canvas.view_xz, canvas.view_yz, canvas.reset_camera):
+ calls.clear()
+ action()
+ assert calls[-1].endswith(", false)"), f"{action.__name__} must re-frame"
+
+
+@pytest.mark.gui
+def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # type: ignore[no-untyped-def]
+ """Re-colouring must not snap the camera back to the preset."""
+ from otko.views.canvas3d.style import RenderStyle
+
+ canvas, calls = _canvas_with_captured_js(qtbot)
+ canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
+ calls.clear()
+
+ canvas.set_style(RenderStyle(show_axis_outline=True))
+
+ assert calls, "a style change must push"
+ payload, preserve = _parse_call(calls[-1])
+ assert preserve is True, "colour changes must not re-frame the view"
+ assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied
+
+
+@pytest.mark.gui
+def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def]
+ """A plotly failure inside the page must not be invisible from Python."""
+ from PySide6.QtWebEngineCore import QWebEnginePage
+
+ from otko.views.canvas_plotly.plotly_canvas import _LoggingPage
+
+ messages: list[tuple[str, str]] = []
+ page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg)))
+
+ levels = QWebEnginePage.JavaScriptConsoleMessageLevel
+ page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html")
+ page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html")
+ page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "")
+
+ assert messages == [
+ ("error", "[web] plot.html:7 boom"),
+ ("warning", "[web] plot.html:8 careful"),
+ ("info", "[web] plotly:9 hello"),
+ ]
diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py
index 9281e67..7b3f95c 100644
--- a/tests/unit/test_architecture.py
+++ b/tests/unit/test_architecture.py
@@ -2,11 +2,12 @@
The documented dependency stack is ``views -> viewmodels -> services -> core``;
each layer may only import from itself or a lower layer, ``core`` stays free of
-Qt/solver/plotting libraries, and the rendering backend is confined to
-``views/canvas3d``. These tests never import the application modules; they read
-the source tree with ``ast`` so the import discipline can be checked without
-executing Qt or the solver. Directories that do not exist yet simply have
-nothing to walk, which trivially satisfies a "forbidden import" rule.
+Qt/solver/plotting libraries, and the rendering backends are confined to the
+canvas packages (``views/canvas3d``, ``views/canvas_plotly``). These tests never
+import the application modules; they read the source tree with ``ast`` so the
+import discipline can be checked without executing Qt or the solver.
+Directories that do not exist yet simply have nothing to walk, which trivially
+satisfies a "forbidden import" rule.
"""
from __future__ import annotations
@@ -16,7 +17,10 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "src" / "otko"
-CANVAS = SRC / "views" / "canvas3d"
+CANVAS_DIRS = (
+ SRC / "views" / "canvas3d",
+ SRC / "views" / "canvas_plotly",
+)
# Qt binding roots, in every spelling the project might use.
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
@@ -32,7 +36,7 @@ CORE_FORBIDDEN = QT_ROOTS | {
"matplotlib",
}
-# Only views/canvas3d/ may touch a 3D or plotting backend.
+# Only the canvas packages may touch a 3D or plotting backend.
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
@@ -75,14 +79,14 @@ def _violations(
forbidden: set[str],
*,
skip: tuple[Path, ...] = (),
- exclude_dir: Path | None = None,
+ exclude_dirs: tuple[Path, ...] = (),
) -> list[str]:
"""Return ``": "`` entries for every forbidden import found."""
found: list[str] = []
for path in _iter_python_files(root):
if path in skip:
continue
- if exclude_dir is not None and _is_within(path, exclude_dir):
+ if any(_is_within(path, directory) for directory in exclude_dirs):
continue
for module in sorted(_root_imports(path) & forbidden):
found.append(f"{path.relative_to(ROOT)}: {module}")
@@ -112,9 +116,9 @@ def test_architecture_views_no_solver() -> None:
def test_no_vtk_import_outside_canvas() -> None:
- """3D/plot backends stay under views/canvas3d/."""
- violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS)
- assert violations == [], f"backend imports outside views/canvas3d/: {violations}"
+ """3D/plot backends stay under the canvas packages."""
+ violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS)
+ assert violations == [], f"backend imports outside the canvas packages: {violations}"
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py
new file mode 100644
index 0000000..45b2b21
--- /dev/null
+++ b/tests/unit/test_plotly_trace_builder.py
@@ -0,0 +1,262 @@
+"""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"])
+
+
+# ── scene recipe (opstool-derived framing) ───────────────────────────────
+def test_axis_bounds_are_padded_around_the_model() -> None:
+ project = _load("cantilever")
+ scene = PlotlyTraceBuilder().build(project, SceneOptions())
+ points = np.array([node.coords for node in project.nodes], dtype=float)
+ for index, axis in enumerate(("x", "y", "z")):
+ low, high = scene.axis_bounds[axis]
+ assert low < points[:, index].min() or low <= points[:, index].min()
+ assert high > points[:, index].max() or high >= points[:, index].max()
+ assert scene.diagonal > 0
+
+
+def test_planar_model_gets_unit_slack_on_the_flat_axis() -> None:
+ project = _load("basic_truss")
+ scene = PlotlyTraceBuilder().build(project, SceneOptions())
+ low, high = scene.axis_bounds["z"]
+ assert high - low > 0 # a degenerate axis must not collapse the view
+
+
+def test_axis_overrides_pin_ranges_with_autorange_off() -> None:
+ scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions())
+ overrides = scene.axis_overrides()
+ assert set(overrides) == {"xaxis", "yaxis", "zaxis"}
+ for axis, override in overrides.items():
+ assert override["autorange"] is False
+ assert len(override["range"]) == 2
+ assert override["range"] == list(scene.axis_bounds[axis[0]])
+
+
+def test_hover_templates_identify_entities() -> None:
+ scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
+ (nodes,) = _traces(scene, "nodes")
+ (frames,) = _traces(scene, "elements")
+ assert nodes["hovertemplate"] == "Node #%{customdata}"
+ assert frames["hovertemplate"] == "Element #%{customdata}"
+ # Hover must not fall back to the raw-id "skip" mode.
+ assert "hoverinfo" not in nodes
+
+
+def test_axis_outline_flag_toggles_grid_and_ticks() -> None:
+ plain = RenderStyle()
+ outlined = RenderStyle(show_axis_outline=True)
+ project = _load("cantilever")
+ scene_plain = PlotlyTraceBuilder(plain).build(project, SceneOptions())
+ scene_outlined = PlotlyTraceBuilder(outlined).build(project, SceneOptions())
+
+ off_axis = scene_plain.layout["scene"]["xaxis"]
+ on_axis = scene_outlined.layout["scene"]["xaxis"]
+ assert off_axis["showgrid"] is False and off_axis["showticklabels"] is False
+ assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True
+ # The coloured axis lines stay visible either way (orientation cue).
+ assert off_axis["visible"] is True and on_axis["visible"] is True
diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py
new file mode 100644
index 0000000..c179eb7
--- /dev/null
+++ b/tests/unit/test_render_style.py
@@ -0,0 +1,117 @@
+"""Unit tests for the shared render style and its editable subset."""
+
+from __future__ import annotations
+
+import dataclasses
+
+import pytest
+
+from otko.core import ElasticBeamColumn, Node, Project, TrussElement, ZeroLengthElement
+from otko.views.canvas3d.style import (
+ EDITABLE_FIELDS,
+ SELECTED_STATE,
+ STYLE_FIELDS,
+ RenderStyle,
+ element_family_index,
+ family_palette,
+)
+
+
+def test_defaults_are_the_documented_palette() -> None:
+ style = RenderStyle()
+ assert style.node_color == "#d9d9d9"
+ assert style.selected_color == "#00ffff"
+ # opstool-derived element palette.
+ assert style.element_beam_color == "#0652ff"
+ assert style.element_truss_color == "#FF8C00"
+ assert style.element_link_color == "#39FF14"
+ assert len(style.response_scale_colors) == 10
+ assert style.response_scale_colors[0] == "#313695" # cold end
+ assert style.response_scale_colors[-1] == "#a50026" # warm end
+
+
+def test_with_updates_is_immutable() -> None:
+ original = RenderStyle()
+ updated = original.with_updates(node_color="#123456", label_font_size=20)
+ assert updated is not original
+ assert updated.node_color == "#123456"
+ assert updated.label_font_size == 20
+ # The source instance is untouched (frozen dataclass contract).
+ assert original.node_color == "#d9d9d9"
+ assert original.label_font_size == 12
+
+
+def test_editable_subset_matches_the_field_table() -> None:
+ style = RenderStyle()
+ assert RenderStyle.EDITABLE == EDITABLE_FIELDS
+ assert set(style.editable_values()) == set(EDITABLE_FIELDS)
+ # Every advertised field must exist on the dataclass with the right kind.
+ kinds = {name: kind for name, _label, kind in STYLE_FIELDS}
+ for name in EDITABLE_FIELDS:
+ assert hasattr(style, name), name
+ assert kinds[name] in {"color", "float", "int", "bool"}
+
+
+def test_response_colorscale_is_an_evenly_spaced_mapping() -> None:
+ style = RenderStyle()
+ scale = style.response_colorscale()
+ assert len(scale) == len(style.response_scale_colors)
+ assert scale[0] == (0.0, style.response_scale_colors[0])
+ assert scale[-1] == (1.0, style.response_scale_colors[-1])
+ positions = [position for position, _color in scale]
+ assert positions == sorted(positions)
+
+
+@pytest.mark.parametrize(
+ ("element", "expected"),
+ [
+ (ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 0),
+ (TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), 1),
+ (ZeroLengthElement(id=3, nodes=(1, 1), material_ids=(1,), dofs=(1,)), 2),
+ ],
+)
+def test_element_family_index(element: object, expected: int) -> None:
+ assert element_family_index(element) == expected
+
+
+def test_family_palette_order_and_selected_slot() -> None:
+ style = RenderStyle(node_color="#010101")
+ palette = family_palette(style)
+ assert palette == [
+ style.element_beam_color,
+ style.element_truss_color,
+ style.element_link_color,
+ style.selected_color,
+ ]
+ assert len(palette) - 1 == SELECTED_STATE
+
+
+def test_editable_values_round_trip_through_replace() -> None:
+ style = RenderStyle()
+ values = style.editable_values()
+ assert RenderStyle(**values) == style
+ # And the persisted shape is JSON-friendly (str/int/float only).
+ import json
+
+ assert json.loads(json.dumps(values)) == values
+
+
+def test_style_is_a_frozen_slots_dataclass() -> None:
+ assert dataclasses.is_dataclass(RenderStyle)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ RenderStyle().node_color = "#000000" # type: ignore[misc]
+
+
+def test_project_builder_helper_is_unused_but_valid() -> None:
+ """A style constructed from a project's elements classifies every family."""
+ project = Project(
+ ndm=2,
+ ndf=3,
+ nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
+ elements=[
+ ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
+ TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1),
+ ],
+ )
+ indices = [element_family_index(el) for el in project.elements]
+ assert indices == [0, 1]
diff --git a/tests/unit/test_renderer.py b/tests/unit/test_renderer.py
index 1586ca2..04529d6 100644
--- a/tests/unit/test_renderer.py
+++ b/tests/unit/test_renderer.py
@@ -24,6 +24,7 @@ from otko.views.canvas3d.model_renderer import (
RendererMode,
_classify_support,
)
+from otko.views.canvas3d.style import SELECTED_STATE
# ──────────────────────────── support classification ────────────────────────────
@@ -125,9 +126,13 @@ def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project
frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
# Nodes 1 and 3 selected → row 0 and row 2
assert node_states == [1, 0, 1]
- # Element 2 selected → it's the second frame (index 1 in frame_ids_ordered)
+ # Frame cells carry the palette slot: the element family normally, and
+ # SELECTED_STATE when selected.
selected_frame_idx = r._frame_id_to_row[2]
- assert frame_states[selected_frame_idx] == 1
+ assert frame_states[selected_frame_idx] == SELECTED_STATE
+ for index, family in enumerate(r._frame_family):
+ expected = SELECTED_STATE if index == selected_frame_idx else family
+ assert frame_states[index] == expected
def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]