Merge pull request 'feat/plotly-canvas' (#5) from feat/plotly-canvas into main
Some checks failed
CI / lint (push) Has been cancelled
CI / type (push) Has been cancelled
CI / test-headless (macos-latest, 3.10) (push) Has been cancelled
CI / test-headless (macos-latest, 3.11) (push) Has been cancelled
CI / test-headless (macos-latest, 3.12) (push) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test-headless (windows-latest, 3.10) (push) Has been cancelled
CI / test-headless (windows-latest, 3.11) (push) Has been cancelled
CI / test-headless (windows-latest, 3.12) (push) Has been cancelled
CI / test-gui (push) Has been cancelled
CI / test-integration (macos-latest) (push) Has been cancelled
CI / test-integration (ubuntu-latest) (push) Has been cancelled
CI / test-integration (windows-latest) (push) Has been cancelled
Some checks failed
CI / lint (push) Has been cancelled
CI / type (push) Has been cancelled
CI / test-headless (macos-latest, 3.10) (push) Has been cancelled
CI / test-headless (macos-latest, 3.11) (push) Has been cancelled
CI / test-headless (macos-latest, 3.12) (push) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test-headless (windows-latest, 3.10) (push) Has been cancelled
CI / test-headless (windows-latest, 3.11) (push) Has been cancelled
CI / test-headless (windows-latest, 3.12) (push) Has been cancelled
CI / test-gui (push) Has been cancelled
CI / test-integration (macos-latest) (push) Has been cancelled
CI / test-integration (ubuntu-latest) (push) Has been cancelled
CI / test-integration (windows-latest) (push) Has been cancelled
Reviewed-on: #5
This commit is contained in:
commit
7839f05384
32 changed files with 3428 additions and 67 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
9
.ignore
Normal 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
15
.slim/clonedeps.json
Normal 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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
18
AGENTS.md
18
AGENTS.md
|
|
@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
|
|||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools
|
||||
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
|
||||
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
|
||||
python -m otko # launch GUI (src/otko/app.py:run)
|
||||
```
|
||||
|
|
@ -19,6 +19,8 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
|
|||
- `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.**
|
||||
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
|
||||
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
|
||||
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`.
|
||||
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
|
||||
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
|
||||
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
|
||||
|
||||
|
|
@ -56,3 +58,17 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git
|
|||
- `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip).
|
||||
- Projects persist as single Pydantic-validated JSON `.osmodel` (diffable); analysis output goes to `*.osresults.h5` (HDF5, one group per case).
|
||||
- Quick smoke: open `examples/cantilever.osmodel` → run `Tip-Load` → M3 peaks 50 kN·m at fixed end.
|
||||
|
||||
## Cloned Dependency Source
|
||||
|
||||
Read-only dependency source repositories are available under
|
||||
`.slim/clonedeps/repos/` for inspection. Do not edit these clones. The
|
||||
structured manifest is `.slim/clonedeps.json`.
|
||||
|
||||
- `.slim/clonedeps/repos/yexiang92__opstool/` — `yexiang92/opstool` at `v1.0.26`; the OpenSeesPy
|
||||
pre/post-processor whose PyVista and Plotly visualization settings
|
||||
(`opstool/vis/{pyvista,plotly}/plot_utils.py`, `plot_resp_base.py`, `vis_model.py`) are the
|
||||
reference for otko's canvas look-and-feel. GPL-3.0, and GPLv3 §13 explicitly permits combining
|
||||
it with otko's AGPL-3.0. Any code actually ported from it must keep the opstool copyright notice
|
||||
and record that it was modified (GPLv3 §5a/b) — add that entry to `NOTICE` when the port lands.
|
||||
|
||||
|
|
|
|||
15
NOTICE
15
NOTICE
|
|
@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Ported / adapted code: `opstool`
|
||||
|
||||
The canvas element colour palette in `src/otko/views/canvas3d/style.py`
|
||||
(per-family element colours and the diverging response colour scale) and the
|
||||
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
|
||||
adapted from the `opstool` project, which is distributed under the **GNU
|
||||
General Public License v3.0**. opstool is Copyright © Yexiang Yan and
|
||||
contributors.
|
||||
|
||||
In accordance with GPLv3 §5(a)/(b) this notice records that the material was
|
||||
modified and adapted for OTKO. Combining the GPLv3-covered material with
|
||||
OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is
|
||||
conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the
|
||||
opstool-derived portions.
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
OTKO depends on third-party software that is not covered by OTKO's
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ gui = [
|
|||
"vtk>=9.3",
|
||||
"pyqtgraph>=0.13",
|
||||
"imageio[ffmpeg]>=2.34",
|
||||
"plotly>=5.18",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.4",
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
92
src/otko/views/canvas_base.py
Normal file
92
src/otko/views/canvas_base.py
Normal 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: ...
|
||||
26
src/otko/views/canvas_plotly/__init__.py
Normal file
26
src/otko/views/canvas_plotly/__init__.py
Normal 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}")
|
||||
28
src/otko/views/canvas_plotly/bridge.py
Normal file
28
src/otko/views/canvas_plotly/bridge.py
Normal 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))
|
||||
276
src/otko/views/canvas_plotly/html.py
Normal file
276
src/otko/views/canvas_plotly/html.py
Normal 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()
|
||||
417
src/otko/views/canvas_plotly/plotly_canvas.py
Normal file
417
src/otko/views/canvas_plotly/plotly_canvas.py
Normal 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"
|
||||
1028
src/otko/views/canvas_plotly/trace_builder.py
Normal file
1028
src/otko/views/canvas_plotly/trace_builder.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
150
src/otko/views/dialogs/plot_properties.py
Normal file
150
src/otko/views/dialogs/plot_properties.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,16 +13,21 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
from PySide6.QtGui import QCloseEvent, QIcon
|
||||
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||
from PySide6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
|
||||
|
||||
from otko.viewmodels import AnalysisRunner, ProjectViewModel
|
||||
from otko.views.action_handlers import ActionHandlers
|
||||
from otko.views.canvas3d import ModelCanvas
|
||||
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
||||
from otko.views.canvas3d.selection import SelectionState
|
||||
from otko.views.canvas3d.style import RenderStyle
|
||||
from otko.views.canvas_base import plotly_available
|
||||
from otko.views.dock_manager import DockManager
|
||||
from otko.views.menu_builder import MenuBuilder
|
||||
from otko.views.render_controls import RenderControls
|
||||
|
|
@ -34,6 +39,9 @@ from otko.views.tools import (
|
|||
ToolController,
|
||||
)
|
||||
|
||||
#: Canvas backends, in menu order.
|
||||
CANVAS_BACKENDS = ("pyvista", "plotly")
|
||||
|
||||
|
||||
class MainWindow(
|
||||
QMainWindow,
|
||||
|
|
@ -44,7 +52,7 @@ class MainWindow(
|
|||
):
|
||||
"""Top-level application shell."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, settings: QSettings | None = None) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("OTKO")
|
||||
self.setWindowIcon(
|
||||
|
|
@ -60,12 +68,22 @@ class MainWindow(
|
|||
self._show_node_labels = False
|
||||
self._show_element_labels = False
|
||||
|
||||
self._build_central_canvas()
|
||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
||||
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
||||
self._draw_node_tool: DrawNodeTool | None = None
|
||||
self._draw_truss_tool: DrawTrussTool | None = None
|
||||
# One selection state shared by every canvas backend so a live swap
|
||||
# keeps the current selection (canvas, tree, table, properties dock).
|
||||
self._selection = SelectionState(self)
|
||||
|
||||
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
||||
self._canvas_backend = self._stored_canvas_backend()
|
||||
#: Shared, user-editable visual style (both backends read it).
|
||||
self._style = self._load_plot_style()
|
||||
self._canvases: dict[str, Any] = {}
|
||||
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
||||
# A stack holds every backend's canvas: swapping is just
|
||||
# setCurrentWidget, so no backend widget is ever destroyed mid-session.
|
||||
self._canvas_stack = QStackedWidget(self)
|
||||
self.setCentralWidget(self._canvas_stack)
|
||||
self._activate_canvas(self._canvas_backend)
|
||||
self._build_tooling()
|
||||
|
||||
self._build_docks()
|
||||
self._build_actions()
|
||||
|
|
@ -77,19 +95,164 @@ class MainWindow(
|
|||
self._refresh_action_enablement()
|
||||
self.restore_layout()
|
||||
|
||||
# ── construction ─────────────────────────────────────────────────
|
||||
def _build_central_canvas(self) -> None:
|
||||
self._canvas = ModelCanvas(self)
|
||||
self.setCentralWidget(self._canvas)
|
||||
# Diagram overlay paints onto the same plotter as the model.
|
||||
self._diagram_renderer = DiagramRenderer(self._canvas)
|
||||
# ── canvas backend ───────────────────────────────────────────────
|
||||
def _stored_canvas_backend(self) -> str:
|
||||
"""The backend chosen last session, falling back to PyVista."""
|
||||
stored = self._settings.value("canvas/backend", "pyvista")
|
||||
name = str(stored) if stored is not None else "pyvista"
|
||||
if name == "plotly" and plotly_available():
|
||||
return "plotly"
|
||||
return "pyvista"
|
||||
|
||||
def _ensure_canvas(self, backend: str) -> Any:
|
||||
"""Create a backend's canvas on first use and cache it.
|
||||
|
||||
Both canvases are kept alive and swapped with ``setCentralWidget``
|
||||
rather than destroyed: tearing a VTK render window down mid-session
|
||||
leaves dangling make-current callbacks, and the widgets are cheap to
|
||||
retain once built.
|
||||
"""
|
||||
canvas = self._canvases.get(backend)
|
||||
if canvas is not None:
|
||||
return canvas
|
||||
if backend == "plotly":
|
||||
# Imported lazily so a plotly-less install still boots PyVista.
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
canvas = PlotlyCanvas(
|
||||
self,
|
||||
style=self._style,
|
||||
selection=self._selection,
|
||||
log=self._log_canvas_message,
|
||||
)
|
||||
else:
|
||||
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
|
||||
self._canvases[backend] = canvas
|
||||
return canvas
|
||||
|
||||
def _activate_canvas(self, backend: str) -> None:
|
||||
"""Make the backend's canvas the central widget and rewire overlays."""
|
||||
if backend not in CANVAS_BACKENDS:
|
||||
backend = "pyvista"
|
||||
canvas = self._ensure_canvas(backend)
|
||||
if self._canvas_stack.indexOf(canvas) < 0:
|
||||
self._canvas_stack.addWidget(canvas)
|
||||
self._canvas_stack.setCurrentWidget(canvas)
|
||||
self._canvas = canvas
|
||||
self._canvas_backend = backend
|
||||
if backend == "pyvista":
|
||||
renderer = self._diagram_renderers.get("pyvista")
|
||||
if renderer is None:
|
||||
# Diagram overlay paints onto the same plotter as the model.
|
||||
renderer = DiagramRenderer(canvas)
|
||||
self._diagram_renderers["pyvista"] = renderer
|
||||
self._diagram_renderer = renderer
|
||||
else:
|
||||
self._diagram_renderer = None
|
||||
|
||||
def canvas_backend(self) -> str:
|
||||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||
return self._canvas_backend
|
||||
|
||||
def _log_canvas_message(self, message: str, severity: str = "info") -> None:
|
||||
"""Route a JS console line from the Plotly page to the Console dock.
|
||||
|
||||
The dock may not exist yet while canvases are being constructed, so
|
||||
the lookup is guarded.
|
||||
"""
|
||||
dock = getattr(self, "_console_dock", None)
|
||||
if dock is not None:
|
||||
dock.log(message, severity=severity)
|
||||
|
||||
# ── plot style ───────────────────────────────────────────────────
|
||||
def _load_plot_style(self) -> RenderStyle:
|
||||
"""Style from the last session, ignoring anything unrecognised."""
|
||||
raw = self._settings.value("plot/props")
|
||||
if not raw:
|
||||
return RenderStyle()
|
||||
try:
|
||||
data = json.loads(str(raw))
|
||||
except ValueError:
|
||||
return RenderStyle()
|
||||
if not isinstance(data, dict):
|
||||
return RenderStyle()
|
||||
allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
|
||||
try:
|
||||
return RenderStyle(**allowed)
|
||||
except (TypeError, ValueError):
|
||||
return RenderStyle()
|
||||
|
||||
def _save_plot_style(self) -> None:
|
||||
self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
|
||||
|
||||
def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
|
||||
"""Apply a style to every canvas (live preview when ``persist`` is off)."""
|
||||
self._style = style
|
||||
for canvas in self._canvases.values():
|
||||
canvas.set_style(style)
|
||||
if persist:
|
||||
self._save_plot_style()
|
||||
|
||||
def _build_tooling(self) -> None:
|
||||
"""(Re)build the tool controller and tools against the current canvas."""
|
||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
||||
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
||||
self._draw_node_tool: DrawNodeTool | None = None
|
||||
self._draw_truss_tool: DrawTrussTool | None = None
|
||||
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
||||
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
|
||||
|
||||
def swap_canvas_backend(self, backend: str) -> None:
|
||||
"""Switch the live canvas backend, preserving model/selection state.
|
||||
|
||||
The camera pose is intentionally re-framed rather than transferred:
|
||||
the two engines have different camera models, and a wrong pose is
|
||||
worse than a clean isometric reset.
|
||||
"""
|
||||
if backend not in CANVAS_BACKENDS or backend == self._canvas_backend:
|
||||
return
|
||||
if backend == "plotly" and not plotly_available():
|
||||
return
|
||||
|
||||
project = self._vm.project
|
||||
plane = self._canvas.working_plane_type()
|
||||
offset = self._canvas.working_plane_offset()
|
||||
active_name = getattr(self._tool_controller.active, "name", None)
|
||||
|
||||
self._activate_canvas(backend)
|
||||
self._build_tooling()
|
||||
|
||||
# Project → re-frames the camera; working plane rebuilds the grid.
|
||||
self._canvas.show_project(project)
|
||||
if plane is not None and offset is not None:
|
||||
self._canvas.set_working_plane(plane, offset)
|
||||
# Re-apply the overlays whose state lives on the toolbar actions.
|
||||
self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked())
|
||||
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
|
||||
self._canvas.set_display_options(
|
||||
show_node_labels=self._show_node_labels,
|
||||
show_element_labels=self._show_element_labels,
|
||||
)
|
||||
# Keep the active tool (and its snap preview) alive across the swap.
|
||||
# Re-entering through the action handlers recreates the tool lazily
|
||||
# against the new canvas.
|
||||
if active_name == "Draw Node":
|
||||
self._on_draw_node_tool()
|
||||
elif active_name == "Draw Frame":
|
||||
self._on_draw_frame_tool()
|
||||
elif active_name == "Draw Truss":
|
||||
self._on_draw_truss_tool()
|
||||
|
||||
self._settings.setValue("canvas/backend", self._canvas_backend)
|
||||
self._sync_canvas_backend_actions()
|
||||
self._refresh_action_enablement()
|
||||
|
||||
# ── layout persistence ───────────────────────────────────────────
|
||||
def save_layout(self) -> None:
|
||||
"""Persist window geometry and dock/toolbar state for next launch."""
|
||||
settings = QSettings("OTKO", "OTKO")
|
||||
settings.setValue("geometry", self.saveGeometry())
|
||||
settings.setValue("windowState", self.saveState())
|
||||
self._settings.setValue("geometry", self.saveGeometry())
|
||||
self._settings.setValue("windowState", self.saveState())
|
||||
|
||||
def restore_layout(self) -> None:
|
||||
"""Restore a previously saved geometry/dock layout, if any.
|
||||
|
|
@ -97,11 +260,10 @@ class MainWindow(
|
|||
On first run no values are stored, so the defaults from ``__init__``
|
||||
(``resize`` + dock construction) stay in effect.
|
||||
"""
|
||||
settings = QSettings("OTKO", "OTKO")
|
||||
geometry = settings.value("geometry")
|
||||
geometry = self._settings.value("geometry")
|
||||
if geometry is not None:
|
||||
self.restoreGeometry(geometry)
|
||||
state = settings.value("windowState")
|
||||
state = self._settings.value("windowState")
|
||||
if state is not None:
|
||||
self.restoreState(state)
|
||||
|
||||
|
|
@ -147,6 +309,7 @@ class MainWindow(
|
|||
self._act_about.triggered.connect(self._on_about)
|
||||
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
||||
self._act_set_units.triggered.connect(self._on_set_units)
|
||||
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
||||
|
||||
# Edit
|
||||
self._act_delete.triggered.connect(self._on_delete)
|
||||
|
|
@ -161,9 +324,12 @@ class MainWindow(
|
|||
self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool)
|
||||
self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_tool)
|
||||
self._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool)
|
||||
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
||||
# Keep the Tools toolbar checked state in sync with the controller.
|
||||
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
|
||||
# toolChanged → toolbar/menu sync is wired in _build_tooling(), which
|
||||
# also runs on a canvas-backend swap (then a fresh controller exists).
|
||||
|
||||
# Canvas backend
|
||||
self._act_canvas_pyvista.triggered.connect(lambda: self.swap_canvas_backend("pyvista"))
|
||||
self._act_canvas_plotly.triggered.connect(lambda: self.swap_canvas_backend("plotly"))
|
||||
|
||||
# Define
|
||||
self._act_grid.triggered.connect(self._on_grid_system)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
122
tests/gui/test_canvas_backend_switch.py
Normal file
122
tests/gui/test_canvas_backend_switch.py
Normal 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")
|
||||
131
tests/gui/test_plot_properties.py
Normal file
131
tests/gui/test_plot_properties.py
Normal 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()
|
||||
93
tests/gui/test_plotly_hover.py
Normal file
93
tests/gui/test_plotly_hover.py
Normal 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"
|
||||
123
tests/gui/test_plotly_view_preservation.py
Normal file
123
tests/gui/test_plotly_view_preservation.py
Normal 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"),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
262
tests/unit/test_plotly_trace_builder.py
Normal file
262
tests/unit/test_plotly_trace_builder.py
Normal 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
|
||||
117
tests/unit/test_render_style.py
Normal file
117
tests/unit/test_render_style.py
Normal 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]
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue