Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17d2ed21d3 | |||
| 40a673c562 | |||
| d09e72e95d | |||
| 9a1baa07c2 | |||
| 804bf22f61 | |||
| fc5971ad3d | |||
| f99b3a3efd |
24 changed files with 2317 additions and 41 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-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
|
||||||
libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \
|
libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \
|
||||||
libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb
|
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"
|
- run: xvfb-run -a pytest tests/gui -m "not slow"
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -61,3 +61,7 @@ examples/ozan*.osmodel
|
||||||
examples/ozan_results/
|
examples/ozan_results/
|
||||||
/Ex*.csv
|
/Ex*.csv
|
||||||
/Ex*.png
|
/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)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```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)
|
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
|
||||||
python -m otko # launch GUI (src/otko/app.py:run)
|
python -m otko # launch GUI (src/otko/app.py:run)
|
||||||
```
|
```
|
||||||
|
|
@ -19,6 +19,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
|
||||||
- `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.**
|
- `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.**
|
- `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/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
|
||||||
|
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless.
|
||||||
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
|
- `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).
|
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
|
||||||
|
|
||||||
|
|
@ -56,3 +57,17 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git
|
||||||
- `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip).
|
- `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).
|
- 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.
|
- 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.
|
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
|
## Runtime dependencies
|
||||||
|
|
||||||
OTKO depends on third-party software that is not covered by OTKO's
|
OTKO depends on third-party software that is not covered by OTKO's
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ gui = [
|
||||||
"vtk>=9.3",
|
"vtk>=9.3",
|
||||||
"pyqtgraph>=0.13",
|
"pyqtgraph>=0.13",
|
||||||
"imageio[ffmpeg]>=2.34",
|
"imageio[ffmpeg]>=2.34",
|
||||||
|
"plotly>=5.18",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.4",
|
"pytest>=7.4",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import pyvista as pv
|
||||||
|
|
||||||
from otko.core import Project
|
from otko.core import Project
|
||||||
from otko.services.element_forces import DiagramData, ForceComponent
|
from otko.services.element_forces import DiagramData, ForceComponent
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
|
||||||
_LOG = logging.getLogger("otko.diagram")
|
_LOG = logging.getLogger("otko.diagram")
|
||||||
|
|
||||||
|
|
@ -62,8 +63,9 @@ class DiagramRenderer:
|
||||||
- ``clear()`` removes the overlay.
|
- ``clear()`` removes the overlay.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plotter: Any) -> None:
|
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
|
||||||
self._plotter = plotter
|
self._plotter = plotter
|
||||||
|
self._style = style or RenderStyle()
|
||||||
self._actor: Any = None
|
self._actor: Any = None
|
||||||
self._label_actor: Any = None
|
self._label_actor: Any = None
|
||||||
|
|
||||||
|
|
@ -158,7 +160,9 @@ class DiagramRenderer:
|
||||||
self._actor = self._plotter.add_mesh(
|
self._actor = self._plotter.add_mesh(
|
||||||
mesh,
|
mesh,
|
||||||
scalars="value",
|
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),
|
clim=(-vmax, vmax),
|
||||||
show_scalar_bar=True,
|
show_scalar_bar=True,
|
||||||
scalar_bar_args={"title": comp_label, "n_labels": 5},
|
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.model_renderer import ModelRenderer
|
||||||
from otko.views.canvas3d.selection import SelectionState
|
from otko.views.canvas3d.selection import SelectionState
|
||||||
from otko.views.canvas3d.style import RenderStyle
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
from otko.views.canvas_base import CanvasCapabilities
|
||||||
|
|
||||||
PICK_DEBUG = False
|
PICK_DEBUG = False
|
||||||
|
|
||||||
|
|
@ -31,6 +32,9 @@ PICK_DEBUG = False
|
||||||
class ModelCanvas(QtInteractor): # type: ignore[misc]
|
class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||||
"""The central 3D viewport widget."""
|
"""The central 3D viewport widget."""
|
||||||
|
|
||||||
|
#: VTK supports every optional overlay layer.
|
||||||
|
capabilities = CanvasCapabilities()
|
||||||
|
|
||||||
# Convenience signals re-emitted from SelectionState.
|
# Convenience signals re-emitted from SelectionState.
|
||||||
nodePicked = Signal(int)
|
nodePicked = Signal(int)
|
||||||
elementPicked = Signal(int)
|
elementPicked = Signal(int)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,31 @@ class RenderStyle:
|
||||||
zerolength_color: str = "#a020f0"
|
zerolength_color: str = "#a020f0"
|
||||||
selected_color: str = "#00d4ff"
|
selected_color: str = "#00d4ff"
|
||||||
|
|
||||||
|
# ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
|
||||||
|
# opstool colours wireframe elements by *family* rather than painting
|
||||||
|
# every frame the same colour, which makes mixed beam/truss/link models
|
||||||
|
# readable at a glance. Values come from ``PLOT_ARGS_DEFAULT`` in
|
||||||
|
# ``opstool/vis/{pyvista,plotly}/plot_utils.py``; see ``NOTICE``.
|
||||||
|
element_beam_color: str = "#0652ff" # beam-column family
|
||||||
|
element_truss_color: str = "#FF8C00" # truss family
|
||||||
|
element_link_color: str = "#39FF14" # zero-length / link family
|
||||||
|
|
||||||
|
#: Diverging scale for scalar response overlays (force diagrams today,
|
||||||
|
#: nodal / element response plots later): blue → red, evenly spaced.
|
||||||
|
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
|
||||||
|
response_scale_colors: tuple[str, ...] = (
|
||||||
|
"#313695",
|
||||||
|
"#4575b4",
|
||||||
|
"#74add1",
|
||||||
|
"#abd9e9",
|
||||||
|
"#e0f3f8",
|
||||||
|
"#fee090",
|
||||||
|
"#fdae61",
|
||||||
|
"#f46d43",
|
||||||
|
"#d73027",
|
||||||
|
"#a50026",
|
||||||
|
)
|
||||||
|
|
||||||
fix_color: str = "#c0392b" # firebrick
|
fix_color: str = "#c0392b" # firebrick
|
||||||
pin_color: str = "#c0392b"
|
pin_color: str = "#c0392b"
|
||||||
roller_color: str = "#e67e22"
|
roller_color: str = "#e67e22"
|
||||||
|
|
@ -49,3 +74,13 @@ class RenderStyle:
|
||||||
load_min_length: float = 0.5
|
load_min_length: float = 0.5
|
||||||
|
|
||||||
selection_thickness_factor: float = 1.6 # multiplier for selected actors
|
selection_thickness_factor: float = 1.6 # multiplier for selected actors
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────
|
||||||
|
def response_colorscale(self) -> list[tuple[float, str]]:
|
||||||
|
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
|
||||||
|
count = len(self.response_scale_colors)
|
||||||
|
if count < 2:
|
||||||
|
return [(0.0, self.response_scale_colors[0])]
|
||||||
|
return [
|
||||||
|
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
|
||||||
|
]
|
||||||
|
|
|
||||||
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))
|
||||||
175
src/otko/views/canvas_plotly/html.py
Normal file
175
src/otko/views/canvas_plotly/html.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""HTML/JS runtime for the Plotly canvas.
|
||||||
|
|
||||||
|
The page is materialised once per process into a temp directory and loaded
|
||||||
|
from ``file://``: the plotly.js bundle is ~5 MB, which is past
|
||||||
|
``QWebEngineView.setHtml``'s data-URL limit, and writing it to disk also lets
|
||||||
|
the browser cache it across figure updates.
|
||||||
|
|
||||||
|
The JS side exposes three entry points to Python (called via
|
||||||
|
``QWebEnginePage.runJavaScript``):
|
||||||
|
|
||||||
|
- ``otkoUpdate(payloadJson)`` — replace data + layout with ``Plotly.react``,
|
||||||
|
which diffs client-side and leaves the interactive camera untouched.
|
||||||
|
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
|
||||||
|
parallel-projection toggle).
|
||||||
|
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview.
|
||||||
|
|
||||||
|
Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
|
||||||
|
entity picks carry the trace ``meta.kind`` and the point ``customdata``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: Materialised runtime directory (plotly.min.js + index.html + qwebchannel.js).
|
||||||
|
_ASSETS_DIR: Path | None = None
|
||||||
|
|
||||||
|
# ``qrc:///qtwebchannel/qwebchannel.js`` is served by QtWebEngine's internal
|
||||||
|
# resource scheme; it is not reachable through QFile.
|
||||||
|
_PAGE = """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
|
||||||
|
#plot { width: 100%; height: 100%; }
|
||||||
|
.modebar { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="plot"></div>
|
||||||
|
<script src="plotly.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var bridge = null;
|
||||||
|
var snapEnabled = false;
|
||||||
|
var handlersReady = false;
|
||||||
|
var hoverIndex = -1;
|
||||||
|
|
||||||
|
var config = {
|
||||||
|
responsive: true,
|
||||||
|
displayModeBar: false,
|
||||||
|
scrollZoom: true,
|
||||||
|
doubleClick: false,
|
||||||
|
displaylogo: false
|
||||||
|
};
|
||||||
|
|
||||||
|
new QWebChannel(qt.webChannelTransport, function (channel) {
|
||||||
|
bridge = channel.objects.otkoBridge;
|
||||||
|
window.otkoBridge = bridge;
|
||||||
|
});
|
||||||
|
|
||||||
|
function kindOf(point) {
|
||||||
|
var data = point && point.data;
|
||||||
|
return data && data.meta ? data.meta.kind : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHover() {
|
||||||
|
var gd = document.getElementById('plot');
|
||||||
|
hoverIndex = -1;
|
||||||
|
if (!gd || !gd.data) return;
|
||||||
|
for (var i = 0; i < gd.data.length; i++) {
|
||||||
|
var meta = gd.data[i] && gd.data[i].meta;
|
||||||
|
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHover(x, y, z) {
|
||||||
|
if (hoverIndex < 0) return;
|
||||||
|
Plotly.restyle('plot', { x: [[x]], y: [[y]], z: [[z]] }, [hoverIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHover() {
|
||||||
|
if (hoverIndex < 0) return;
|
||||||
|
Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installHandlers() {
|
||||||
|
if (handlersReady) return;
|
||||||
|
var gd = document.getElementById('plot');
|
||||||
|
if (!gd || !gd.on) return;
|
||||||
|
handlersReady = true;
|
||||||
|
|
||||||
|
gd.on('plotly_click', function (ev) {
|
||||||
|
var pts = ev.points || [];
|
||||||
|
if (!pts.length || !bridge) return;
|
||||||
|
var p = pts[0];
|
||||||
|
var kind = kindOf(p);
|
||||||
|
var additive = !!(ev.event && (ev.event.shiftKey || ev.event.ctrlKey || ev.event.metaKey));
|
||||||
|
if (kind === 'node' || kind === 'element') {
|
||||||
|
bridge.onPicked(kind, p.customdata, additive);
|
||||||
|
} else if (kind === 'snap') {
|
||||||
|
var c = p.customdata;
|
||||||
|
bridge.onSnapClicked(c[0], c[1], c[2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gd.on('plotly_hover', function (ev) {
|
||||||
|
if (!snapEnabled) return;
|
||||||
|
var pts = ev.points || [];
|
||||||
|
if (!pts.length) return;
|
||||||
|
var p = pts[0];
|
||||||
|
if (kindOf(p) !== 'snap') { clearHover(); return; }
|
||||||
|
var c = p.customdata;
|
||||||
|
setHover(c[0], c[1], c[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
gd.on('plotly_unhover', function () { clearHover(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.otkoUpdate = function (payloadJson) {
|
||||||
|
var fig = JSON.parse(payloadJson);
|
||||||
|
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
|
||||||
|
findHover();
|
||||||
|
installHandlers();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.otkoSetCamera = function (cameraJson) {
|
||||||
|
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.otkoSetSnapEnabled = function (on) {
|
||||||
|
snapEnabled = !!on;
|
||||||
|
if (!snapEnabled) clearHover();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_url() -> str:
|
||||||
|
"""Ensure the JS runtime is on disk and return the page's file path.
|
||||||
|
|
||||||
|
Called once per :class:`~otko.views.canvas_plotly.PlotlyCanvas`; the
|
||||||
|
directory is reused and removed at interpreter exit.
|
||||||
|
"""
|
||||||
|
global _ASSETS_DIR
|
||||||
|
directory = _ensure_dir()
|
||||||
|
index = directory / "index.html"
|
||||||
|
if not index.exists():
|
||||||
|
(directory / "index.html").write_text(_PAGE, encoding="utf-8")
|
||||||
|
(directory / "plotly.min.js").write_text(_plotly_js(), encoding="utf-8")
|
||||||
|
return str(index)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dir() -> Path:
|
||||||
|
global _ASSETS_DIR
|
||||||
|
if _ASSETS_DIR is None:
|
||||||
|
_ASSETS_DIR = Path(tempfile.mkdtemp(prefix="otko-plotly-"))
|
||||||
|
atexit.register(shutil.rmtree, _ASSETS_DIR, True)
|
||||||
|
return _ASSETS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _plotly_js() -> str:
|
||||||
|
"""The offline plotly.js bundle shipped inside the ``plotly`` package."""
|
||||||
|
from plotly.offline import get_plotlyjs
|
||||||
|
|
||||||
|
return get_plotlyjs()
|
||||||
353
src/otko/views/canvas_plotly/plotly_canvas.py
Normal file
353
src/otko/views/canvas_plotly/plotly_canvas.py
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
"""Plotly-backed 3D canvas.
|
||||||
|
|
||||||
|
A ``QWebEngineView`` hosting plotly.js, driven over ``QWebChannel``. It
|
||||||
|
implements the same public surface as :class:`otko.views.canvas3d.ModelCanvas`
|
||||||
|
(signals, selection, working plane, view presets, display toggles) so
|
||||||
|
``MainWindow`` can swap the two at runtime.
|
||||||
|
|
||||||
|
Update strategy: ``Plotly.react`` diffs client-side, and the layout only
|
||||||
|
carries ``scene.camera`` when a view preset or the projection toggle asks for
|
||||||
|
it — so re-rendering on a model edit or selection change never yanks the
|
||||||
|
camera the user is orbiting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QUrl, Signal
|
||||||
|
from PySide6.QtWebChannel import QWebChannel
|
||||||
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||||
|
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from otko.views.canvas3d.model_renderer import RendererMode
|
||||||
|
from otko.views.canvas3d.selection import SelectionState
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
from otko.views.canvas_base import CanvasCapabilities
|
||||||
|
from otko.views.canvas_plotly import html as _html
|
||||||
|
from otko.views.canvas_plotly.bridge import _Bridge
|
||||||
|
from otko.views.canvas_plotly.trace_builder import (
|
||||||
|
PlotlyTraceBuilder,
|
||||||
|
Scene,
|
||||||
|
SceneOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: View preset directions (unit-ish vectors from the scene centre to the eye).
|
||||||
|
_VIEW_DIRECTIONS = {
|
||||||
|
"iso": (1.0, 1.0, 0.8),
|
||||||
|
"xy": (0.0, 0.0, 1.0),
|
||||||
|
"xz": (0.0, -1.0, 0.0),
|
||||||
|
"yz": (1.0, 0.0, 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _CameraShim:
|
||||||
|
"""Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere."""
|
||||||
|
|
||||||
|
def __init__(self, canvas: PlotlyCanvas) -> None:
|
||||||
|
self._canvas = canvas
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parallel_projection(self) -> bool:
|
||||||
|
return self._canvas._parallel
|
||||||
|
|
||||||
|
@parallel_projection.setter
|
||||||
|
def parallel_projection(self, value: bool) -> None:
|
||||||
|
self._canvas.set_parallel_projection(bool(value))
|
||||||
|
|
||||||
|
|
||||||
|
class _PlotlyRendererFacade:
|
||||||
|
"""Stand-in for ``ModelRenderer`` covering the calls made on ``_renderer``.
|
||||||
|
|
||||||
|
``RenderControls`` and ``DockManager`` reach into ``canvas._renderer`` for
|
||||||
|
``render`` / ``set_mode`` / ``_mode``; this keeps those code paths
|
||||||
|
backend-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, canvas: PlotlyCanvas) -> None:
|
||||||
|
self._canvas = canvas
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _mode(self) -> RendererMode:
|
||||||
|
return self._canvas._mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _project(self) -> Any:
|
||||||
|
return self._canvas._project
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _working_plane(self) -> tuple[str, float] | None:
|
||||||
|
return self._canvas._working_plane
|
||||||
|
|
||||||
|
def render(self, project: Any) -> None:
|
||||||
|
self._canvas.set_project(project)
|
||||||
|
|
||||||
|
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
|
||||||
|
self._canvas.set_mode(mode, deformation)
|
||||||
|
|
||||||
|
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
|
||||||
|
if plane is None:
|
||||||
|
self._canvas.set_working_plane(None, 0.0)
|
||||||
|
else:
|
||||||
|
self._canvas.set_working_plane(plane[0], plane[1])
|
||||||
|
|
||||||
|
def set_show_section_extrusions(self, on: bool) -> None:
|
||||||
|
self._canvas.set_show_section_extrusions(on)
|
||||||
|
|
||||||
|
def set_show_local_axes(self, on: bool) -> None:
|
||||||
|
self._canvas.set_show_local_axes(on)
|
||||||
|
|
||||||
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
|
self._canvas.set_display_options(
|
||||||
|
show_node_labels=show_node_labels,
|
||||||
|
show_element_labels=show_element_labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlotlyCanvas(QWidget):
|
||||||
|
"""The central 3D viewport, rendered by plotly.js in a web view."""
|
||||||
|
|
||||||
|
nodePicked = Signal(int)
|
||||||
|
elementPicked = Signal(int)
|
||||||
|
emptyClicked = Signal(float, float, float)
|
||||||
|
|
||||||
|
#: Force diagrams and off-screen video capture are not implemented on
|
||||||
|
#: this backend yet (both are PyVista-specific today).
|
||||||
|
capabilities = CanvasCapabilities(diagrams=False, animation_export=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
style: RenderStyle | None = None,
|
||||||
|
selection: SelectionState | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._style = style or RenderStyle()
|
||||||
|
self.selection = selection or SelectionState(self)
|
||||||
|
self._builder = PlotlyTraceBuilder(self._style)
|
||||||
|
self._project: Any = None
|
||||||
|
self._scene = Scene(data=[], layout={})
|
||||||
|
self._options = SceneOptions()
|
||||||
|
self._mode = RendererMode.MODEL
|
||||||
|
self._parallel = False
|
||||||
|
self._view_preset = "iso"
|
||||||
|
self._snap_enabled = False
|
||||||
|
self._default_selection_enabled = True
|
||||||
|
self._working_plane: tuple[str, float] | None = None
|
||||||
|
self._camera = _CameraShim(self)
|
||||||
|
self._camera_dirty = True
|
||||||
|
self._renderer = _PlotlyRendererFacade(self)
|
||||||
|
self._ready = False
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self.selection.selectionChanged.connect(self._on_selection_changed)
|
||||||
|
|
||||||
|
# ── construction ─────────────────────────────────────────────────
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._web = QWebEngineView(self)
|
||||||
|
self._channel = QWebChannel(self._web.page())
|
||||||
|
self._bridge = _Bridge(self)
|
||||||
|
self._channel.registerObject("otkoBridge", self._bridge)
|
||||||
|
self._web.page().setWebChannel(self._channel)
|
||||||
|
self._web.loadFinished.connect(self._on_loaded)
|
||||||
|
self._bridge.picked.connect(self._on_picked)
|
||||||
|
self._bridge.snapClicked.connect(self._on_snap_clicked)
|
||||||
|
layout.addWidget(self._web)
|
||||||
|
self._web.load(QUrl.fromLocalFile(_html.runtime_url()))
|
||||||
|
|
||||||
|
def _on_loaded(self, ok: bool) -> None:
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
self._ready = True
|
||||||
|
self._push_scene()
|
||||||
|
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
|
||||||
|
|
||||||
|
# ── public API (mirrors ModelCanvas) ─────────────────────────────
|
||||||
|
def show_project(self, project: Any) -> None:
|
||||||
|
"""Render (or clear) a project; frame the camera when nodes exist."""
|
||||||
|
self.set_project(project)
|
||||||
|
if project is not None and project.nodes:
|
||||||
|
self._view_preset = "iso"
|
||||||
|
self._camera_dirty = True
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def clear_model(self) -> None:
|
||||||
|
"""Remove the model but keep the view/selection machinery."""
|
||||||
|
self.selection.clear()
|
||||||
|
self.set_project(None)
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Rebuild the figure and hand it to plotly.js.
|
||||||
|
|
||||||
|
``QWidget.render`` is overloaded for painting into a target; those
|
||||||
|
calls are forwarded untouched so the widget stays well-behaved, and
|
||||||
|
only the no-argument canvas idiom (shared with ``ModelCanvas``)
|
||||||
|
triggers a figure push.
|
||||||
|
"""
|
||||||
|
if args or kwargs:
|
||||||
|
super().render(*args, **kwargs)
|
||||||
|
return
|
||||||
|
self._push_scene()
|
||||||
|
|
||||||
|
def set_project(self, project: Any) -> None:
|
||||||
|
"""Bind a project without rendering (used by the ``_renderer`` facade)."""
|
||||||
|
self._project = project
|
||||||
|
|
||||||
|
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
|
||||||
|
"""Set MODEL / DEFORMED / MODAL plus the displacement source to apply."""
|
||||||
|
self._mode = mode
|
||||||
|
self._options = replace(self._options, deformation=deformation)
|
||||||
|
|
||||||
|
def set_parallel_projection(self, on: bool) -> None:
|
||||||
|
self._parallel = bool(on)
|
||||||
|
self._camera_dirty = True
|
||||||
|
|
||||||
|
def reset_camera(self) -> None:
|
||||||
|
self._view_preset = "iso"
|
||||||
|
self._camera_dirty = True
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def view_isometric(self) -> None:
|
||||||
|
self._view_preset = "iso"
|
||||||
|
self._camera_dirty = True
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def view_xy(self) -> None:
|
||||||
|
"""Top view: the eye sits on +Z looking down."""
|
||||||
|
self._view_preset = "xy"
|
||||||
|
self._camera_dirty = True
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def view_xz(self) -> None:
|
||||||
|
"""Front view: the eye sits on -Y."""
|
||||||
|
self._view_preset = "xz"
|
||||||
|
self._camera_dirty = True
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def view_yz(self) -> None:
|
||||||
|
"""Right view: the eye sits on +X."""
|
||||||
|
self._view_preset = "yz"
|
||||||
|
self._camera_dirty = True
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
# ── working plane ────────────────────────────────────────────────
|
||||||
|
def set_working_plane(self, plane: str | None, offset: float) -> None:
|
||||||
|
"""Filter the grid overlay to the active plan / elevation level."""
|
||||||
|
if plane is None:
|
||||||
|
self._working_plane = None
|
||||||
|
else:
|
||||||
|
if plane not in ("XY", "XZ", "YZ"):
|
||||||
|
raise ValueError(f"Unsupported working plane: {plane!r}")
|
||||||
|
self._working_plane = (plane, float(offset))
|
||||||
|
self._options = replace(self._options, working_plane=self._working_plane)
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def clear_working_plane(self) -> None:
|
||||||
|
self.set_working_plane(None, 0.0)
|
||||||
|
|
||||||
|
def working_plane_type(self) -> str | None:
|
||||||
|
return self._working_plane[0] if self._working_plane is not None else None
|
||||||
|
|
||||||
|
def working_plane_offset(self) -> float | None:
|
||||||
|
return self._working_plane[1] if self._working_plane is not None else None
|
||||||
|
|
||||||
|
# ── display toggles ──────────────────────────────────────────────
|
||||||
|
def set_snap_preview_enabled(self, enabled: bool) -> None:
|
||||||
|
"""Toggle the hover snap-target preview (draw tools turn it on)."""
|
||||||
|
self._snap_enabled = bool(enabled)
|
||||||
|
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
|
||||||
|
|
||||||
|
def set_show_section_extrusions(self, enabled: bool) -> None:
|
||||||
|
self._options = replace(self._options, show_extrusions=bool(enabled))
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def set_show_local_axes(self, enabled: bool) -> None:
|
||||||
|
self._options = replace(self._options, show_local_axes=bool(enabled))
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
|
self._options = replace(
|
||||||
|
self._options,
|
||||||
|
show_node_labels=bool(show_node_labels),
|
||||||
|
show_element_labels=bool(show_element_labels),
|
||||||
|
)
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def set_default_selection_enabled(self, enabled: bool) -> None:
|
||||||
|
"""When False, picks fire signals but do not touch :attr:`selection`."""
|
||||||
|
self._default_selection_enabled = bool(enabled)
|
||||||
|
|
||||||
|
# ── internals ───────────────────────────────────────────────────
|
||||||
|
def _push_scene(self) -> None:
|
||||||
|
self._scene = self._builder.build(self._project, self._options)
|
||||||
|
layout = dict(self._scene.layout)
|
||||||
|
if self._camera_dirty:
|
||||||
|
layout["scene"] = {
|
||||||
|
**layout["scene"],
|
||||||
|
"camera": self._camera_dict(self._scene),
|
||||||
|
}
|
||||||
|
self._camera_dirty = False
|
||||||
|
if not self._ready:
|
||||||
|
return
|
||||||
|
payload = json.dumps({"data": self._scene.data, "layout": layout})
|
||||||
|
self._web.page().runJavaScript(f"window.otkoUpdate({json.dumps(payload)})")
|
||||||
|
|
||||||
|
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
|
||||||
|
cx, cy, cz = scene.center
|
||||||
|
distance = max(scene.diagonal, 1e-6) * 1.6
|
||||||
|
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
|
||||||
|
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
|
||||||
|
eye = (
|
||||||
|
cx + direction[0] / norm * distance,
|
||||||
|
cy + direction[1] / norm * distance,
|
||||||
|
cz + direction[2] / norm * distance,
|
||||||
|
)
|
||||||
|
# Looking straight down the Z axis needs a non-degenerate up vector.
|
||||||
|
up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0)
|
||||||
|
return {
|
||||||
|
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
|
||||||
|
"center": {"x": cx, "y": cy, "z": cz},
|
||||||
|
"up": {"x": up[0], "y": up[1], "z": up[2]},
|
||||||
|
"projection": {"type": "orthographic" if self._parallel else "perspective"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _eval(self, js: str) -> None:
|
||||||
|
if self._ready:
|
||||||
|
self._web.page().runJavaScript(js)
|
||||||
|
|
||||||
|
def _on_selection_changed(self, nodes: frozenset[int], elements: frozenset[int]) -> None:
|
||||||
|
self._options = replace(
|
||||||
|
self._options,
|
||||||
|
selection_nodes=frozenset(nodes),
|
||||||
|
selection_elements=frozenset(elements),
|
||||||
|
)
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def _on_picked(self, kind: str, entity_id: int, additive: bool) -> None:
|
||||||
|
if kind == "node":
|
||||||
|
if self._default_selection_enabled:
|
||||||
|
if additive:
|
||||||
|
self.selection.toggle_node(entity_id)
|
||||||
|
else:
|
||||||
|
self.selection.select_node(entity_id)
|
||||||
|
self.nodePicked.emit(entity_id)
|
||||||
|
elif kind == "element":
|
||||||
|
if self._default_selection_enabled:
|
||||||
|
if additive:
|
||||||
|
self.selection.toggle_element(entity_id)
|
||||||
|
else:
|
||||||
|
self.selection.select_element(entity_id)
|
||||||
|
self.elementPicked.emit(entity_id)
|
||||||
|
|
||||||
|
def _on_snap_clicked(self, x: float, y: float, z: float) -> None:
|
||||||
|
self.emptyClicked.emit(float(x), float(y), float(z))
|
||||||
|
|
||||||
|
|
||||||
|
def _js_bool(value: bool) -> str:
|
||||||
|
return "true" if value else "false"
|
||||||
963
src/otko/views/canvas_plotly/trace_builder.py
Normal file
963
src/otko/views/canvas_plotly/trace_builder.py
Normal file
|
|
@ -0,0 +1,963 @@
|
||||||
|
"""Project → Plotly figure dictionaries.
|
||||||
|
|
||||||
|
Pure data transformation: no Qt, no pyvista, no plotly import. The
|
||||||
|
:class:`PlotlyCanvas` serialises the returned :class:`Scene` to JSON and
|
||||||
|
hands it to plotly.js; the unit tests exercise it headless.
|
||||||
|
|
||||||
|
Geometry conventions mirror :mod:`otko.views.canvas3d.model_renderer` so the
|
||||||
|
two backends draw the same model, but the output medium differs:
|
||||||
|
|
||||||
|
- Nodes / supports / labels are ``scatter3d`` markers, whose size is in
|
||||||
|
*pixels* (plotly.js does not scale markers with world units).
|
||||||
|
- Frames are ``scatter3d`` line traces. Plotly cannot colour individual
|
||||||
|
segments of one line trace, so selection is expressed as two traces
|
||||||
|
(normal + selected) whose segment lists are regrouped on every update.
|
||||||
|
- Loads and local axes are ``cone`` traces (plotly.js has no 3D arrow glyph).
|
||||||
|
- Section extrusions are ``mesh3d`` box sweeps from
|
||||||
|
:func:`otko.services.section_bbox.bbox_for_section` — the same bbox
|
||||||
|
fallback the PyVista backend uses when a shape hint is not an exact fit.
|
||||||
|
|
||||||
|
Picking rides on ``customdata`` + trace ``meta``: every pickable trace is
|
||||||
|
tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads
|
||||||
|
``point.data.meta.kind`` to decide what the click meant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from otko.core import (
|
||||||
|
BeamWithHingesElement,
|
||||||
|
CorotTrussElement,
|
||||||
|
DispBeamColumn,
|
||||||
|
ElasticBeamColumn,
|
||||||
|
ForceBeamColumn,
|
||||||
|
NodalLoad,
|
||||||
|
PlainLoadPattern,
|
||||||
|
Project,
|
||||||
|
QuadElement,
|
||||||
|
TrussElement,
|
||||||
|
UniformElementLoad,
|
||||||
|
ZeroLengthElement,
|
||||||
|
ZeroLengthSectionElement,
|
||||||
|
)
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
|
||||||
|
_FRAME_CLASSES = (
|
||||||
|
ElasticBeamColumn,
|
||||||
|
DispBeamColumn,
|
||||||
|
ForceBeamColumn,
|
||||||
|
BeamWithHingesElement,
|
||||||
|
TrussElement,
|
||||||
|
CorotTrussElement,
|
||||||
|
ZeroLengthElement,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Element families that get their own palette entry (see ``RenderStyle``).
|
||||||
|
_TRUSS_FAMILY = (TrussElement, CorotTrussElement)
|
||||||
|
|
||||||
|
|
||||||
|
def _family_index(el: Any) -> int:
|
||||||
|
"""Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length."""
|
||||||
|
if isinstance(el, _TRUSS_FAMILY):
|
||||||
|
return 1
|
||||||
|
if isinstance(el, ZeroLengthElement):
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`.
|
||||||
|
_BOX_TRIS = (
|
||||||
|
(0, 1, 2),
|
||||||
|
(0, 2, 3),
|
||||||
|
(4, 5, 6),
|
||||||
|
(4, 6, 7),
|
||||||
|
(0, 1, 5),
|
||||||
|
(0, 5, 4),
|
||||||
|
(3, 2, 6),
|
||||||
|
(3, 6, 7),
|
||||||
|
(0, 3, 7),
|
||||||
|
(0, 7, 4),
|
||||||
|
(1, 2, 6),
|
||||||
|
(1, 6, 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Support kind → plotly 3D marker symbol.
|
||||||
|
_SUPPORT_SYMBOLS = {
|
||||||
|
"fix": "square",
|
||||||
|
"pin": "triangle-up",
|
||||||
|
"roller": "circle",
|
||||||
|
"custom": "diamond",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NODE_MARKER_SIZE = 7.0
|
||||||
|
_SUPPORT_MARKER_SIZE = 11.0
|
||||||
|
_SNAP_MARKER_SIZE = 8.0
|
||||||
|
_LABEL_FONT_SIZE = 11
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SceneOptions:
|
||||||
|
"""Everything the builder needs beyond the project itself."""
|
||||||
|
|
||||||
|
selection_nodes: frozenset[int] = frozenset()
|
||||||
|
selection_elements: frozenset[int] = frozenset()
|
||||||
|
#: Anything exposing ``shifted(points, node_ids)`` (e.g. ``DeformationSource``).
|
||||||
|
deformation: Any = None
|
||||||
|
working_plane: tuple[str, float] | None = None
|
||||||
|
show_node_labels: bool = False
|
||||||
|
show_element_labels: bool = False
|
||||||
|
show_extrusions: bool = False
|
||||||
|
show_local_axes: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scene:
|
||||||
|
"""A transport-ready plotly figure plus framing metadata."""
|
||||||
|
|
||||||
|
data: list[dict[str, Any]]
|
||||||
|
layout: dict[str, Any]
|
||||||
|
center: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
diagonal: float = 1.0
|
||||||
|
#: Trace index (in ``data``) of the empty hover-snap marker, or -1.
|
||||||
|
hover_trace: int = -1
|
||||||
|
|
||||||
|
def to_payload(self) -> dict[str, Any]:
|
||||||
|
"""Figure dict without the camera — camera is owned by the widget."""
|
||||||
|
return {"data": self.data, "layout": self.layout}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Mesh:
|
||||||
|
"""Accumulates a triangle soup for a single ``mesh3d`` trace."""
|
||||||
|
|
||||||
|
x: list[float] = field(default_factory=list)
|
||||||
|
y: list[float] = field(default_factory=list)
|
||||||
|
z: list[float] = field(default_factory=list)
|
||||||
|
i: list[int] = field(default_factory=list)
|
||||||
|
j: list[int] = field(default_factory=list)
|
||||||
|
k: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add_box(self, corners: np.ndarray) -> None:
|
||||||
|
base = len(self.x)
|
||||||
|
for cx, cy, cz in corners:
|
||||||
|
self.x.append(float(cx))
|
||||||
|
self.y.append(float(cy))
|
||||||
|
self.z.append(float(cz))
|
||||||
|
for a, b, c in _BOX_TRIS:
|
||||||
|
self.i.append(base + a)
|
||||||
|
self.j.append(base + b)
|
||||||
|
self.k.append(base + c)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return not self.x
|
||||||
|
|
||||||
|
def as_trace(self, *, color: str, opacity: float, name: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "mesh3d",
|
||||||
|
"x": self.x,
|
||||||
|
"y": self.y,
|
||||||
|
"z": self.z,
|
||||||
|
"i": self.i,
|
||||||
|
"j": self.j,
|
||||||
|
"k": self.k,
|
||||||
|
"color": color,
|
||||||
|
"opacity": opacity,
|
||||||
|
"flatshading": True,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showscale": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── small geometry helpers ────────────────────────────────────────────────
|
||||||
|
def _diag_of_points(pts: np.ndarray | None) -> float:
|
||||||
|
if pts is None or len(pts) == 0:
|
||||||
|
return 1.0
|
||||||
|
mn, mx = pts.min(axis=0), pts.max(axis=0)
|
||||||
|
d = float(np.linalg.norm(mx - mn))
|
||||||
|
return d if d > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
|
||||||
|
x = x_local / float(np.linalg.norm(x_local))
|
||||||
|
vecxz = getattr(el, "vecxz", None)
|
||||||
|
if vecxz is not None:
|
||||||
|
try:
|
||||||
|
v = np.asarray(vecxz, dtype=float)
|
||||||
|
z_local = v - float(np.dot(v, x)) * x
|
||||||
|
n = float(np.linalg.norm(z_local))
|
||||||
|
if n > 1e-9:
|
||||||
|
z_local /= n
|
||||||
|
y_local = np.cross(z_local, x)
|
||||||
|
m = float(np.linalg.norm(y_local))
|
||||||
|
if m > 1e-9:
|
||||||
|
return y_local / m, z_local
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
z_global = np.array([0.0, 0.0, 1.0])
|
||||||
|
y_local = np.cross(z_global, x)
|
||||||
|
if float(np.linalg.norm(y_local)) < 1e-6:
|
||||||
|
y_local = np.cross(np.array([1.0, 0.0, 0.0]), x)
|
||||||
|
y_local /= float(np.linalg.norm(y_local))
|
||||||
|
z_local = np.cross(x, y_local)
|
||||||
|
return y_local, z_local
|
||||||
|
|
||||||
|
|
||||||
|
def _dof_indices(ndf: int) -> tuple[int, ...]:
|
||||||
|
if ndf == 6:
|
||||||
|
return (0, 1, 2, 3, 4, 5)
|
||||||
|
if ndf == 3:
|
||||||
|
return (0, 1, 5)
|
||||||
|
if ndf == 2:
|
||||||
|
return (0, 1)
|
||||||
|
return tuple(range(ndf))
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) -> str:
|
||||||
|
flags = [restraint[i] for i in dof_idx]
|
||||||
|
if all(flags):
|
||||||
|
return "fix"
|
||||||
|
trans_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx < 3]
|
||||||
|
rot_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx >= 3]
|
||||||
|
if trans_flags and all(trans_flags) and not any(rot_flags):
|
||||||
|
return "pin"
|
||||||
|
if sum(flags) == 1:
|
||||||
|
return "roller"
|
||||||
|
return "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def _line_trace(
|
||||||
|
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||||
|
*,
|
||||||
|
color: str,
|
||||||
|
width: float,
|
||||||
|
name: str,
|
||||||
|
opacity: float = 1.0,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
customdata: list[Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""One ``scatter3d`` line trace from None-separated segment endpoints."""
|
||||||
|
x: list[float | None] = []
|
||||||
|
y: list[float | None] = []
|
||||||
|
z: list[float | None] = []
|
||||||
|
for a, b in segments:
|
||||||
|
x.extend([float(a[0]), float(b[0]), None])
|
||||||
|
y.extend([float(a[1]), float(b[1]), None])
|
||||||
|
z.extend([float(a[2]), float(b[2]), None])
|
||||||
|
trace: dict[str, Any] = {
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "lines",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"line": {"color": color, "width": width},
|
||||||
|
"opacity": opacity,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
if meta is not None:
|
||||||
|
trace["meta"] = meta
|
||||||
|
if customdata is not None:
|
||||||
|
trace["customdata"] = customdata
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
|
class PlotlyTraceBuilder:
|
||||||
|
"""Builds the full figure for a project."""
|
||||||
|
|
||||||
|
def __init__(self, style: RenderStyle | None = None) -> None:
|
||||||
|
self._style = style or RenderStyle()
|
||||||
|
|
||||||
|
# ── public ───────────────────────────────────────────────────────
|
||||||
|
def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene:
|
||||||
|
opts = options or SceneOptions()
|
||||||
|
if project is None:
|
||||||
|
return Scene(data=[], layout=self._layout())
|
||||||
|
|
||||||
|
nodes = list(project.nodes)
|
||||||
|
node_ids = [n.id for n in nodes]
|
||||||
|
points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3))
|
||||||
|
if opts.deformation is not None and len(points):
|
||||||
|
points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float)
|
||||||
|
node_row = {nid: i for i, nid in enumerate(node_ids)}
|
||||||
|
|
||||||
|
data: list[dict[str, Any]] = []
|
||||||
|
grid_pts = self._build_grid(project, data, opts)
|
||||||
|
self._build_extrusions(project, data, opts)
|
||||||
|
self._build_local_axes(project, data, opts)
|
||||||
|
self._build_loads(project, data, opts)
|
||||||
|
self._build_supports(project, data, opts)
|
||||||
|
self._build_frames(project, data, opts, points, node_row)
|
||||||
|
self._build_nodes(data, opts, points, node_ids)
|
||||||
|
self._build_labels(project, data, opts, points, node_row)
|
||||||
|
hover_trace = self._build_hover_marker(data)
|
||||||
|
|
||||||
|
candidates = [points] if len(points) else []
|
||||||
|
if grid_pts is not None:
|
||||||
|
candidates.append(grid_pts)
|
||||||
|
all_pts = np.vstack(candidates) if candidates else np.empty((0, 3))
|
||||||
|
center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0)
|
||||||
|
return Scene(
|
||||||
|
data=data,
|
||||||
|
layout=self._layout(),
|
||||||
|
center=(float(center[0]), float(center[1]), float(center[2])),
|
||||||
|
diagonal=_diag_of_points(all_pts),
|
||||||
|
hover_trace=hover_trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── layout ───────────────────────────────────────────────────────
|
||||||
|
def _layout(self) -> dict[str, Any]:
|
||||||
|
style = self._style
|
||||||
|
|
||||||
|
def axis(color: str, title: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"title": {"text": title, "font": {"color": color, "size": 12}},
|
||||||
|
"showgrid": False,
|
||||||
|
"showbackground": False,
|
||||||
|
"zeroline": False,
|
||||||
|
"showticklabels": False,
|
||||||
|
"showspikes": False,
|
||||||
|
"visible": True,
|
||||||
|
"linecolor": color,
|
||||||
|
"linewidth": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"paper_bgcolor": style.background_bottom,
|
||||||
|
"plot_bgcolor": style.background_bottom,
|
||||||
|
"showlegend": False,
|
||||||
|
"margin": {"l": 0, "r": 0, "t": 0, "b": 0},
|
||||||
|
"uirevision": "otko",
|
||||||
|
"scene": {
|
||||||
|
"bgcolor": style.background_bottom,
|
||||||
|
"aspectmode": "data",
|
||||||
|
"dragmode": "orbit",
|
||||||
|
"xaxis": axis(style.fix_color, "X"),
|
||||||
|
"yaxis": axis(style.load_color, "Y"),
|
||||||
|
"zaxis": axis(style.truss_color, "Z"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── grid ─────────────────────────────────────────────────────────
|
||||||
|
def _build_grid(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> np.ndarray | None:
|
||||||
|
coord_systems = getattr(project, "coord_systems", None) or []
|
||||||
|
palette = [
|
||||||
|
((0.08, 0.08, 0.08), (0.85, 0.55, 0.00)),
|
||||||
|
((0.20, 0.35, 0.55), (0.85, 0.55, 0.00)),
|
||||||
|
((0.20, 0.55, 0.30), (0.85, 0.55, 0.00)),
|
||||||
|
((0.55, 0.20, 0.40), (0.85, 0.55, 0.00)),
|
||||||
|
((0.35, 0.20, 0.55), (0.85, 0.55, 0.00)),
|
||||||
|
]
|
||||||
|
all_dots: list[np.ndarray] = []
|
||||||
|
|
||||||
|
for idx, cs in enumerate(coord_systems):
|
||||||
|
grid = cs.grid
|
||||||
|
if not grid.visible or getattr(grid, "hide_all", False):
|
||||||
|
continue
|
||||||
|
xs = list(grid.x_lines)
|
||||||
|
ys = list(grid.y_lines)
|
||||||
|
zs = list(grid.z_lines)
|
||||||
|
if not (xs or ys or zs):
|
||||||
|
continue
|
||||||
|
palette_idx = 0 if cs.is_global() else (idx % (len(palette) - 1)) + 1
|
||||||
|
grid_color, dot_color = palette[palette_idx]
|
||||||
|
grid_hex = _rgb_to_hex(grid_color)
|
||||||
|
dot_hex = _rgb_to_hex(dot_color)
|
||||||
|
|
||||||
|
xmin, xmax = (min(xs), max(xs)) if xs else (-1.0, 1.0)
|
||||||
|
ymin, ymax = (min(ys), max(ys)) if ys else (-1.0, 1.0)
|
||||||
|
if xmin == xmax:
|
||||||
|
xmin, xmax = xmin - 1.0, xmax + 1.0
|
||||||
|
if ymin == ymax:
|
||||||
|
ymin, ymax = ymin - 1.0, ymax + 1.0
|
||||||
|
|
||||||
|
plane_axis: int | None = None
|
||||||
|
plane_offset: float | None = None
|
||||||
|
if opts.working_plane is not None:
|
||||||
|
name, off = opts.working_plane
|
||||||
|
axis_idx = {"XY": 2, "XZ": 1, "YZ": 0}[name]
|
||||||
|
plane_axis = axis_idx
|
||||||
|
plane_offset = off - cs.coord.origin[axis_idx]
|
||||||
|
|
||||||
|
def on_plane(
|
||||||
|
local: tuple[float, float, float],
|
||||||
|
_axis: int | None = plane_axis,
|
||||||
|
_offset: float | None = plane_offset,
|
||||||
|
) -> bool:
|
||||||
|
if _axis is None or _offset is None:
|
||||||
|
return True
|
||||||
|
return abs(local[_axis] - _offset) < 1e-6
|
||||||
|
|
||||||
|
active: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
dim: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
|
||||||
|
def add_seg(
|
||||||
|
p1: tuple[float, float, float],
|
||||||
|
p2: tuple[float, float, float],
|
||||||
|
_active: list = active,
|
||||||
|
_dim: list = dim,
|
||||||
|
_on_plane: Any = on_plane,
|
||||||
|
_cs: Any = cs,
|
||||||
|
) -> None:
|
||||||
|
on = _on_plane(p1) and _on_plane(p2)
|
||||||
|
bucket = _active if on else _dim
|
||||||
|
bucket.append((_cs.coord.local_to_world(p1), _cs.coord.local_to_world(p2)))
|
||||||
|
|
||||||
|
z_planes = zs if zs else [0.0]
|
||||||
|
for z in z_planes:
|
||||||
|
for x in xs:
|
||||||
|
add_seg((x, ymin, z), (x, ymax, z))
|
||||||
|
for y in ys:
|
||||||
|
add_seg((xmin, y, z), (xmax, y, z))
|
||||||
|
if opts.working_plane is None and zs and xs and ys:
|
||||||
|
for x in xs:
|
||||||
|
for y in ys:
|
||||||
|
add_seg((x, y, zs[0]), (x, y, zs[-1]))
|
||||||
|
|
||||||
|
if dim:
|
||||||
|
data.append(_line_trace(dim, color=grid_hex, width=1, name="grid", opacity=0.18))
|
||||||
|
if active:
|
||||||
|
data.append(_line_trace(active, color=grid_hex, width=2, name="grid-active"))
|
||||||
|
|
||||||
|
active_dots: list[tuple[float, float, float]] = []
|
||||||
|
dim_dots: list[tuple[float, float, float]] = []
|
||||||
|
for z in z_planes:
|
||||||
|
for x in xs or [0.0]:
|
||||||
|
for y in ys or [0.0]:
|
||||||
|
pt = cs.coord.local_to_world((x, y, z))
|
||||||
|
(active_dots if on_plane((x, y, z)) else dim_dots).append(pt)
|
||||||
|
if dim_dots:
|
||||||
|
data.append(self._dot_trace(dim_dots, color="#999999", opacity=0.35))
|
||||||
|
if active_dots:
|
||||||
|
data.append(
|
||||||
|
self._dot_trace(
|
||||||
|
active_dots,
|
||||||
|
color=dot_hex,
|
||||||
|
opacity=1.0,
|
||||||
|
meta={"kind": "snap"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
all_dots.extend(np.asarray(active_dots, dtype=float))
|
||||||
|
|
||||||
|
return np.vstack(all_dots) if all_dots else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dot_trace(
|
||||||
|
points: list[tuple[float, float, float]],
|
||||||
|
*,
|
||||||
|
color: str,
|
||||||
|
opacity: float,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
customdata = (
|
||||||
|
[[float(p[0]), float(p[1]), float(p[2])] for p in points] if meta is not None else None
|
||||||
|
)
|
||||||
|
trace: dict[str, Any] = {
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [float(p[0]) for p in points],
|
||||||
|
"y": [float(p[1]) for p in points],
|
||||||
|
"z": [float(p[2]) for p in points],
|
||||||
|
"marker": {"color": color, "size": _SNAP_MARKER_SIZE, "line": {"width": 0}},
|
||||||
|
"opacity": opacity,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "snap",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
if meta is not None:
|
||||||
|
trace["meta"] = meta
|
||||||
|
trace["customdata"] = customdata
|
||||||
|
return trace
|
||||||
|
|
||||||
|
# ── model entities ───────────────────────────────────────────────
|
||||||
|
def _build_nodes(
|
||||||
|
self,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
opts: SceneOptions,
|
||||||
|
points: np.ndarray,
|
||||||
|
node_ids: list[int],
|
||||||
|
) -> None:
|
||||||
|
if not len(points):
|
||||||
|
return
|
||||||
|
colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids]
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [float(p[0]) for p in points],
|
||||||
|
"y": [float(p[1]) for p in points],
|
||||||
|
"z": [float(p[2]) for p in points],
|
||||||
|
"marker": {
|
||||||
|
"color": colors,
|
||||||
|
"size": _NODE_MARKER_SIZE,
|
||||||
|
"line": {"color": "#4d4d4d", "width": 1},
|
||||||
|
},
|
||||||
|
"customdata": list(node_ids),
|
||||||
|
"meta": {"kind": "node"},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "nodes",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_frames(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
opts: SceneOptions,
|
||||||
|
points: np.ndarray,
|
||||||
|
node_row: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
"""One line trace whose per-point ``line.color`` carries the palette.
|
||||||
|
|
||||||
|
plotly accepts an *array* for ``Scatter3d.line.color`` mapped through
|
||||||
|
a ``colorscale``, so a single trace can colour every element
|
||||||
|
individually — by family and by selection — instead of one trace per
|
||||||
|
colour. That also leaves room to colour by response value later.
|
||||||
|
Each element's two endpoints (and its ``None`` separator) share one
|
||||||
|
colour index, so each drawn segment is a solid colour.
|
||||||
|
"""
|
||||||
|
if not len(points):
|
||||||
|
return
|
||||||
|
scale_colors = [
|
||||||
|
self._style.element_beam_color,
|
||||||
|
self._style.element_truss_color,
|
||||||
|
self._style.element_link_color,
|
||||||
|
self._style.selected_color,
|
||||||
|
]
|
||||||
|
selected_index = len(scale_colors) - 1
|
||||||
|
|
||||||
|
x: list[float | None] = []
|
||||||
|
y: list[float | None] = []
|
||||||
|
z: list[float | None] = []
|
||||||
|
color_index: list[float] = []
|
||||||
|
customdata: list[Any] = []
|
||||||
|
for el in project.elements:
|
||||||
|
if not isinstance(el, _FRAME_CLASSES):
|
||||||
|
continue
|
||||||
|
i = node_row.get(el.nodes[0])
|
||||||
|
j = node_row.get(el.nodes[1])
|
||||||
|
if i is None or j is None:
|
||||||
|
continue
|
||||||
|
index = float(selected_index if el.id in opts.selection_elements else _family_index(el))
|
||||||
|
x.extend([float(points[i][0]), float(points[j][0]), None])
|
||||||
|
y.extend([float(points[i][1]), float(points[j][1]), None])
|
||||||
|
z.extend([float(points[i][2]), float(points[j][2]), None])
|
||||||
|
color_index.extend([index, index, index])
|
||||||
|
customdata.extend([el.id, el.id, None])
|
||||||
|
if not x:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = len(scale_colors)
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "lines",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"line": {
|
||||||
|
"color": color_index,
|
||||||
|
"colorscale": [
|
||||||
|
(index / (count - 1), color) for index, color in enumerate(scale_colors)
|
||||||
|
],
|
||||||
|
"cmin": 0,
|
||||||
|
"cmax": count - 1,
|
||||||
|
"width": 4,
|
||||||
|
},
|
||||||
|
"customdata": customdata,
|
||||||
|
"meta": {"kind": "element"},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "elements",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_supports(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not project.nodes:
|
||||||
|
return
|
||||||
|
dof_idx = _dof_indices(project.ndf)
|
||||||
|
groups: dict[str, list[Any]] = {}
|
||||||
|
for node in project.nodes:
|
||||||
|
if not any(node.restraint[i] for i in dof_idx):
|
||||||
|
continue
|
||||||
|
kind = _classify_support(node.restraint, dof_idx)
|
||||||
|
groups.setdefault(kind, []).append(node)
|
||||||
|
for kind, nodes in groups.items():
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [float(n.coords[0]) for n in nodes],
|
||||||
|
"y": [float(n.coords[1]) for n in nodes],
|
||||||
|
"z": [float(n.coords[2]) for n in nodes],
|
||||||
|
"marker": {
|
||||||
|
"color": "#ff8019",
|
||||||
|
"size": _SUPPORT_MARKER_SIZE,
|
||||||
|
"symbol": _SUPPORT_SYMBOLS[kind],
|
||||||
|
"line": {"color": "#7f3f00", "width": 1},
|
||||||
|
},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": f"support-{kind}",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── loads / axes / extrusions ────────────────────────────────────
|
||||||
|
def _build_loads(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not project.load_patterns or not project.nodes:
|
||||||
|
return
|
||||||
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
elem_by_id = {e.id: e for e in project.elements}
|
||||||
|
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
||||||
|
scale = max(_diag_of_points(pts) * 0.05, 1e-6)
|
||||||
|
|
||||||
|
nodal_x: list[float] = []
|
||||||
|
nodal_y: list[float] = []
|
||||||
|
nodal_z: list[float] = []
|
||||||
|
nodal_u: list[float] = []
|
||||||
|
nodal_v: list[float] = []
|
||||||
|
nodal_w: list[float] = []
|
||||||
|
dist_x: list[float] = []
|
||||||
|
dist_y: list[float] = []
|
||||||
|
dist_z: list[float] = []
|
||||||
|
dist_u: list[float] = []
|
||||||
|
dist_v: list[float] = []
|
||||||
|
dist_w: list[float] = []
|
||||||
|
|
||||||
|
for pattern in project.load_patterns:
|
||||||
|
if not isinstance(pattern, PlainLoadPattern):
|
||||||
|
continue
|
||||||
|
for nload in pattern.nodal_loads:
|
||||||
|
if not isinstance(nload, NodalLoad):
|
||||||
|
continue
|
||||||
|
node = node_by_id.get(nload.node_id)
|
||||||
|
if node is None:
|
||||||
|
continue
|
||||||
|
f = np.asarray(nload.forces[:3], dtype=float)
|
||||||
|
mag = float(np.linalg.norm(f))
|
||||||
|
if mag < 1e-12:
|
||||||
|
continue
|
||||||
|
direction = f / mag
|
||||||
|
tail = np.asarray(node.coords, dtype=float) - direction * scale
|
||||||
|
nodal_x.append(float(tail[0]))
|
||||||
|
nodal_y.append(float(tail[1]))
|
||||||
|
nodal_z.append(float(tail[2]))
|
||||||
|
nodal_u.append(float(direction[0]))
|
||||||
|
nodal_v.append(float(direction[1]))
|
||||||
|
nodal_w.append(float(direction[2]))
|
||||||
|
|
||||||
|
for eload in pattern.element_loads:
|
||||||
|
if not isinstance(eload, UniformElementLoad):
|
||||||
|
continue
|
||||||
|
elem = elem_by_id.get(eload.element_id)
|
||||||
|
if elem is None:
|
||||||
|
continue
|
||||||
|
node_i = node_by_id.get(elem.nodes[0])
|
||||||
|
node_j = node_by_id.get(elem.nodes[1])
|
||||||
|
if node_i is None or node_j is None:
|
||||||
|
continue
|
||||||
|
pi = np.asarray(node_i.coords, dtype=float)
|
||||||
|
pj = np.asarray(node_j.coords, dtype=float)
|
||||||
|
axis = pj - pi
|
||||||
|
length = float(np.linalg.norm(axis))
|
||||||
|
if length < 1e-9:
|
||||||
|
continue
|
||||||
|
x_local = axis / length
|
||||||
|
z_global = np.array([0.0, 0.0, 1.0])
|
||||||
|
y_local = np.cross(z_global, x_local)
|
||||||
|
if float(np.linalg.norm(y_local)) < 1e-6:
|
||||||
|
y_local = np.cross(np.array([0.0, 1.0, 0.0]), x_local)
|
||||||
|
y_local /= float(np.linalg.norm(y_local))
|
||||||
|
z_local = np.cross(x_local, y_local)
|
||||||
|
load_vec = eload.wx * x_local + eload.wy * y_local + eload.wz * z_local
|
||||||
|
mag = float(np.linalg.norm(load_vec))
|
||||||
|
if mag < 1e-12:
|
||||||
|
continue
|
||||||
|
direction = load_vec / mag
|
||||||
|
n_arrows = 5
|
||||||
|
for k in range(n_arrows):
|
||||||
|
t = (k + 0.5) / n_arrows
|
||||||
|
tail = pi + t * axis - direction * (0.4 * scale)
|
||||||
|
dist_x.append(float(tail[0]))
|
||||||
|
dist_y.append(float(tail[1]))
|
||||||
|
dist_z.append(float(tail[2]))
|
||||||
|
dist_u.append(float(direction[0]))
|
||||||
|
dist_v.append(float(direction[1]))
|
||||||
|
dist_w.append(float(direction[2]))
|
||||||
|
|
||||||
|
if nodal_x:
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
nodal_x,
|
||||||
|
nodal_y,
|
||||||
|
nodal_z,
|
||||||
|
nodal_u,
|
||||||
|
nodal_v,
|
||||||
|
nodal_w,
|
||||||
|
color="#33d933",
|
||||||
|
name="nodal-loads",
|
||||||
|
size=scale,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if dist_x:
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
dist_x,
|
||||||
|
dist_y,
|
||||||
|
dist_z,
|
||||||
|
dist_u,
|
||||||
|
dist_v,
|
||||||
|
dist_w,
|
||||||
|
color="#ff8c33",
|
||||||
|
name="element-loads",
|
||||||
|
size=0.6 * scale,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_local_axes(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not opts.show_local_axes or not project.nodes:
|
||||||
|
return
|
||||||
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
||||||
|
cap = max(_diag_of_points(pts) * 0.08, 1e-6)
|
||||||
|
axes: dict[str, dict[str, list[float]]] = {
|
||||||
|
"x": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []},
|
||||||
|
"y": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []},
|
||||||
|
"z": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []},
|
||||||
|
}
|
||||||
|
for el in project.elements:
|
||||||
|
if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement):
|
||||||
|
continue
|
||||||
|
if not isinstance(
|
||||||
|
el,
|
||||||
|
ElasticBeamColumn
|
||||||
|
| DispBeamColumn
|
||||||
|
| ForceBeamColumn
|
||||||
|
| BeamWithHingesElement
|
||||||
|
| TrussElement
|
||||||
|
| CorotTrussElement,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
node_i = node_by_id.get(el.nodes[0])
|
||||||
|
node_j = node_by_id.get(el.nodes[1])
|
||||||
|
if node_i is None or node_j is None:
|
||||||
|
continue
|
||||||
|
pi = np.asarray(node_i.coords, dtype=float)
|
||||||
|
pj = np.asarray(node_j.coords, dtype=float)
|
||||||
|
axis = pj - pi
|
||||||
|
length = float(np.linalg.norm(axis))
|
||||||
|
if length < 1e-9:
|
||||||
|
continue
|
||||||
|
x_local = axis / length
|
||||||
|
y_local, z_local = _frame_basis(el, x_local)
|
||||||
|
mid = (pi + pj) / 2.0
|
||||||
|
for key, direction in (("x", x_local), ("y", y_local), ("z", z_local)):
|
||||||
|
bucket = axes[key]
|
||||||
|
bucket["x"].append(float(mid[0]))
|
||||||
|
bucket["y"].append(float(mid[1]))
|
||||||
|
bucket["z"].append(float(mid[2]))
|
||||||
|
bucket["u"].append(float(direction[0]))
|
||||||
|
bucket["v"].append(float(direction[1]))
|
||||||
|
bucket["w"].append(float(direction[2]))
|
||||||
|
for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")):
|
||||||
|
bucket = axes[key]
|
||||||
|
if bucket["x"]:
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
bucket["x"],
|
||||||
|
bucket["y"],
|
||||||
|
bucket["z"],
|
||||||
|
bucket["u"],
|
||||||
|
bucket["v"],
|
||||||
|
bucket["w"],
|
||||||
|
color=color,
|
||||||
|
name=f"local-{key}",
|
||||||
|
size=cap,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_extrusions(
|
||||||
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
) -> None:
|
||||||
|
if not opts.show_extrusions:
|
||||||
|
return
|
||||||
|
from otko.services.section_bbox import bbox_for_section
|
||||||
|
|
||||||
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
section_by_id = {s.id: s for s in project.sections}
|
||||||
|
mesh = _Mesh()
|
||||||
|
for el in project.elements:
|
||||||
|
if not isinstance(el, _FRAME_CLASSES):
|
||||||
|
continue
|
||||||
|
if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement):
|
||||||
|
continue
|
||||||
|
section_id = getattr(el, "section_id", None)
|
||||||
|
if section_id is None:
|
||||||
|
continue
|
||||||
|
section = section_by_id.get(section_id)
|
||||||
|
if section is None:
|
||||||
|
continue
|
||||||
|
dims = bbox_for_section(section, project)
|
||||||
|
if dims is None:
|
||||||
|
continue
|
||||||
|
w_y, h_z = dims
|
||||||
|
if w_y <= 0 or h_z <= 0:
|
||||||
|
continue
|
||||||
|
node_i = node_by_id.get(el.nodes[0])
|
||||||
|
node_j = node_by_id.get(el.nodes[1])
|
||||||
|
if node_i is None or node_j is None:
|
||||||
|
continue
|
||||||
|
pi = np.asarray(node_i.coords, dtype=float)
|
||||||
|
pj = np.asarray(node_j.coords, dtype=float)
|
||||||
|
axis = pj - pi
|
||||||
|
length = float(np.linalg.norm(axis))
|
||||||
|
if length < 1e-9:
|
||||||
|
continue
|
||||||
|
x_local = axis / length
|
||||||
|
y_local, z_local = _frame_basis(el, x_local)
|
||||||
|
mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z))
|
||||||
|
if not mesh.is_empty:
|
||||||
|
data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _box_corners(
|
||||||
|
pi: np.ndarray,
|
||||||
|
x_local: np.ndarray,
|
||||||
|
y_local: np.ndarray,
|
||||||
|
z_local: np.ndarray,
|
||||||
|
length: float,
|
||||||
|
w_y: float,
|
||||||
|
h_z: float,
|
||||||
|
) -> np.ndarray:
|
||||||
|
hy, hz = w_y / 2.0, h_z / 2.0
|
||||||
|
offsets = np.array(
|
||||||
|
[
|
||||||
|
[0.0, -hy, -hz],
|
||||||
|
[length, -hy, -hz],
|
||||||
|
[length, +hy, -hz],
|
||||||
|
[0.0, +hy, -hz],
|
||||||
|
[0.0, -hy, +hz],
|
||||||
|
[length, -hy, +hz],
|
||||||
|
[length, +hy, +hz],
|
||||||
|
[0.0, +hy, +hz],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
basis = np.column_stack([x_local, y_local, z_local])
|
||||||
|
return pi + offsets @ basis.T
|
||||||
|
|
||||||
|
# ── labels / hover marker ────────────────────────────────────────
|
||||||
|
def _build_labels(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
opts: SceneOptions,
|
||||||
|
points: np.ndarray,
|
||||||
|
node_row: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
if opts.show_node_labels and len(points):
|
||||||
|
labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes]
|
||||||
|
data.append(_text_trace(points, labels, name="node-labels"))
|
||||||
|
if opts.show_element_labels and len(points):
|
||||||
|
centers: list[np.ndarray] = []
|
||||||
|
labels: list[str] = []
|
||||||
|
for el in project.elements:
|
||||||
|
if len(el.nodes) != 2:
|
||||||
|
continue
|
||||||
|
i = node_row.get(el.nodes[0])
|
||||||
|
j = node_row.get(el.nodes[1])
|
||||||
|
if i is None or j is None:
|
||||||
|
continue
|
||||||
|
centers.append((points[i] + points[j]) / 2.0)
|
||||||
|
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
|
||||||
|
if centers:
|
||||||
|
data.append(_text_trace(np.asarray(centers), labels, name="element-labels"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_hover_marker(data: list[dict[str, Any]]) -> int:
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "markers",
|
||||||
|
"x": [],
|
||||||
|
"y": [],
|
||||||
|
"z": [],
|
||||||
|
"marker": {
|
||||||
|
"color": "#ffd900",
|
||||||
|
"size": 13,
|
||||||
|
"line": {"color": "#8a6d00", "width": 1},
|
||||||
|
},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "snap-hover",
|
||||||
|
"showlegend": False,
|
||||||
|
"meta": {"kind": "hover"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return len(data) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cone_trace(
|
||||||
|
x: list[float],
|
||||||
|
y: list[float],
|
||||||
|
z: list[float],
|
||||||
|
u: list[float],
|
||||||
|
v: list[float],
|
||||||
|
w: list[float],
|
||||||
|
*,
|
||||||
|
color: str,
|
||||||
|
name: str,
|
||||||
|
size: float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "cone",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"u": u,
|
||||||
|
"v": v,
|
||||||
|
"w": w,
|
||||||
|
"anchor": "tail",
|
||||||
|
"sizemode": "absolute",
|
||||||
|
"sizeref": float(size),
|
||||||
|
"colorscale": [[0, color], [1, color]],
|
||||||
|
"showscale": False,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "text",
|
||||||
|
"x": [float(p[0]) for p in points],
|
||||||
|
"y": [float(p[1]) for p in points],
|
||||||
|
"z": [float(p[2]) for p in points],
|
||||||
|
"text": labels,
|
||||||
|
"textposition": "top center",
|
||||||
|
"textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"},
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": name,
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(rgb: tuple[float, float, float]) -> str:
|
||||||
|
r, g, b = (int(round(v * 255)) for v in rgb)
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
@ -210,6 +210,14 @@ class DockManager:
|
||||||
because the off-screen renderer must be touched from the GUI
|
because the off-screen renderer must be touched from the GUI
|
||||||
thread (VTK's Qt-backed render window isn't thread-safe).
|
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
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
path, _sel = QFileDialog.getSaveFileName(
|
path, _sel = QFileDialog.getSaveFileName(
|
||||||
|
|
@ -260,6 +268,14 @@ class DockManager:
|
||||||
"Run a Static analysis first; force diagrams visualise its element-force output.",
|
"Run a Static analysis first; force diagrams visualise its element-force output.",
|
||||||
)
|
)
|
||||||
return
|
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()
|
self._tear_down_post_dock()
|
||||||
|
|
||||||
# Pick the component with the largest abs_max as the initial choice
|
# 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.",
|
"Run a Transient (time-history) analysis first.",
|
||||||
)
|
)
|
||||||
return
|
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 pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtWidgets import QFileDialog, QInputDialog
|
from PySide6.QtWidgets import QFileDialog, QInputDialog
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,18 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QSettings
|
from PySide6.QtCore import QSettings
|
||||||
from PySide6.QtGui import QCloseEvent, QIcon
|
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.viewmodels import AnalysisRunner, ProjectViewModel
|
||||||
from otko.views.action_handlers import ActionHandlers
|
from otko.views.action_handlers import ActionHandlers
|
||||||
from otko.views.canvas3d import ModelCanvas
|
from otko.views.canvas3d import ModelCanvas
|
||||||
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
||||||
|
from otko.views.canvas3d.selection import SelectionState
|
||||||
|
from otko.views.canvas_base import plotly_available
|
||||||
from otko.views.dock_manager import DockManager
|
from otko.views.dock_manager import DockManager
|
||||||
from otko.views.menu_builder import MenuBuilder
|
from otko.views.menu_builder import MenuBuilder
|
||||||
from otko.views.render_controls import RenderControls
|
from otko.views.render_controls import RenderControls
|
||||||
|
|
@ -34,6 +37,9 @@ from otko.views.tools import (
|
||||||
ToolController,
|
ToolController,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Canvas backends, in menu order.
|
||||||
|
CANVAS_BACKENDS = ("pyvista", "plotly")
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(
|
class MainWindow(
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
|
|
@ -44,7 +50,7 @@ class MainWindow(
|
||||||
):
|
):
|
||||||
"""Top-level application shell."""
|
"""Top-level application shell."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, settings: QSettings | None = None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("OTKO")
|
self.setWindowTitle("OTKO")
|
||||||
self.setWindowIcon(
|
self.setWindowIcon(
|
||||||
|
|
@ -60,12 +66,20 @@ class MainWindow(
|
||||||
self._show_node_labels = False
|
self._show_node_labels = False
|
||||||
self._show_element_labels = False
|
self._show_element_labels = False
|
||||||
|
|
||||||
self._build_central_canvas()
|
# One selection state shared by every canvas backend so a live swap
|
||||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
# keeps the current selection (canvas, tree, table, properties dock).
|
||||||
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
self._selection = SelectionState(self)
|
||||||
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
|
||||||
self._draw_node_tool: DrawNodeTool | None = None
|
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
||||||
self._draw_truss_tool: DrawTrussTool | None = None
|
self._canvas_backend = self._stored_canvas_backend()
|
||||||
|
self._canvases: dict[str, Any] = {}
|
||||||
|
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
||||||
|
# A stack holds every backend's canvas: swapping is just
|
||||||
|
# setCurrentWidget, so no backend widget is ever destroyed mid-session.
|
||||||
|
self._canvas_stack = QStackedWidget(self)
|
||||||
|
self.setCentralWidget(self._canvas_stack)
|
||||||
|
self._activate_canvas(self._canvas_backend)
|
||||||
|
self._build_tooling()
|
||||||
|
|
||||||
self._build_docks()
|
self._build_docks()
|
||||||
self._build_actions()
|
self._build_actions()
|
||||||
|
|
@ -77,19 +91,120 @@ class MainWindow(
|
||||||
self._refresh_action_enablement()
|
self._refresh_action_enablement()
|
||||||
self.restore_layout()
|
self.restore_layout()
|
||||||
|
|
||||||
# ── construction ─────────────────────────────────────────────────
|
# ── canvas backend ───────────────────────────────────────────────
|
||||||
def _build_central_canvas(self) -> None:
|
def _stored_canvas_backend(self) -> str:
|
||||||
self._canvas = ModelCanvas(self)
|
"""The backend chosen last session, falling back to PyVista."""
|
||||||
self.setCentralWidget(self._canvas)
|
stored = self._settings.value("canvas/backend", "pyvista")
|
||||||
|
name = str(stored) if stored is not None else "pyvista"
|
||||||
|
if name == "plotly" and plotly_available():
|
||||||
|
return "plotly"
|
||||||
|
return "pyvista"
|
||||||
|
|
||||||
|
def _ensure_canvas(self, backend: str) -> Any:
|
||||||
|
"""Create a backend's canvas on first use and cache it.
|
||||||
|
|
||||||
|
Both canvases are kept alive and swapped with ``setCentralWidget``
|
||||||
|
rather than destroyed: tearing a VTK render window down mid-session
|
||||||
|
leaves dangling make-current callbacks, and the widgets are cheap to
|
||||||
|
retain once built.
|
||||||
|
"""
|
||||||
|
canvas = self._canvases.get(backend)
|
||||||
|
if canvas is not None:
|
||||||
|
return canvas
|
||||||
|
if backend == "plotly":
|
||||||
|
# Imported lazily so a plotly-less install still boots PyVista.
|
||||||
|
from otko.views.canvas_plotly import PlotlyCanvas
|
||||||
|
|
||||||
|
canvas = PlotlyCanvas(self, selection=self._selection)
|
||||||
|
else:
|
||||||
|
canvas = ModelCanvas(self, selection=self._selection)
|
||||||
|
self._canvases[backend] = canvas
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
def _activate_canvas(self, backend: str) -> None:
|
||||||
|
"""Make the backend's canvas the central widget and rewire overlays."""
|
||||||
|
if backend not in CANVAS_BACKENDS:
|
||||||
|
backend = "pyvista"
|
||||||
|
canvas = self._ensure_canvas(backend)
|
||||||
|
if self._canvas_stack.indexOf(canvas) < 0:
|
||||||
|
self._canvas_stack.addWidget(canvas)
|
||||||
|
self._canvas_stack.setCurrentWidget(canvas)
|
||||||
|
self._canvas = canvas
|
||||||
|
self._canvas_backend = backend
|
||||||
|
if backend == "pyvista":
|
||||||
|
renderer = self._diagram_renderers.get("pyvista")
|
||||||
|
if renderer is None:
|
||||||
# Diagram overlay paints onto the same plotter as the model.
|
# Diagram overlay paints onto the same plotter as the model.
|
||||||
self._diagram_renderer = DiagramRenderer(self._canvas)
|
renderer = DiagramRenderer(canvas)
|
||||||
|
self._diagram_renderers["pyvista"] = renderer
|
||||||
|
self._diagram_renderer = renderer
|
||||||
|
else:
|
||||||
|
self._diagram_renderer = None
|
||||||
|
|
||||||
|
def canvas_backend(self) -> str:
|
||||||
|
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||||
|
return self._canvas_backend
|
||||||
|
|
||||||
|
def _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 ───────────────────────────────────────────
|
# ── layout persistence ───────────────────────────────────────────
|
||||||
def save_layout(self) -> None:
|
def save_layout(self) -> None:
|
||||||
"""Persist window geometry and dock/toolbar state for next launch."""
|
"""Persist window geometry and dock/toolbar state for next launch."""
|
||||||
settings = QSettings("OTKO", "OTKO")
|
self._settings.setValue("geometry", self.saveGeometry())
|
||||||
settings.setValue("geometry", self.saveGeometry())
|
self._settings.setValue("windowState", self.saveState())
|
||||||
settings.setValue("windowState", self.saveState())
|
|
||||||
|
|
||||||
def restore_layout(self) -> None:
|
def restore_layout(self) -> None:
|
||||||
"""Restore a previously saved geometry/dock layout, if any.
|
"""Restore a previously saved geometry/dock layout, if any.
|
||||||
|
|
@ -97,11 +212,10 @@ class MainWindow(
|
||||||
On first run no values are stored, so the defaults from ``__init__``
|
On first run no values are stored, so the defaults from ``__init__``
|
||||||
(``resize`` + dock construction) stay in effect.
|
(``resize`` + dock construction) stay in effect.
|
||||||
"""
|
"""
|
||||||
settings = QSettings("OTKO", "OTKO")
|
geometry = self._settings.value("geometry")
|
||||||
geometry = settings.value("geometry")
|
|
||||||
if geometry is not None:
|
if geometry is not None:
|
||||||
self.restoreGeometry(geometry)
|
self.restoreGeometry(geometry)
|
||||||
state = settings.value("windowState")
|
state = self._settings.value("windowState")
|
||||||
if state is not None:
|
if state is not None:
|
||||||
self.restoreState(state)
|
self.restoreState(state)
|
||||||
|
|
||||||
|
|
@ -161,9 +275,12 @@ class MainWindow(
|
||||||
self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool)
|
self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool)
|
||||||
self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_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._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool)
|
||||||
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
# toolChanged → toolbar/menu sync is wired in _build_tooling(), which
|
||||||
# Keep the Tools toolbar checked state in sync with the controller.
|
# also runs on a canvas-backend swap (then a fresh controller exists).
|
||||||
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
|
|
||||||
|
# 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
|
# Define
|
||||||
self._act_grid.triggered.connect(self._on_grid_system)
|
self._act_grid.triggered.connect(self._on_grid_system)
|
||||||
|
|
|
||||||
|
|
@ -193,8 +193,41 @@ class MenuBuilder:
|
||||||
self._act_quick_guide = QAction("&Quick Guide", self)
|
self._act_quick_guide = QAction("&Quick Guide", self)
|
||||||
self._act_set_units = QAction("Set Display &Units…", self)
|
self._act_set_units = QAction("Set Display &Units…", self)
|
||||||
|
|
||||||
|
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
||||||
|
from otko.views.canvas_base import plotly_available
|
||||||
|
|
||||||
|
self._canvas_backend_group = QActionGroup(self)
|
||||||
|
self._canvas_backend_group.setExclusive(True)
|
||||||
|
self._act_canvas_pyvista = QAction("&PyVista (Native)", self, checkable=True)
|
||||||
|
self._act_canvas_pyvista.setToolTip(
|
||||||
|
"Native VTK/OpenGL viewport — fastest for large models."
|
||||||
|
)
|
||||||
|
self._act_canvas_plotly = QAction("&Plotly (WebGL)", self, checkable=True)
|
||||||
|
self._act_canvas_plotly.setToolTip(
|
||||||
|
"plotly.js rendered in a WebEngine view — richer graphics, higher overhead."
|
||||||
|
)
|
||||||
|
self._canvas_backend_group.addAction(self._act_canvas_pyvista)
|
||||||
|
self._canvas_backend_group.addAction(self._act_canvas_plotly)
|
||||||
|
if not plotly_available():
|
||||||
|
self._act_canvas_plotly.setEnabled(False)
|
||||||
|
self._act_canvas_plotly.setToolTip(
|
||||||
|
'Install the optional GUI extra (pip install -e ".[gui]") to enable plotly.'
|
||||||
|
)
|
||||||
|
|
||||||
self._apply_toolbar_icons()
|
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:
|
def _apply_toolbar_icons(self) -> None:
|
||||||
"""Assign icons + self-documenting tips to every toolbar action.
|
"""Assign icons + self-documenting tips to every toolbar action.
|
||||||
|
|
||||||
|
|
@ -479,6 +512,10 @@ class MenuBuilder:
|
||||||
|
|
||||||
m_options = mb.addMenu("&Options")
|
m_options = mb.addMenu("&Options")
|
||||||
m_options.addAction(self._act_set_units)
|
m_options.addAction(self._act_set_units)
|
||||||
|
m_backend = m_options.addMenu("Canvas &Backend")
|
||||||
|
m_backend.addAction(self._act_canvas_pyvista)
|
||||||
|
m_backend.addAction(self._act_canvas_plotly)
|
||||||
|
self._sync_canvas_backend_actions()
|
||||||
|
|
||||||
m_help = mb.addMenu("&Help")
|
m_help = mb.addMenu("&Help")
|
||||||
m_help.addAction(self._act_quick_guide)
|
m_help.addAction(self._act_quick_guide)
|
||||||
|
|
|
||||||
|
|
@ -459,6 +459,7 @@ class RenderControls:
|
||||||
has_pushover = isinstance(self._latest_results, PushoverResults)
|
has_pushover = isinstance(self._latest_results, PushoverResults)
|
||||||
has_rs = isinstance(self._latest_results, ResponseSpectrumResults)
|
has_rs = isinstance(self._latest_results, ResponseSpectrumResults)
|
||||||
n_sel = len(self._canvas.selection.nodes)
|
n_sel = len(self._canvas.selection.nodes)
|
||||||
|
caps = self._canvas.capabilities
|
||||||
self._act_save.setEnabled(has_project)
|
self._act_save.setEnabled(has_project)
|
||||||
self._act_save_as.setEnabled(has_project)
|
self._act_save_as.setEnabled(has_project)
|
||||||
self._act_export_opspy.setEnabled(has_project)
|
self._act_export_opspy.setEnabled(has_project)
|
||||||
|
|
@ -482,9 +483,11 @@ class RenderControls:
|
||||||
self._act_tool_draw_truss.setEnabled(has_project)
|
self._act_tool_draw_truss.setEnabled(has_project)
|
||||||
self._act_show_deformed.setEnabled(has_static)
|
self._act_show_deformed.setEnabled(has_static)
|
||||||
self._act_show_mode_shape.setEnabled(has_modal)
|
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_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_hysteresis.setEnabled(has_transient)
|
||||||
self._act_show_pushover.setEnabled(has_pushover)
|
self._act_show_pushover.setEnabled(has_pushover)
|
||||||
self._act_show_response_spectrum.setEnabled(has_rs)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
|
@ -19,6 +20,27 @@ import pytest
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _wipe_opensees_domain() -> Iterator[None]:
|
def _wipe_opensees_domain() -> Iterator[None]:
|
||||||
"""Wipe the OpenseesPy domain after a test if the solver was imported."""
|
"""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")
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
The documented dependency stack is ``views -> viewmodels -> services -> core``;
|
The documented dependency stack is ``views -> viewmodels -> services -> core``;
|
||||||
each layer may only import from itself or a lower layer, ``core`` stays free of
|
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
|
Qt/solver/plotting libraries, and the rendering backends are confined to the
|
||||||
``views/canvas3d``. These tests never import the application modules; they read
|
canvas packages (``views/canvas3d``, ``views/canvas_plotly``). These tests never
|
||||||
the source tree with ``ast`` so the import discipline can be checked without
|
import the application modules; they read the source tree with ``ast`` so the
|
||||||
executing Qt or the solver. Directories that do not exist yet simply have
|
import discipline can be checked without executing Qt or the solver.
|
||||||
nothing to walk, which trivially satisfies a "forbidden import" rule.
|
Directories that do not exist yet simply have nothing to walk, which trivially
|
||||||
|
satisfies a "forbidden import" rule.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -16,7 +17,10 @@ from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
SRC = ROOT / "src" / "otko"
|
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 binding roots, in every spelling the project might use.
|
||||||
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
|
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
|
||||||
|
|
@ -32,7 +36,7 @@ CORE_FORBIDDEN = QT_ROOTS | {
|
||||||
"matplotlib",
|
"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"}
|
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,14 +79,14 @@ def _violations(
|
||||||
forbidden: set[str],
|
forbidden: set[str],
|
||||||
*,
|
*,
|
||||||
skip: tuple[Path, ...] = (),
|
skip: tuple[Path, ...] = (),
|
||||||
exclude_dir: Path | None = None,
|
exclude_dirs: tuple[Path, ...] = (),
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Return ``"<path>: <module>"`` entries for every forbidden import found."""
|
"""Return ``"<path>: <module>"`` entries for every forbidden import found."""
|
||||||
found: list[str] = []
|
found: list[str] = []
|
||||||
for path in _iter_python_files(root):
|
for path in _iter_python_files(root):
|
||||||
if path in skip:
|
if path in skip:
|
||||||
continue
|
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
|
continue
|
||||||
for module in sorted(_root_imports(path) & forbidden):
|
for module in sorted(_root_imports(path) & forbidden):
|
||||||
found.append(f"{path.relative_to(ROOT)}: {module}")
|
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:
|
def test_no_vtk_import_outside_canvas() -> None:
|
||||||
"""3D/plot backends stay under views/canvas3d/."""
|
"""3D/plot backends stay under the canvas packages."""
|
||||||
violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS)
|
violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS)
|
||||||
assert violations == [], f"backend imports outside views/canvas3d/: {violations}"
|
assert violations == [], f"backend imports outside the canvas packages: {violations}"
|
||||||
|
|
||||||
|
|
||||||
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
|
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
|
||||||
|
|
|
||||||
208
tests/unit/test_plotly_trace_builder.py
Normal file
208
tests/unit/test_plotly_trace_builder.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""Unit tests for the pure Plotly trace builder (no Qt, no plotly import)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from otko.services import load_project
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
from otko.views.canvas_plotly.trace_builder import (
|
||||||
|
PlotlyTraceBuilder,
|
||||||
|
SceneOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name: str): # type: ignore[no-untyped-def]
|
||||||
|
return load_project(EXAMPLES / f"{name}.osmodel")
|
||||||
|
|
||||||
|
|
||||||
|
def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def]
|
||||||
|
return [trace for trace in scene.data if trace.get("name") == name]
|
||||||
|
|
||||||
|
|
||||||
|
def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def]
|
||||||
|
return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data]
|
||||||
|
|
||||||
|
|
||||||
|
def test_builds_grid_nodes_and_frames() -> None:
|
||||||
|
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
||||||
|
names = {trace.get("name") for trace in scene.data}
|
||||||
|
assert "grid-active" in names
|
||||||
|
assert "nodes" in names
|
||||||
|
assert "elements" in names
|
||||||
|
assert scene.diagonal > 0
|
||||||
|
# The hover-snap marker is always present so JS can restyle it.
|
||||||
|
assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_carry_ids_as_customdata() -> None:
|
||||||
|
project = _load("basic_truss")
|
||||||
|
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
||||||
|
(nodes,) = _traces(scene, "nodes")
|
||||||
|
assert nodes["meta"]["kind"] == "node"
|
||||||
|
assert nodes["customdata"] == [node.id for node in project.nodes]
|
||||||
|
assert len(nodes["marker"]["color"]) == len(project.nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def _color_for_index(trace: dict, index: float) -> str:
|
||||||
|
"""Map a ``line.color`` scalar index to its colour via the colorscale."""
|
||||||
|
scale = sorted(trace["line"]["colorscale"], key=lambda item: item[0])
|
||||||
|
return scale[int(index)][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frames_are_one_trace_with_per_element_colours() -> None:
|
||||||
|
"""A single line trace carries the palette through its ``line.color`` array."""
|
||||||
|
project = _load("cantilever")
|
||||||
|
style = RenderStyle()
|
||||||
|
scene = PlotlyTraceBuilder(style).build(
|
||||||
|
project, SceneOptions(selection_elements=frozenset({2}))
|
||||||
|
)
|
||||||
|
frames = _traces(scene, "elements")
|
||||||
|
assert len(frames) == 1, "selection must not split the frame trace"
|
||||||
|
line = frames[0]["line"]
|
||||||
|
assert isinstance(line["color"], list)
|
||||||
|
assert len(line["color"]) == len(frames[0]["x"])
|
||||||
|
assert line["colorscale"]
|
||||||
|
|
||||||
|
colors = line["color"]
|
||||||
|
customdata = frames[0]["customdata"]
|
||||||
|
|
||||||
|
def index_of(element_id: int) -> set[float]:
|
||||||
|
return {colors[k] for k, value in enumerate(customdata) if value == element_id}
|
||||||
|
|
||||||
|
selected = index_of(2)
|
||||||
|
unselected = index_of(1)
|
||||||
|
assert len(selected) == 1 and len(unselected) == 1
|
||||||
|
assert selected != unselected
|
||||||
|
assert _color_for_index(frames[0], next(iter(selected))) == style.selected_color
|
||||||
|
assert _color_for_index(frames[0], next(iter(unselected))) == style.element_beam_color
|
||||||
|
|
||||||
|
|
||||||
|
def test_element_palette_distinguishes_beam_from_truss() -> None:
|
||||||
|
style = RenderStyle()
|
||||||
|
builder = PlotlyTraceBuilder(style)
|
||||||
|
beam = _traces(builder.build(_load("cantilever"), SceneOptions()), "elements")[0]
|
||||||
|
truss = _traces(builder.build(_load("basic_truss"), SceneOptions()), "elements")[0]
|
||||||
|
assert _color_for_index(beam, beam["line"]["color"][0]) == style.element_beam_color
|
||||||
|
assert _color_for_index(truss, truss["line"]["color"][0]) == style.element_truss_color
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_node_is_highlighted_by_colour() -> None:
|
||||||
|
project = _load("basic_truss")
|
||||||
|
target = project.nodes[1].id
|
||||||
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_nodes=frozenset({target})))
|
||||||
|
(nodes,) = _traces(scene, "nodes")
|
||||||
|
ids = nodes["customdata"]
|
||||||
|
assert nodes["marker"]["color"][ids.index(target)] != nodes["marker"]["color"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_working_plane_filters_grid_segments() -> None:
|
||||||
|
project = _load("basic_truss")
|
||||||
|
builder = PlotlyTraceBuilder()
|
||||||
|
iso = builder.build(project, SceneOptions())
|
||||||
|
plane = builder.build(project, SceneOptions(working_plane=("XY", 0.0)))
|
||||||
|
(iso_grid,) = _traces(iso, "grid-active")
|
||||||
|
(plane_grid,) = _traces(plane, "grid-active")
|
||||||
|
assert len(plane_grid["x"]) <= len(iso_grid["x"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_snap_targets_carry_world_coordinates() -> None:
|
||||||
|
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
||||||
|
(snap,) = _traces(scene, "snap")
|
||||||
|
assert snap["meta"]["kind"] == "snap"
|
||||||
|
assert snap["customdata"], "expected at least one snappable intersection"
|
||||||
|
assert all(len(point) == 3 for point in snap["customdata"])
|
||||||
|
assert all(isinstance(coord, float) for point in snap["customdata"] for coord in point)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extrusions_add_a_mesh3d_trace() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(show_extrusions=True))
|
||||||
|
(mesh,) = _traces(scene, "extrusions")
|
||||||
|
assert mesh["type"] == "mesh3d"
|
||||||
|
assert mesh["i"] and mesh["j"] and mesh["k"]
|
||||||
|
assert max(mesh["i"]) < len(mesh["x"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_axes_add_coloured_cones() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(show_local_axes=True))
|
||||||
|
names = {trace.get("name") for trace in scene.data}
|
||||||
|
assert {"local-x", "local-y", "local-z"} <= names
|
||||||
|
for trace in scene.data:
|
||||||
|
if trace.get("name", "").startswith("local-"):
|
||||||
|
assert trace["type"] == "cone"
|
||||||
|
assert len(trace["u"]) == len(trace["x"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_traces_are_text_only() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
scene = PlotlyTraceBuilder().build(
|
||||||
|
project, SceneOptions(show_node_labels=True, show_element_labels=True)
|
||||||
|
)
|
||||||
|
(node_labels,) = _traces(scene, "node-labels")
|
||||||
|
(element_labels,) = _traces(scene, "element-labels")
|
||||||
|
assert node_labels["mode"] == "text"
|
||||||
|
assert len(node_labels["text"]) == len(project.nodes)
|
||||||
|
assert element_labels["mode"] == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deformation_shifts_node_coordinates() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
|
||||||
|
class _Shift:
|
||||||
|
def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray:
|
||||||
|
out = points.copy()
|
||||||
|
out[:, 2] += 1.0
|
||||||
|
return out
|
||||||
|
|
||||||
|
base = PlotlyTraceBuilder().build(project, SceneOptions())
|
||||||
|
moved = PlotlyTraceBuilder().build(project, SceneOptions(deformation=_Shift()))
|
||||||
|
(base_nodes,) = _traces(base, "nodes")
|
||||||
|
(moved_nodes,) = _traces(moved, "nodes")
|
||||||
|
assert moved_nodes["z"] == pytest.approx([z + 1.0 for z in base_nodes["z"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_is_json_serialisable() -> None:
|
||||||
|
scene = PlotlyTraceBuilder().build(
|
||||||
|
_load("cantilever"),
|
||||||
|
SceneOptions(
|
||||||
|
selection_nodes=frozenset({1}),
|
||||||
|
selection_elements=frozenset({1}),
|
||||||
|
working_plane=("XY", 0.0),
|
||||||
|
show_extrusions=True,
|
||||||
|
show_local_axes=True,
|
||||||
|
show_node_labels=True,
|
||||||
|
show_element_labels=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
payload = json.dumps(scene.to_payload())
|
||||||
|
assert '"data"' in payload and '"layout"' in payload
|
||||||
|
assert scene.layout["scene"]["aspectmode"] == "data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_project_yields_only_the_hover_marker() -> None:
|
||||||
|
scene = PlotlyTraceBuilder().build(None, SceneOptions())
|
||||||
|
assert scene.data == []
|
||||||
|
assert scene.hover_trace == -1
|
||||||
|
|
||||||
|
from otko.core import Project
|
||||||
|
|
||||||
|
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
|
||||||
|
assert _kinds(empty) == ["hover"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_trace_meta_marks_elements_pickable() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
scene = PlotlyTraceBuilder().build(project, SceneOptions())
|
||||||
|
(frames,) = _traces(scene, "elements")
|
||||||
|
assert frames["meta"]["kind"] == "element"
|
||||||
|
# None separators break the line into per-element segments.
|
||||||
|
assert None in frames["x"]
|
||||||
|
assert len(frames["customdata"]) == len(frames["x"])
|
||||||
Loading…
Reference in a new issue