feat/plotly-canvas #5

Merged
smill merged 15 commits from feat/plotly-canvas into main 2026-09-16 20:37:40 -04:00
32 changed files with 3428 additions and 67 deletions

View file

@ -62,7 +62,7 @@ jobs:
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \
libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb
- run: pip install -e ".[dev]"
- run: pip install -e ".[gui,dev]"
- run: xvfb-run -a pytest tests/gui -m "not slow"
test-integration:

4
.gitignore vendored
View file

@ -61,3 +61,7 @@ examples/ozan*.osmodel
examples/ozan_results/
/Ex*.csv
/Ex*.png
# BEGIN oh-my-opencode-slim clonedeps
.slim/clonedeps/repos/
# END oh-my-opencode-slim clonedeps

9
.ignore Normal file
View file

@ -0,0 +1,9 @@
# BEGIN oh-my-opencode-slim clonedeps
!.slim/
!.slim/clonedeps.json
!.slim/clonedeps/
!.slim/clonedeps/repos/
!.slim/clonedeps/repos/**
.slim/clonedeps/repos/**/.git/
.slim/clonedeps/repos/**/.git/**
# END oh-my-opencode-slim clonedeps

15
.slim/clonedeps.json Normal file
View file

@ -0,0 +1,15 @@
{
"version": "1.0.0",
"updatedAt": "2026-09-16T17:30:00.000Z",
"dependencies": [
{
"name": "opstool",
"resolvedVersion": "1.0.26",
"repoUrl": "https://github.com/yexiang92/opstool.git",
"ref": "v1.0.26",
"path": ".slim/clonedeps/repos/yexiang92__opstool",
"packagePath": "opstool/vis",
"reason": "Read-only source for evaluating whether to port opstool's PyVista/Plotly visualization settings into otko's canvas backends, or to depend on opstool.vis directly (GPL-3.0, compatible with otko's AGPL-3.0 via GPLv3 section 13)."
}
]
}

View file

@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
## Install
```bash
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
python -m otko # launch GUI (src/otko/app.py:run)
```
@ -19,6 +19,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.

15
NOTICE
View file

@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Ported / adapted code: `opstool`
The canvas element colour palette in `src/otko/views/canvas3d/style.py`
(per-family element colours and the diverging response colour scale) and the
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
adapted from the `opstool` project, which is distributed under the **GNU
General Public License v3.0**. opstool is Copyright © Yexiang Yan and
contributors.
In accordance with GPLv3 §5(a)/(b) this notice records that the material was
modified and adapted for OTKO. Combining the GPLv3-covered material with
OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is
conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the
opstool-derived portions.
## Runtime dependencies
OTKO depends on third-party software that is not covered by OTKO's

View file

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

View file

@ -31,6 +31,7 @@ import pyvista as pv
from otko.core import Project
from otko.services.element_forces import DiagramData, ForceComponent
from otko.views.canvas3d.style import RenderStyle
_LOG = logging.getLogger("otko.diagram")
@ -62,8 +63,9 @@ class DiagramRenderer:
- ``clear()`` removes the overlay.
"""
def __init__(self, plotter: Any) -> None:
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
self._actor: Any = None
self._label_actor: Any = None
@ -158,7 +160,9 @@ class DiagramRenderer:
self._actor = self._plotter.add_mesh(
mesh,
scalars="value",
cmap="coolwarm",
# Shared response palette (opstool's default diverging scale), so
# the PyVista and Plotly backends stay visually consistent.
cmap=list(self._style.response_scale_colors),
clim=(-vmax, vmax),
show_scalar_bar=True,
scalar_bar_args={"title": comp_label, "n_labels": 5},

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,26 @@
"""Plotly-backed canvas package (optional GUI backend).
``PlotlyCanvas`` is exported lazily (PEP 562): the pure
:mod:`~otko.views.canvas_plotly.trace_builder` must stay importable in the
headless test job, which has no Qt at all.
"""
from __future__ import annotations
from typing import Any
from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder,
Scene,
SceneOptions,
)
__all__ = ["PlotlyCanvas", "PlotlyTraceBuilder", "Scene", "SceneOptions"]
def __getattr__(name: str) -> Any:
if name == "PlotlyCanvas":
from otko.views.canvas_plotly.plotly_canvas import PlotlyCanvas
return PlotlyCanvas
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -0,0 +1,28 @@
"""Qt ↔ plotly.js bridge exposed through ``QWebChannel``.
The page calls :meth:`_Bridge.picked` / :meth:`_Bridge.snapClicked`; every
call is re-emitted here and wired to the canvas' own Qt signals.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, Signal, Slot
class _Bridge(QObject):
"""Slot surface the JavaScript side addresses as ``otkoBridge``."""
#: (kind, entity id, additive modifier held) where kind is node|element.
picked = Signal(str, int, bool)
#: World-space coordinates of a clicked grid intersection.
snapClicked = Signal(float, float, float)
@Slot(str, int, bool)
def onPicked(self, kind: str, entity_id: int, additive: bool) -> None:
"""JS entry point: a model entity (or snap target) was clicked."""
self.picked.emit(kind, int(entity_id), bool(additive))
@Slot(float, float, float)
def onSnapClicked(self, x: float, y: float, z: float) -> None:
"""JS entry point: a grid-intersection target was clicked."""
self.snapClicked.emit(float(x), float(y), float(z))

View file

@ -0,0 +1,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 = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
#plot { width: 100%; height: 100%; }
.modebar { display: none !important; }
</style>
</head>
<body>
<div id="plot"></div>
<script src="plotly.min.js"></script>
<script>
(function () {
var bridge = null;
var snapEnabled = false;
var handlersReady = false;
var hoverIndex = -1;
var config = {
responsive: true,
displayModeBar: false,
scrollZoom: true,
doubleClick: false,
displaylogo: false
};
new QWebChannel(qt.webChannelTransport, function (channel) {
bridge = channel.objects.otkoBridge;
window.otkoBridge = bridge;
});
function kindOf(point) {
var data = point && point.data;
return data && data.meta ? data.meta.kind : null;
}
function findHover() {
var gd = document.getElementById('plot');
hoverIndex = -1;
if (!gd || !gd.data) return;
for (var i = 0; i < gd.data.length; i++) {
var meta = gd.data[i] && gd.data[i].meta;
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
}
// A freshly pushed figure has an empty hover marker again.
hoverShown = false;
hoverPoint = null;
}
// --- snap-target marker ------------------------------------------------
// Updated synchronously but only when the visual state actually changes.
// Restyling on every hover/unhover made the plot redraw per mouse move,
// which re-fired hover and ended in a runaway redraw loop (RangeError:
// Maximum call stack size exceeded, frozen canvas). Timers were avoided
// deliberately: WebEngine throttles them to ~1 s when the page is not
// compositing, which made the preview lag.
var hoverShown = false;
var hoverPoint = null;
function samePoint(a, b) {
return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
function setHoverMarker(point) {
var want = snapEnabled ? point : null;
if (samePoint(want, hoverPoint) && !!want === hoverShown) return;
if (!want && !hoverShown) { hoverPoint = null; return; }
hoverPoint = want;
hoverShown = !!want;
if (hoverIndex < 0) return;
var x = want ? [want[0]] : [];
var y = want ? [want[1]] : [];
var z = want ? [want[2]] : [];
Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]);
}
// --- view preservation -------------------------------------------------
// `Plotly.react` resets any scene attribute the incoming layout omits, so a
// non-framing update carries the live camera/ranges forward. Only plain,
// validated copies are merged: the raw `_fullLayout` objects are owned by
// plotly (react mutates them in place), and injecting an undefined value
// would make every later react fail, wedging the plot.
function toPlain(value) {
try { return JSON.parse(JSON.stringify(value)); } catch (err) { return null; }
}
function currentView() {
var gd = document.getElementById('plot');
var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null;
if (!scene) return null;
var view = { ranges: {} };
var camera = toPlain(scene.camera);
if (camera && camera.eye && camera.center) {
view.camera = { eye: camera.eye, center: camera.center, up: camera.up };
if (camera.projection) view.camera.projection = camera.projection;
}
['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) {
var source = scene[axis];
if (!source || !Array.isArray(source.range) || source.range.length !== 2) return;
var low = Number(source.range[0]);
var high = Number(source.range[1]);
if (isFinite(low) && isFinite(high) && high > low) view.ranges[axis] = [low, high];
});
return view;
}
function mergeView(fig, view) {
try {
if (!view) return;
fig.layout.scene = fig.layout.scene || {};
if (view.camera) fig.layout.scene.camera = view.camera;
Object.keys(view.ranges).forEach(function (axis) {
fig.layout.scene[axis] = fig.layout.scene[axis] || {};
fig.layout.scene[axis].range = view.ranges[axis];
fig.layout.scene[axis].autorange = false;
});
} catch (err) {
// Never let view preservation block the figure update.
if (window.console) console.warn('otko: view merge skipped', err);
}
}
// --- update queue ------------------------------------------------------
// One react at a time, latest payload wins. Overlapping reacts on the same
// graph div are what left the plot unresponsive before.
var pendingUpdate = null;
var reactBusy = false;
function runUpdate() {
if (reactBusy || !pendingUpdate) return;
var job = pendingUpdate;
pendingUpdate = null;
reactBusy = true;
var fig;
try {
fig = JSON.parse(job.payload);
} catch (err) {
reactBusy = false;
if (window.console) console.error('otko: bad payload', err);
return;
}
mergeView(fig, job.preserve ? currentView() : null);
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
findHover();
installHandlers();
}, function (err) {
if (window.console) console.error('otko: react failed', err);
}).then(function () {
reactBusy = false;
runUpdate();
});
}
window.otkoUpdate = function (payloadJson, preserveView) {
pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
runUpdate();
};
function installHandlers() {
if (handlersReady) return;
var gd = document.getElementById('plot');
if (!gd || !gd.on) return;
handlersReady = true;
gd.on('plotly_click', function (ev) {
var pts = ev.points || [];
if (!pts.length || !bridge) return;
var p = pts[0];
var kind = kindOf(p);
var additive = !!(ev.event && (ev.event.shiftKey || ev.event.ctrlKey || ev.event.metaKey));
if (kind === 'node' || kind === 'element') {
bridge.onPicked(kind, p.customdata, additive);
} else if (kind === 'snap') {
var c = p.customdata;
bridge.onSnapClicked(c[0], c[1], c[2]);
}
});
gd.on('plotly_hover', function (ev) {
if (!snapEnabled) return;
var pts = ev.points || [];
if (!pts.length) return;
var p = pts[0];
if (kindOf(p) !== 'snap') { setHoverMarker(null); return; }
var c = p.customdata;
if (c) setHoverMarker([c[0], c[1], c[2]]);
});
gd.on('plotly_unhover', function () {
// Nothing can be visible unless snapping is armed, so stay out of the
// redraw path entirely for ordinary mouse movement.
if (!snapEnabled) return;
setHoverMarker(null);
});
}
window.otkoSetCamera = function (cameraJson) {
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
};
window.otkoSetSnapEnabled = function (on) {
snapEnabled = !!on;
if (!snapEnabled) setHoverMarker(null);
};
})();
</script>
</body>
</html>
"""
def runtime_url() -> str:
"""Ensure the JS runtime is on disk and return the page's file path.
Called once per :class:`~otko.views.canvas_plotly.PlotlyCanvas`; the
directory is reused and removed at interpreter exit.
"""
global _ASSETS_DIR
directory = _ensure_dir()
index = directory / "index.html"
if not index.exists():
(directory / "index.html").write_text(_PAGE, encoding="utf-8")
(directory / "plotly.min.js").write_text(_plotly_js(), encoding="utf-8")
return str(index)
def _ensure_dir() -> Path:
global _ASSETS_DIR
if _ASSETS_DIR is None:
_ASSETS_DIR = Path(tempfile.mkdtemp(prefix="otko-plotly-"))
atexit.register(shutil.rmtree, _ASSETS_DIR, True)
return _ASSETS_DIR
def _plotly_js() -> str:
"""The offline plotly.js bundle shipped inside the ``plotly`` package."""
from plotly.offline import get_plotlyjs
return get_plotlyjs()

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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)
# ── 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.
self._diagram_renderer = DiagramRenderer(self._canvas)
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)

View file

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

View file

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

View file

@ -11,6 +11,7 @@ domain after every test. The import is inside the teardown so that
from __future__ import annotations
import importlib.util
import os
from collections.abc import Iterator
@ -19,6 +20,27 @@ import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@pytest.fixture(autouse=True, scope="session")
def _isolate_qsettings(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
"""Redirect ``QSettings`` to a temp dir so tests never touch user state.
``MainWindow`` persists preferences (window layout, canvas backend) under
``QSettings("OTKO", "OTKO")``. Without redirection a developer who, say,
switched to the Plotly backend would see PyVista-specific GUI tests fail
against their real settings. Skipped entirely when Qt is not installed, so
the headless job stays free of Qt.
"""
if importlib.util.find_spec("PySide6") is None:
yield
return
from PySide6.QtCore import QSettings
settings_dir = tmp_path_factory.mktemp("qsettings")
for fmt in (QSettings.Format.NativeFormat, QSettings.Format.IniFormat):
QSettings.setPath(fmt, QSettings.Scope.UserScope, str(settings_dir))
yield
@pytest.fixture(autouse=True)
def _wipe_opensees_domain() -> Iterator[None]:
"""Wipe the OpenseesPy domain after a test if the solver was imported."""

View file

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

View file

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

View file

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

View file

@ -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(<json>, <flag>)`` 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"),
]

View file

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

View file

@ -0,0 +1,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}<extra></extra>"
assert frames["hovertemplate"] == "Element #%{customdata}<extra></extra>"
# 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

View file

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

View file

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