Compare commits
156 changed files with 506 additions and 3866 deletions
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
|
|
@ -4,7 +4,6 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main, develop]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, develop]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
|
@ -13,9 +12,8 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.12"
|
||||||
# Pinned to the pre-commit revisions so CI and local hooks agree.
|
- run: pip install ruff
|
||||||
- run: pip install ruff==0.4.4
|
|
||||||
- run: ruff check src tests
|
- run: ruff check src tests
|
||||||
- run: ruff format --check src tests
|
- run: ruff format --check src tests
|
||||||
|
|
||||||
|
|
@ -25,14 +23,11 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.12"
|
||||||
# Pinned to the pre-commit revision (see .pre-commit-config.yaml).
|
- run: pip install -e ".[gui,dev]"
|
||||||
- run: pip install mypy==1.10.0 pydantic numpy
|
- run: mypy src/otko/core src/otko/services src/otko/viewmodels
|
||||||
- run: pip install -e .
|
|
||||||
- run: mypy src/otko/core src/otko/services
|
|
||||||
|
|
||||||
test-headless:
|
test-headless:
|
||||||
needs: [lint, type]
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -48,14 +43,19 @@ jobs:
|
||||||
- run: pytest tests/unit tests/services -m "not slow"
|
- run: pytest tests/unit tests/services -m "not slow"
|
||||||
|
|
||||||
test-gui:
|
test-gui:
|
||||||
needs: [lint, type]
|
strategy:
|
||||||
runs-on: ubuntu-latest
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install Linux Qt deps
|
- name: Install Linux Qt deps
|
||||||
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 \
|
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 \
|
||||||
|
|
@ -63,19 +63,24 @@ jobs:
|
||||||
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 ".[gui,dev]"
|
- run: pip install -e ".[gui,dev]"
|
||||||
- run: xvfb-run -a pytest tests/gui -m "not slow"
|
- name: Run GUI tests (Linux, headless)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: xvfb-run -a pytest tests/gui
|
||||||
|
- name: Run GUI tests (Win/Mac)
|
||||||
|
if: runner.os != 'Linux'
|
||||||
|
run: pytest tests/gui
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
needs: [lint, type]
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: ${{ matrix.python-version }}
|
||||||
- run: pip install -e ".[dev]"
|
- run: pip install -e ".[gui,dev]"
|
||||||
- run: pytest tests/integration -m "not slow"
|
- run: pytest tests/integration -m slow
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -61,7 +61,3 @@ 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
9
.ignore
|
|
@ -1,9 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0.0",
|
|
||||||
"updatedAt": "2026-09-16T17:30:00.000Z",
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"name": "opstool",
|
|
||||||
"resolvedVersion": "1.0.26",
|
|
||||||
"repoUrl": "https://github.com/yexiang92/opstool.git",
|
|
||||||
"ref": "v1.0.26",
|
|
||||||
"path": ".slim/clonedeps/repos/yexiang92__opstool",
|
|
||||||
"packagePath": "opstool/vis",
|
|
||||||
"reason": "Read-only source for evaluating whether to port opstool's PyVista/Plotly visualization settings into otko's canvas backends, or to depend on opstool.vis directly (GPL-3.0, compatible with otko's AGPL-3.0 via GPLv3 section 13)."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
18
AGENTS.md
18
AGENTS.md
|
|
@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
|
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + 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,8 +19,6 @@ 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. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`.
|
|
||||||
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
|
|
||||||
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
|
- `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).
|
||||||
|
|
||||||
|
|
@ -58,17 +56,3 @@ 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,21 +45,6 @@ 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,7 +42,6 @@ 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",
|
||||||
|
|
@ -90,25 +89,7 @@ select = [
|
||||||
"SIM", # simplify
|
"SIM", # simplify
|
||||||
"RUF", # ruff-specific
|
"RUF", # ruff-specific
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = ["E501"] # line length handled by formatter
|
||||||
"E501", # line-length handled by the formatter
|
|
||||||
"RUF001", # ambiguous unicode: engineering notation (×, ≈, ²) is deliberate
|
|
||||||
"RUF002",
|
|
||||||
"RUF003",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
# Engineering symbols (A, I, L, E, P) and `importorskip`-then-import blocks
|
|
||||||
# are normal in tests.
|
|
||||||
"tests/**" = ["E402", "E741", "N802", "N806", "N815"]
|
|
||||||
# Qt requires camelCase signal names and PascalCase event overrides.
|
|
||||||
"src/otko/views/**" = ["N802", "N815"]
|
|
||||||
"src/otko/viewmodels/**" = ["N815"]
|
|
||||||
# Material fields mirror the OpenSees parameter names verbatim (cR1, epsU, ...).
|
|
||||||
"src/otko/core/materials/__init__.py" = ["N815"]
|
|
||||||
# Engineering symbols in numeric code: L (length), Iz/Iy (second moments).
|
|
||||||
"src/otko/services/section_properties.py" = ["N806"]
|
|
||||||
"src/otko/views/canvas3d/model_renderer.py" = ["N802", "N806", "N815"]
|
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ from otko.commands.grid import (
|
||||||
SetCoordSystemsCommand,
|
SetCoordSystemsCommand,
|
||||||
SetGridSystemCommand,
|
SetGridSystemCommand,
|
||||||
)
|
)
|
||||||
|
from otko.commands.series_and_patterns import (
|
||||||
|
AddLoadPatternCommand,
|
||||||
|
AddTimeSeriesCommand,
|
||||||
|
DeleteLoadPatternCommand,
|
||||||
|
DeleteTimeSeriesCommand,
|
||||||
|
UpdateLoadPatternCommand,
|
||||||
|
UpdateTimeSeriesCommand,
|
||||||
|
)
|
||||||
from otko.commands.loads import (
|
from otko.commands.loads import (
|
||||||
AddElementLoadsCommand,
|
AddElementLoadsCommand,
|
||||||
AddNodalLoadsCommand,
|
AddNodalLoadsCommand,
|
||||||
|
|
@ -55,14 +63,6 @@ from otko.commands.sections import (
|
||||||
DeleteSectionsCommand,
|
DeleteSectionsCommand,
|
||||||
UpdateSectionCommand,
|
UpdateSectionCommand,
|
||||||
)
|
)
|
||||||
from otko.commands.series_and_patterns import (
|
|
||||||
AddLoadPatternCommand,
|
|
||||||
AddTimeSeriesCommand,
|
|
||||||
DeleteLoadPatternCommand,
|
|
||||||
DeleteTimeSeriesCommand,
|
|
||||||
UpdateLoadPatternCommand,
|
|
||||||
UpdateTimeSeriesCommand,
|
|
||||||
)
|
|
||||||
from otko.commands.transforms import (
|
from otko.commands.transforms import (
|
||||||
MirrorCommand,
|
MirrorCommand,
|
||||||
MoveNodesCommand,
|
MoveNodesCommand,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ if TYPE_CHECKING:
|
||||||
class AddAnalysisCasesCommand(ProjectCommand):
|
class AddAnalysisCasesCommand(ProjectCommand):
|
||||||
"""Add one or more analysis cases."""
|
"""Add one or more analysis cases."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, cases: list[Any], *, text: str | None = None) -> None:
|
def __init__(
|
||||||
|
self, vm: "ProjectViewModel", cases: list[Any], *, text: str | None = None
|
||||||
|
) -> None:
|
||||||
super().__init__(vm, text or f"Add {len(cases)} analysis case(s)")
|
super().__init__(vm, text or f"Add {len(cases)} analysis case(s)")
|
||||||
self._cases = list(cases)
|
self._cases = list(cases)
|
||||||
|
|
||||||
|
|
@ -34,7 +36,7 @@ class AddAnalysisCasesCommand(ProjectCommand):
|
||||||
class DeleteAnalysisCasesCommand(ProjectCommand):
|
class DeleteAnalysisCasesCommand(ProjectCommand):
|
||||||
"""Remove a set of analysis cases."""
|
"""Remove a set of analysis cases."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, case_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", case_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(case_ids)} analysis case(s)")
|
super().__init__(vm, f"Delete {len(case_ids)} analysis case(s)")
|
||||||
self._case_ids = set(case_ids)
|
self._case_ids = set(case_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
@ -56,7 +58,7 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
|
||||||
class UpdateAnalysisCaseCommand(ProjectCommand):
|
class UpdateAnalysisCaseCommand(ProjectCommand):
|
||||||
"""Replace an analysis case at a given id."""
|
"""Replace an analysis case at a given id."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_case: Any) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_case: Any) -> None:
|
||||||
super().__init__(vm, f"Edit analysis case {new_case.id}")
|
super().__init__(vm, f"Edit analysis case {new_case.id}")
|
||||||
self._new = new_case
|
self._new = new_case
|
||||||
self._old: Any | None = None
|
self._old: Any | None = None
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,16 @@ class ProjectCommand(QUndoCommand):
|
||||||
:meth:`_notify` exactly once at the end of each.
|
:meth:`_notify` exactly once at the end of each.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, text: str) -> None:
|
def __init__(self, vm: "ProjectViewModel", text: str) -> None:
|
||||||
super().__init__(text)
|
super().__init__(text)
|
||||||
self._vm = vm
|
self._vm = vm
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vm(self) -> ProjectViewModel:
|
def vm(self) -> "ProjectViewModel":
|
||||||
return self._vm
|
return self._vm
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def project(self) -> Project:
|
def project(self) -> "Project":
|
||||||
if self._vm.project is None:
|
if self._vm.project is None:
|
||||||
raise RuntimeError(f"Cannot apply '{self.text()}': no active project.")
|
raise RuntimeError(f"Cannot apply '{self.text()}': no active project.")
|
||||||
return self._vm.project
|
return self._vm.project
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ if TYPE_CHECKING:
|
||||||
class AddCombinationsCommand(ProjectCommand):
|
class AddCombinationsCommand(ProjectCommand):
|
||||||
"""Add one or more load combinations."""
|
"""Add one or more load combinations."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, combos: list[Any], *, text: str | None = None) -> None:
|
def __init__(
|
||||||
|
self, vm: "ProjectViewModel", combos: list[Any], *, text: str | None = None
|
||||||
|
) -> None:
|
||||||
super().__init__(vm, text or f"Add {len(combos)} load combination(s)")
|
super().__init__(vm, text or f"Add {len(combos)} load combination(s)")
|
||||||
self._combos = list(combos)
|
self._combos = list(combos)
|
||||||
|
|
||||||
|
|
@ -34,7 +36,7 @@ class AddCombinationsCommand(ProjectCommand):
|
||||||
class DeleteCombinationsCommand(ProjectCommand):
|
class DeleteCombinationsCommand(ProjectCommand):
|
||||||
"""Remove a set of load combinations."""
|
"""Remove a set of load combinations."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, combo_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", combo_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(combo_ids)} load combination(s)")
|
super().__init__(vm, f"Delete {len(combo_ids)} load combination(s)")
|
||||||
self._combo_ids = set(combo_ids)
|
self._combo_ids = set(combo_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
@ -58,7 +60,7 @@ class DeleteCombinationsCommand(ProjectCommand):
|
||||||
class UpdateCombinationCommand(ProjectCommand):
|
class UpdateCombinationCommand(ProjectCommand):
|
||||||
"""Replace a load combination at a given id."""
|
"""Replace a load combination at a given id."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_combo: Any) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_combo: Any) -> None:
|
||||||
super().__init__(vm, f"Edit load combination {new_combo.id}")
|
super().__init__(vm, f"Edit load combination {new_combo.id}")
|
||||||
self._new = new_combo
|
self._new = new_combo
|
||||||
self._old: Any | None = None
|
self._old: Any | None = None
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ if TYPE_CHECKING:
|
||||||
class AddEqualDOFConstraintCommand(ProjectCommand):
|
class AddEqualDOFConstraintCommand(ProjectCommand):
|
||||||
"""Append an ``equalDOF`` constraint to the project (undoable)."""
|
"""Append an ``equalDOF`` constraint to the project (undoable)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, constraint: EqualDOFConstraint) -> None:
|
def __init__(self, vm: "ProjectViewModel", constraint: EqualDOFConstraint) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
vm,
|
vm,
|
||||||
f"Add equalDOF {constraint.retained_node}->{constraint.constrained_node}",
|
f"Add equalDOF {constraint.retained_node}->{constraint.constrained_node}",
|
||||||
|
|
@ -48,7 +48,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, vm: ProjectViewModel, index: int, new_constraint: EqualDOFConstraint
|
self, vm: "ProjectViewModel", index: int, new_constraint: EqualDOFConstraint
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(vm, f"Edit equalDOF row {index + 1}")
|
super().__init__(vm, f"Edit equalDOF row {index + 1}")
|
||||||
self._index = index
|
self._index = index
|
||||||
|
|
@ -71,7 +71,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
|
||||||
class DeleteEqualDOFConstraintCommand(ProjectCommand):
|
class DeleteEqualDOFConstraintCommand(ProjectCommand):
|
||||||
"""Remove ``equalDOF`` constraints by table-row index (no cascade)."""
|
"""Remove ``equalDOF`` constraints by table-row index (no cascade)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, indices: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", indices: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(indices)} constraint(s)")
|
super().__init__(vm, f"Delete {len(indices)} constraint(s)")
|
||||||
self._indices = set(indices)
|
self._indices = set(indices)
|
||||||
self._removed: list[tuple[int, EqualDOFConstraint]] = []
|
self._removed: list[tuple[int, EqualDOFConstraint]] = []
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class AddElementsCommand(ProjectCommand):
|
||||||
"""Add one or more elements in a single undoable step."""
|
"""Add one or more elements in a single undoable step."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, vm: ProjectViewModel, elements: list[Any], *, text: str | None = None
|
self, vm: "ProjectViewModel", elements: list[Any], *, text: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(vm, text or f"Add {len(elements)} element(s)")
|
super().__init__(vm, text or f"Add {len(elements)} element(s)")
|
||||||
self._elements = list(elements)
|
self._elements = list(elements)
|
||||||
|
|
@ -40,7 +40,7 @@ class AddElementsCommand(ProjectCommand):
|
||||||
class DeleteElementsCommand(ProjectCommand):
|
class DeleteElementsCommand(ProjectCommand):
|
||||||
"""Remove a set of elements (no cascade — nodes are not affected)."""
|
"""Remove a set of elements (no cascade — nodes are not affected)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, element_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", element_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(element_ids)} element(s)")
|
super().__init__(vm, f"Delete {len(element_ids)} element(s)")
|
||||||
self._element_ids = set(element_ids)
|
self._element_ids = set(element_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
@ -68,7 +68,7 @@ class AssignSectionCommand(ProjectCommand):
|
||||||
(e.g. trusses, zero-length elements).
|
(e.g. trusses, zero-length elements).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, element_ids: set[int], section_id: int) -> None:
|
def __init__(self, vm: "ProjectViewModel", element_ids: set[int], section_id: int) -> None:
|
||||||
super().__init__(vm, f"Assign section {section_id} to {len(element_ids)} element(s)")
|
super().__init__(vm, f"Assign section {section_id} to {len(element_ids)} element(s)")
|
||||||
self._element_ids = set(element_ids)
|
self._element_ids = set(element_ids)
|
||||||
self._section_id = section_id
|
self._section_id = section_id
|
||||||
|
|
@ -106,7 +106,7 @@ class ConvertElementTypeCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
element_ids: set[int],
|
element_ids: set[int],
|
||||||
target_type: str,
|
target_type: str,
|
||||||
defaults: dict[str, Any] | None = None,
|
defaults: dict[str, Any] | None = None,
|
||||||
|
|
@ -185,7 +185,7 @@ class ReplaceElementsCommand(ProjectCommand):
|
||||||
for another while preserving the id and node connectivity.
|
for another while preserving the id and node connectivity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, replacements: list[Any]) -> None:
|
def __init__(self, vm: "ProjectViewModel", replacements: list[Any]) -> None:
|
||||||
super().__init__(vm, f"Replace {len(replacements)} element(s)")
|
super().__init__(vm, f"Replace {len(replacements)} element(s)")
|
||||||
self._replacements = {el.id: el for el in replacements}
|
self._replacements = {el.id: el for el in replacements}
|
||||||
self._previous: dict[int, Any] = {}
|
self._previous: dict[int, Any] = {}
|
||||||
|
|
@ -217,7 +217,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
element_id: int,
|
element_id: int,
|
||||||
fields: dict[str, Any],
|
fields: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -254,7 +254,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
|
||||||
class AssignMaterialCommand(ProjectCommand):
|
class AssignMaterialCommand(ProjectCommand):
|
||||||
"""Set ``material_id`` on a set of elements (truss-style)."""
|
"""Set ``material_id`` on a set of elements (truss-style)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, element_ids: set[int], material_id: int) -> None:
|
def __init__(self, vm: "ProjectViewModel", element_ids: set[int], material_id: int) -> None:
|
||||||
super().__init__(vm, f"Assign material {material_id} to {len(element_ids)} element(s)")
|
super().__init__(vm, f"Assign material {material_id} to {len(element_ids)} element(s)")
|
||||||
self._element_ids = set(element_ids)
|
self._element_ids = set(element_ids)
|
||||||
self._material_id = material_id
|
self._material_id = material_id
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class SetCoordSystemsCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
new_systems: list[CoordinateGridSystem],
|
new_systems: list[CoordinateGridSystem],
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(vm, "Update coordinate/grid systems")
|
super().__init__(vm, "Update coordinate/grid systems")
|
||||||
|
|
@ -46,7 +46,7 @@ class SetGridSystemCommand(ProjectCommand):
|
||||||
systems at once.
|
systems at once.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_grid: GridSystem) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_grid: GridSystem) -> None:
|
||||||
super().__init__(vm, "Update grid system")
|
super().__init__(vm, "Update grid system")
|
||||||
self._new_grid = new_grid
|
self._new_grid = new_grid
|
||||||
self._previous: GridSystem | None = None
|
self._previous: GridSystem | None = None
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AddNodalLoadsCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_ids: set[int],
|
node_ids: set[int],
|
||||||
forces: tuple[float, float, float, float, float, float],
|
forces: tuple[float, float, float, float, float, float],
|
||||||
pattern_id: int | None = None,
|
pattern_id: int | None = None,
|
||||||
|
|
@ -122,7 +122,7 @@ class AddElementLoadsCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
element_ids: set[int],
|
element_ids: set[int],
|
||||||
wy: float = 0.0,
|
wy: float = 0.0,
|
||||||
wz: float = 0.0,
|
wz: float = 0.0,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class AddMaterialsCommand(ProjectCommand):
|
||||||
"""Add one or more materials in a single undoable step."""
|
"""Add one or more materials in a single undoable step."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, vm: ProjectViewModel, materials: list[Any], *, text: str | None = None
|
self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(vm, text or f"Add {len(materials)} material(s)")
|
super().__init__(vm, text or f"Add {len(materials)} material(s)")
|
||||||
self._materials = list(materials)
|
self._materials = list(materials)
|
||||||
|
|
@ -36,7 +36,7 @@ class AddMaterialsCommand(ProjectCommand):
|
||||||
class DeleteMaterialsCommand(ProjectCommand):
|
class DeleteMaterialsCommand(ProjectCommand):
|
||||||
"""Remove materials (no cascade)."""
|
"""Remove materials (no cascade)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, material_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", material_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(material_ids)} material(s)")
|
super().__init__(vm, f"Delete {len(material_ids)} material(s)")
|
||||||
self._material_ids = set(material_ids)
|
self._material_ids = set(material_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
@ -64,7 +64,7 @@ class UpdateMaterialCommand(ProjectCommand):
|
||||||
otherwise this is an Add+Delete, not an update.
|
otherwise this is an Add+Delete, not an update.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_material: Any) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_material: Any) -> None:
|
||||||
super().__init__(vm, f"Edit material {new_material.id}")
|
super().__init__(vm, f"Edit material {new_material.id}")
|
||||||
self._new = new_material
|
self._new = new_material
|
||||||
self._old: Any | None = None
|
self._old: Any | None = None
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ if TYPE_CHECKING:
|
||||||
class AddNodesCommand(ProjectCommand):
|
class AddNodesCommand(ProjectCommand):
|
||||||
"""Add one or more nodes in a single undoable step."""
|
"""Add one or more nodes in a single undoable step."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, nodes: list[Node], *, text: str | None = None) -> None:
|
def __init__(
|
||||||
|
self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None
|
||||||
|
) -> None:
|
||||||
super().__init__(vm, text or f"Add {len(nodes)} node(s)")
|
super().__init__(vm, text or f"Add {len(nodes)} node(s)")
|
||||||
self._nodes = list(nodes)
|
self._nodes = list(nodes)
|
||||||
|
|
||||||
|
|
@ -39,7 +41,7 @@ class DeleteNodesCommand(ProjectCommand):
|
||||||
them in their original positions.
|
them in their original positions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, node_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", node_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(node_ids)} node(s)")
|
super().__init__(vm, f"Delete {len(node_ids)} node(s)")
|
||||||
self._node_ids = set(node_ids)
|
self._node_ids = set(node_ids)
|
||||||
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
|
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
|
||||||
|
|
@ -93,7 +95,7 @@ class SetRestraintCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_ids: set[int],
|
node_ids: set[int],
|
||||||
restraint: tuple[bool, bool, bool, bool, bool, bool],
|
restraint: tuple[bool, bool, bool, bool, bool, bool],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -128,7 +130,7 @@ class UpdateNodeFieldsCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_id: int,
|
node_id: int,
|
||||||
fields: dict[str, Any],
|
fields: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -166,7 +168,7 @@ class SetMassCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_ids: set[int],
|
node_ids: set[int],
|
||||||
mass: tuple[float, float, float, float, float, float],
|
mass: tuple[float, float, float, float, float, float],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class SetUnitsCommand(ProjectCommand):
|
||||||
so both redo and undo notify (dirty + modelMutated) to refresh.
|
so both redo and undo notify (dirty + modelMutated) to refresh.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, units: UnitSystem) -> None:
|
def __init__(self, vm: "ProjectViewModel", units: UnitSystem) -> None:
|
||||||
super().__init__(vm, f"Set display units to {units.value}")
|
super().__init__(vm, f"Set display units to {units.value}")
|
||||||
self._units = units
|
self._units = units
|
||||||
self._previous: UnitSystem | None = None
|
self._previous: UnitSystem | None = None
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class AddSectionsCommand(ProjectCommand):
|
||||||
"""Add one or more sections in a single undoable step."""
|
"""Add one or more sections in a single undoable step."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, vm: ProjectViewModel, sections: list[Any], *, text: str | None = None
|
self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(vm, text or f"Add {len(sections)} section(s)")
|
super().__init__(vm, text or f"Add {len(sections)} section(s)")
|
||||||
self._sections = list(sections)
|
self._sections = list(sections)
|
||||||
|
|
@ -36,7 +36,7 @@ class AddSectionsCommand(ProjectCommand):
|
||||||
class DeleteSectionsCommand(ProjectCommand):
|
class DeleteSectionsCommand(ProjectCommand):
|
||||||
"""Remove sections (no cascade — elements referencing them must be cleaned separately)."""
|
"""Remove sections (no cascade — elements referencing them must be cleaned separately)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, section_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", section_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(section_ids)} section(s)")
|
super().__init__(vm, f"Delete {len(section_ids)} section(s)")
|
||||||
self._section_ids = set(section_ids)
|
self._section_ids = set(section_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
@ -60,7 +60,7 @@ class DeleteSectionsCommand(ProjectCommand):
|
||||||
class UpdateSectionCommand(ProjectCommand):
|
class UpdateSectionCommand(ProjectCommand):
|
||||||
"""Replace a section's parameters at a given id."""
|
"""Replace a section's parameters at a given id."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_section: Any) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_section: Any) -> None:
|
||||||
super().__init__(vm, f"Edit section {new_section.id}")
|
super().__init__(vm, f"Edit section {new_section.id}")
|
||||||
self._new = new_section
|
self._new = new_section
|
||||||
self._old: Any | None = None
|
self._old: Any | None = None
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||||
class AddTimeSeriesCommand(ProjectCommand):
|
class AddTimeSeriesCommand(ProjectCommand):
|
||||||
"""Append a :class:`TimeSeries` to the project (undoable)."""
|
"""Append a :class:`TimeSeries` to the project (undoable)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, ts: TimeSeries) -> None:
|
def __init__(self, vm: "ProjectViewModel", ts: "TimeSeries") -> None:
|
||||||
super().__init__(vm, f"Add time series '{ts.name or ts.id}'")
|
super().__init__(vm, f"Add time series '{ts.name or ts.id}'")
|
||||||
self._ts = ts
|
self._ts = ts
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ class UpdateTimeSeriesCommand(ProjectCommand):
|
||||||
The new series must carry the same id as the one being replaced.
|
The new series must carry the same id as the one being replaced.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_ts: Any) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_ts: Any) -> None:
|
||||||
super().__init__(vm, f"Edit time series {new_ts.id}")
|
super().__init__(vm, f"Edit time series {new_ts.id}")
|
||||||
self._new = new_ts
|
self._new = new_ts
|
||||||
self._old: Any | None = None
|
self._old: Any | None = None
|
||||||
|
|
@ -67,7 +67,7 @@ class DeleteTimeSeriesCommand(ProjectCommand):
|
||||||
"""Remove time series (no cascade — patterns referencing them must be
|
"""Remove time series (no cascade — patterns referencing them must be
|
||||||
cleaned separately; ``validate_references`` catches strays before save)."""
|
cleaned separately; ``validate_references`` catches strays before save)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, ts_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", ts_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(ts_ids)} time serie(s)")
|
super().__init__(vm, f"Delete {len(ts_ids)} time serie(s)")
|
||||||
self._ts_ids = set(ts_ids)
|
self._ts_ids = set(ts_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
@ -91,7 +91,7 @@ class DeleteTimeSeriesCommand(ProjectCommand):
|
||||||
class AddLoadPatternCommand(ProjectCommand):
|
class AddLoadPatternCommand(ProjectCommand):
|
||||||
"""Append a :class:`LoadPattern` to the project (undoable)."""
|
"""Append a :class:`LoadPattern` to the project (undoable)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, pattern: LoadPattern) -> None:
|
def __init__(self, vm: "ProjectViewModel", pattern: "LoadPattern") -> None:
|
||||||
super().__init__(vm, f"Add pattern '{pattern.name or pattern.id}'")
|
super().__init__(vm, f"Add pattern '{pattern.name or pattern.id}'")
|
||||||
self._pattern = pattern
|
self._pattern = pattern
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
|
||||||
drops the pattern's stored loads.
|
drops the pattern's stored loads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, new_pattern: Any) -> None:
|
def __init__(self, vm: "ProjectViewModel", new_pattern: Any) -> None:
|
||||||
super().__init__(vm, f"Edit pattern {new_pattern.id}")
|
super().__init__(vm, f"Edit pattern {new_pattern.id}")
|
||||||
self._new = new_pattern
|
self._new = new_pattern
|
||||||
self._old: Any | None = None
|
self._old: Any | None = None
|
||||||
|
|
@ -142,7 +142,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
|
||||||
class DeleteLoadPatternCommand(ProjectCommand):
|
class DeleteLoadPatternCommand(ProjectCommand):
|
||||||
"""Remove load patterns (their child nodal/element loads go with them)."""
|
"""Remove load patterns (their child nodal/element loads go with them)."""
|
||||||
|
|
||||||
def __init__(self, vm: ProjectViewModel, pattern_ids: set[int]) -> None:
|
def __init__(self, vm: "ProjectViewModel", pattern_ids: set[int]) -> None:
|
||||||
super().__init__(vm, f"Delete {len(pattern_ids)} pattern(s)")
|
super().__init__(vm, f"Delete {len(pattern_ids)} pattern(s)")
|
||||||
self._pattern_ids = set(pattern_ids)
|
self._pattern_ids = set(pattern_ids)
|
||||||
self._removed: list[tuple[int, Any]] = []
|
self._removed: list[tuple[int, Any]] = []
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ All copies inherit the source node's restraint, mass, and name.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from otko.commands.base import ProjectCommand
|
from otko.commands.base import ProjectCommand
|
||||||
|
from otko.core import Node
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel
|
||||||
|
|
@ -29,7 +30,7 @@ class MoveNodesCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_ids: set[int],
|
node_ids: set[int],
|
||||||
offset: tuple[float, float, float],
|
offset: tuple[float, float, float],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -62,7 +63,7 @@ class ReplicateCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_ids: set[int],
|
node_ids: set[int],
|
||||||
element_ids: set[int],
|
element_ids: set[int],
|
||||||
offset: tuple[float, float, float],
|
offset: tuple[float, float, float],
|
||||||
|
|
@ -146,7 +147,7 @@ class MirrorCommand(ProjectCommand):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
node_ids: set[int],
|
node_ids: set[int],
|
||||||
element_ids: set[int],
|
element_ids: set[int],
|
||||||
plane: Plane,
|
plane: Plane,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ in Phase 6/8.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import Field, PositiveFloat, PositiveInt, model_validator
|
from pydantic import Field, PositiveFloat, PositiveInt, model_validator
|
||||||
|
|
||||||
|
|
@ -269,6 +269,6 @@ class ResponseSpectrumCase(Entity):
|
||||||
|
|
||||||
|
|
||||||
AnalysisCase = Annotated[
|
AnalysisCase = Annotated[
|
||||||
StaticCase | ModalCase | TransientCase | PushoverCase | ResponseSpectrumCase,
|
Union[StaticCase, ModalCase, TransientCase, PushoverCase, ResponseSpectrumCase],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Conventions follow OpenSeesPy ``element ...`` commands.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import Field, PositiveFloat, PositiveInt
|
from pydantic import Field, PositiveFloat, PositiveInt
|
||||||
|
|
||||||
|
|
@ -198,14 +198,16 @@ class QuadElement(Entity):
|
||||||
|
|
||||||
|
|
||||||
Element = Annotated[
|
Element = Annotated[
|
||||||
TrussElement
|
Union[
|
||||||
| CorotTrussElement
|
TrussElement,
|
||||||
| ElasticBeamColumn
|
CorotTrussElement,
|
||||||
| ForceBeamColumn
|
ElasticBeamColumn,
|
||||||
| DispBeamColumn
|
ForceBeamColumn,
|
||||||
| ZeroLengthElement
|
DispBeamColumn,
|
||||||
| ZeroLengthSectionElement
|
ZeroLengthElement,
|
||||||
| BeamWithHingesElement
|
ZeroLengthSectionElement,
|
||||||
| QuadElement,
|
BeamWithHingesElement,
|
||||||
|
QuadElement,
|
||||||
|
],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ class GridSystem(BaseModel):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _sort_and_dedupe(self) -> GridSystem:
|
def _sort_and_dedupe(self) -> "GridSystem":
|
||||||
for name in ("x_grid_lines", "y_grid_lines", "z_grid_lines"):
|
for name in ("x_grid_lines", "y_grid_lines", "z_grid_lines"):
|
||||||
lines: list[GridLine] = list(getattr(self, name))
|
lines: list[GridLine] = list(getattr(self, name))
|
||||||
lines.sort(key=lambda ln: ln.ordinate)
|
lines.sort(key=lambda ln: ln.ordinate)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pattern is deleted, its loads go with it.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ class ResponseSpectrum(Entity):
|
||||||
|
|
||||||
|
|
||||||
TimeSeries = Annotated[
|
TimeSeries = Annotated[
|
||||||
LinearTimeSeries | ConstantTimeSeries | PathTimeSeries,
|
Union[LinearTimeSeries, ConstantTimeSeries, PathTimeSeries],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ class LoadCombination(Entity):
|
||||||
)
|
)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_items(self) -> LoadCombination:
|
def _check_items(self) -> "LoadCombination":
|
||||||
"""Reject duplicate case ids and non-finite factors."""
|
"""Reject duplicate case ids and non-finite factors."""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
|
@ -250,6 +250,6 @@ class LoadCombination(Entity):
|
||||||
|
|
||||||
|
|
||||||
LoadPattern = Annotated[
|
LoadPattern = Annotated[
|
||||||
PlainLoadPattern | UniformExcitationPattern | ImposedSupportMotionPattern,
|
Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ https://openseespydoc.readthedocs.io/en/latest/src/uniaxialMaterial.html
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import Field, PositiveFloat, model_validator
|
from pydantic import Field, PositiveFloat, model_validator
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ class Concrete04(Entity):
|
||||||
)
|
)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _tensile_params_consistent(self) -> Concrete04:
|
def _tensile_params_consistent(self) -> "Concrete04":
|
||||||
if self.fct is not None and self.et is None:
|
if self.fct is not None and self.et is None:
|
||||||
raise ValueError("et is required when fct is given.")
|
raise ValueError("et is required when fct is given.")
|
||||||
if self.et is not None and self.fct is None:
|
if self.et is not None and self.fct is None:
|
||||||
|
|
@ -229,16 +229,18 @@ class HystereticSM(Entity):
|
||||||
|
|
||||||
# ──────────────────────────── Discriminated union ────────────────────────────
|
# ──────────────────────────── Discriminated union ────────────────────────────
|
||||||
Material = Annotated[
|
Material = Annotated[
|
||||||
ElasticIsotropic
|
Union[
|
||||||
| ElasticUniaxial
|
ElasticIsotropic,
|
||||||
| Steel01
|
ElasticUniaxial,
|
||||||
| Steel02
|
Steel01,
|
||||||
| Concrete01
|
Steel02,
|
||||||
| Concrete02
|
Concrete01,
|
||||||
| Concrete04
|
Concrete02,
|
||||||
| ElasticPP
|
Concrete04,
|
||||||
| HystereticMaterial
|
ElasticPP,
|
||||||
| HystereticSM,
|
HystereticMaterial,
|
||||||
|
HystereticSM,
|
||||||
|
],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
"""Tagged union of every material kind. Pydantic uses ``type`` to dispatch on JSON load."""
|
"""Tagged union of every material kind. Pydantic uses ``type`` to dispatch on JSON load."""
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ class Project(BaseModel):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _ensure_global_system(self) -> Project:
|
def _ensure_global_system(self) -> "Project":
|
||||||
"""Guarantee that a 'Global' entry exists as the first coord system."""
|
"""Guarantee that a 'Global' entry exists as the first coord system."""
|
||||||
has_global = any(cs.name == "Global" for cs in self.coord_systems)
|
has_global = any(cs.name == "Global" for cs in self.coord_systems)
|
||||||
if not has_global:
|
if not has_global:
|
||||||
|
|
@ -174,7 +174,7 @@ class Project(BaseModel):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _ensure_indexed(self) -> Project:
|
def _ensure_indexed(self) -> "Project":
|
||||||
"""Wrap every collection in an :class:`_IndexedList` and drop caches.
|
"""Wrap every collection in an :class:`_IndexedList` and drop caches.
|
||||||
|
|
||||||
Runs after each validation pass (construction, ``model_validate``,
|
Runs after each validation pass (construction, ``model_validate``,
|
||||||
|
|
@ -223,7 +223,7 @@ class Project(BaseModel):
|
||||||
|
|
||||||
# ─────────────────── invariants ───────────────────
|
# ─────────────────── invariants ───────────────────
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_ndm_ndf(self) -> Project:
|
def _check_ndm_ndf(self) -> "Project":
|
||||||
valid = {(2, 2), (2, 3), (3, 3), (3, 6)}
|
valid = {(2, 2), (2, 3), (3, 3), (3, 6)}
|
||||||
if (self.ndm, self.ndf) not in valid:
|
if (self.ndm, self.ndf) not in valid:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -233,7 +233,7 @@ class Project(BaseModel):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_unique_ids(self) -> Project:
|
def _check_unique_ids(self) -> "Project":
|
||||||
for label, items in (
|
for label, items in (
|
||||||
("node", self.nodes),
|
("node", self.nodes),
|
||||||
("material", self.materials),
|
("material", self.materials),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ exactly so the runner can emit them without further translation.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt
|
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ class RectShape(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
SectionShape = Annotated[
|
SectionShape = Annotated[
|
||||||
PipeShape | AngleShape | RectShape,
|
Union[PipeShape, AngleShape, RectShape],
|
||||||
Field(discriminator="kind"),
|
Field(discriminator="kind"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -165,12 +165,12 @@ class StraightLayer(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
Patch = Annotated[
|
Patch = Annotated[
|
||||||
RectangularPatch | CircularPatch,
|
Union[RectangularPatch, CircularPatch],
|
||||||
Field(discriminator="kind"),
|
Field(discriminator="kind"),
|
||||||
]
|
]
|
||||||
|
|
||||||
Layer = Annotated[
|
Layer = Annotated[
|
||||||
StraightLayer,
|
Union[StraightLayer],
|
||||||
Field(discriminator="kind"),
|
Field(discriminator="kind"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -225,6 +225,6 @@ class SectionAggregator(Entity):
|
||||||
|
|
||||||
|
|
||||||
Section = Annotated[
|
Section = Annotated[
|
||||||
ElasticSection | FiberSection | SectionAggregator,
|
Union[ElasticSection, FiberSection, SectionAggregator],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,8 @@ the main thread.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ def _record_calls(project: Project, case_id: int | None) -> list[RecordedCall]:
|
||||||
runner.build()
|
runner.build()
|
||||||
else:
|
else:
|
||||||
case = _find_case(project, case_id)
|
case = _find_case(project, case_id)
|
||||||
if isinstance(case, StaticCase | ModalCase | PushoverCase):
|
if isinstance(case, (StaticCase, ModalCase, PushoverCase)):
|
||||||
# Safe to run end-to-end: dummy query results let the
|
# Safe to run end-to-end: dummy query results let the
|
||||||
# harvesting code finish; queries are filtered at render.
|
# harvesting code finish; queries are filtered at render.
|
||||||
runner.run(case)
|
runner.run(case)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ building the PathTimeSeries.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -65,9 +64,10 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
|
||||||
values: list[float] = []
|
values: list[float] = []
|
||||||
for line in lines[data_start:]:
|
for line in lines[data_start:]:
|
||||||
for tok in line.split():
|
for tok in line.split():
|
||||||
# Skip stray non-numeric tokens.
|
try:
|
||||||
with contextlib.suppress(ValueError):
|
|
||||||
values.append(float(tok))
|
values.append(float(tok))
|
||||||
|
except ValueError:
|
||||||
|
pass # skip stray tokens
|
||||||
if not values:
|
if not values:
|
||||||
raise ValueError("Header parsed but no numeric data lines found.")
|
raise ValueError("Header parsed but no numeric data lines found.")
|
||||||
return dt, npts, values
|
return dt, npts, values
|
||||||
|
|
@ -83,8 +83,10 @@ def parse_plain_values(path: str | Path) -> list[float]:
|
||||||
vals: list[float] = []
|
vals: list[float] = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
for tok in line.split():
|
for tok in line.split():
|
||||||
with contextlib.suppress(ValueError):
|
try:
|
||||||
vals.append(float(tok))
|
vals.append(float(tok))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
if not vals:
|
if not vals:
|
||||||
raise ValueError(f"{path} contains no numeric values.")
|
raise ValueError(f"{path} contains no numeric values.")
|
||||||
return vals
|
return vals
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ def _angle_in_arc(angle: float, start: float, span: float) -> bool:
|
||||||
|
|
||||||
def bbox_for_section(
|
def bbox_for_section(
|
||||||
section: object,
|
section: object,
|
||||||
project: Project | None = None,
|
project: "Project | None" = None,
|
||||||
) -> tuple[float, float] | None:
|
) -> tuple[float, float] | None:
|
||||||
"""Return ``(width_y, height_z)`` of the section's local bounding box.
|
"""Return ``(width_y, height_z)`` of the section's local bounding box.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import numpy as np
|
||||||
from otko.core.sections import (
|
from otko.core.sections import (
|
||||||
CircularPatch,
|
CircularPatch,
|
||||||
FiberSection,
|
FiberSection,
|
||||||
|
Fibre,
|
||||||
RectangularPatch,
|
RectangularPatch,
|
||||||
StraightLayer,
|
StraightLayer,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ a re-paint without losing camera/selection state.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal, Slot
|
from PySide6.QtCore import QObject, Signal, Slot
|
||||||
|
|
@ -41,8 +40,10 @@ class ProjectViewModel(QObject):
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def _on_stack_clean_changed(self, clean: bool) -> None:
|
def _on_stack_clean_changed(self, clean: bool) -> None:
|
||||||
# Guard against the late-fire that Qt sends during destruction.
|
# Guard against the late-fire that Qt sends during destruction.
|
||||||
with contextlib.suppress(RuntimeError):
|
try:
|
||||||
self._set_dirty(not clean)
|
self._set_dirty(not clean)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
# ── read ─────────────────────────────────────────────────────────
|
# ── read ─────────────────────────────────────────────────────────
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,9 @@ from otko.views.dialogs import (
|
||||||
AssignSectionDialog,
|
AssignSectionDialog,
|
||||||
AssignSupportDialog,
|
AssignSupportDialog,
|
||||||
AssignZeroLengthSectionDialog,
|
AssignZeroLengthSectionDialog,
|
||||||
|
LoadCombinationManagerDialog,
|
||||||
CoordinateGridSystemsDialog,
|
CoordinateGridSystemsDialog,
|
||||||
LinearTimeSeriesDialog,
|
LinearTimeSeriesDialog,
|
||||||
LoadCombinationManagerDialog,
|
|
||||||
MaterialLibraryDialog,
|
MaterialLibraryDialog,
|
||||||
MaterialTesterDialog,
|
MaterialTesterDialog,
|
||||||
MirrorDialog,
|
MirrorDialog,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ Convention:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -31,7 +30,6 @@ 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")
|
||||||
|
|
||||||
|
|
@ -63,9 +61,8 @@ class DiagramRenderer:
|
||||||
- ``clear()`` removes the overlay.
|
- ``clear()`` removes the overlay.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
|
def __init__(self, plotter: Any) -> 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
|
||||||
|
|
||||||
|
|
@ -160,9 +157,7 @@ class DiagramRenderer:
|
||||||
self._actor = self._plotter.add_mesh(
|
self._actor = self._plotter.add_mesh(
|
||||||
mesh,
|
mesh,
|
||||||
scalars="value",
|
scalars="value",
|
||||||
# Shared response palette (opstool's default diverging scale), so
|
cmap="coolwarm",
|
||||||
# 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},
|
||||||
|
|
@ -259,8 +254,10 @@ class DiagramRenderer:
|
||||||
pass
|
pass
|
||||||
self._actor = None
|
self._actor = None
|
||||||
if self._label_actor is not None:
|
if self._label_actor is not None:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.remove_actor(self._label_actor, render=False)
|
self._plotter.remove_actor(self._label_actor, render=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._label_actor = None
|
self._label_actor = None
|
||||||
|
|
||||||
# ── helpers ─────────────────────────────────────────────────────
|
# ── helpers ─────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -32,9 +31,6 @@ 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)
|
||||||
|
|
@ -342,17 +338,6 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||||
self._renderer.set_show_local_axes(enabled)
|
self._renderer.set_show_local_axes(enabled)
|
||||||
self.render()
|
self.render()
|
||||||
|
|
||||||
def set_style(self, style: RenderStyle) -> None:
|
|
||||||
"""Swap the visual style and repaint the scene.
|
|
||||||
|
|
||||||
Applies the background immediately (it lives outside the renderer)
|
|
||||||
and rebuilds the actors whose colours come from the style LUTs.
|
|
||||||
"""
|
|
||||||
self._style = style
|
|
||||||
self.set_background(style.background_bottom, top=style.background_top)
|
|
||||||
self._renderer.set_style(style)
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
"""Toggle viewport labels such as node and element names."""
|
"""Toggle viewport labels such as node and element names."""
|
||||||
self._renderer.set_display_options(
|
self._renderer.set_display_options(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ Mode-aware: MODEL / DEFORMED / MODAL change only the points array.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import enum
|
import enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -25,20 +24,15 @@ from otko.core import (
|
||||||
ElasticBeamColumn,
|
ElasticBeamColumn,
|
||||||
ForceBeamColumn,
|
ForceBeamColumn,
|
||||||
NodalLoad,
|
NodalLoad,
|
||||||
|
QuadElement,
|
||||||
|
UniformElementLoad,
|
||||||
PlainLoadPattern,
|
PlainLoadPattern,
|
||||||
Project,
|
Project,
|
||||||
QuadElement,
|
|
||||||
TrussElement,
|
TrussElement,
|
||||||
UniformElementLoad,
|
|
||||||
ZeroLengthElement,
|
ZeroLengthElement,
|
||||||
ZeroLengthSectionElement,
|
ZeroLengthSectionElement,
|
||||||
)
|
)
|
||||||
from otko.views.canvas3d.style import (
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
SELECTED_STATE,
|
|
||||||
RenderStyle,
|
|
||||||
element_family_index,
|
|
||||||
family_palette,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RendererMode(enum.Enum):
|
class RendererMode(enum.Enum):
|
||||||
|
|
@ -190,6 +184,9 @@ class ModelRenderer:
|
||||||
r, g, b = (int(round(x * 255)) for x in rgb)
|
r, g, b = (int(round(x * 255)) for x in rgb)
|
||||||
return f"#{r:02x}{g:02x}{b:02x}"
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
_NODE_LUT = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected
|
||||||
|
_FRAME_LUT = ["#338cd9", "#00ffff"] # blue normal, cyan selected
|
||||||
|
|
||||||
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
|
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
|
||||||
self._plotter = plotter
|
self._plotter = plotter
|
||||||
self._style = style or RenderStyle()
|
self._style = style or RenderStyle()
|
||||||
|
|
@ -208,8 +205,6 @@ class ModelRenderer:
|
||||||
self._frame_actor: Any = None
|
self._frame_actor: Any = None
|
||||||
self._frame_ids_ordered: list[int] = []
|
self._frame_ids_ordered: list[int] = []
|
||||||
self._frame_id_to_row: dict[int, int] = {}
|
self._frame_id_to_row: dict[int, int] = {}
|
||||||
#: Per-frame palette index (family), parallel to ``_frame_ids_ordered``.
|
|
||||||
self._frame_family: list[int] = []
|
|
||||||
self._node_label_actor: Any = None
|
self._node_label_actor: Any = None
|
||||||
self._element_label_actor: Any = None
|
self._element_label_actor: Any = None
|
||||||
self._show_node_labels: bool = False
|
self._show_node_labels: bool = False
|
||||||
|
|
@ -224,8 +219,10 @@ class ModelRenderer:
|
||||||
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
|
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
|
||||||
self._working_plane: tuple[str, float] | None = None
|
self._working_plane: tuple[str, float] | None = None
|
||||||
|
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.enable_anti_aliasing("ssaa")
|
self._plotter.enable_anti_aliasing("ssaa")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# ── public API ───────────────────────────────────────────────────
|
# ── public API ───────────────────────────────────────────────────
|
||||||
def render(self, project: Project | None) -> None:
|
def render(self, project: Project | None) -> None:
|
||||||
|
|
@ -282,16 +279,6 @@ class ModelRenderer:
|
||||||
if self._project is not None:
|
if self._project is not None:
|
||||||
self.render(self._project)
|
self.render(self._project)
|
||||||
|
|
||||||
def set_style(self, style: RenderStyle) -> None:
|
|
||||||
"""Swap the visual style and rebuild the scene it colours.
|
|
||||||
|
|
||||||
Colours are baked into actor LUTs at build time, so a style change
|
|
||||||
needs a full re-render rather than a scalar-array tweak.
|
|
||||||
"""
|
|
||||||
self._style = style
|
|
||||||
if self._project is not None:
|
|
||||||
self.render(self._project)
|
|
||||||
|
|
||||||
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
|
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
|
||||||
"""Update the working-plane filter and rebuild the grid overlay.
|
"""Update the working-plane filter and rebuild the grid overlay.
|
||||||
|
|
||||||
|
|
@ -320,12 +307,11 @@ class ModelRenderer:
|
||||||
self._reglyph_nodes()
|
self._reglyph_nodes()
|
||||||
|
|
||||||
if self._frame_pd is not None and self._frame_ids_ordered:
|
if self._frame_pd is not None and self._frame_ids_ordered:
|
||||||
# Restore each cell to its family slot, then flag the selected ones.
|
states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8)
|
||||||
states = np.array(self._frame_family, dtype=np.int8)
|
|
||||||
for eid in element_ids:
|
for eid in element_ids:
|
||||||
row = self._frame_id_to_row.get(eid)
|
row = self._frame_id_to_row.get(eid)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
states[row] = SELECTED_STATE
|
states[row] = 1
|
||||||
self._frame_pd.cell_data["_oss_state"] = states
|
self._frame_pd.cell_data["_oss_state"] = states
|
||||||
self._frame_pd.Modified()
|
self._frame_pd.Modified()
|
||||||
|
|
||||||
|
|
@ -343,8 +329,10 @@ class ModelRenderer:
|
||||||
"""
|
"""
|
||||||
# Remove any previous marker.
|
# Remove any previous marker.
|
||||||
if self._hover_actor is not None:
|
if self._hover_actor is not None:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.remove_actor(self._hover_actor, render=False)
|
self._plotter.remove_actor(self._hover_actor, render=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._hover_actor = None
|
self._hover_actor = None
|
||||||
|
|
||||||
if world_point is None:
|
if world_point is None:
|
||||||
|
|
@ -388,13 +376,15 @@ class ModelRenderer:
|
||||||
sphere = pv.Sphere(radius=radius, theta_resolution=8, phi_resolution=8)
|
sphere = pv.Sphere(radius=radius, theta_resolution=8, phi_resolution=8)
|
||||||
glyph = self._node_pd.glyph(geom=sphere, scale=False, orient=False)
|
glyph = self._node_pd.glyph(geom=sphere, scale=False, orient=False)
|
||||||
if self._node_actor is not None:
|
if self._node_actor is not None:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.remove_actor(self._node_actor, render=False)
|
self._plotter.remove_actor(self._node_actor, render=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._node_glyph = glyph
|
self._node_glyph = glyph
|
||||||
self._node_actor = self._plotter.add_mesh(
|
self._node_actor = self._plotter.add_mesh(
|
||||||
glyph,
|
glyph,
|
||||||
scalars="_oss_state",
|
scalars="_oss_state",
|
||||||
cmap=[self._style.node_color, self._style.node_selected_color],
|
cmap=self._NODE_LUT,
|
||||||
clim=[0, 1],
|
clim=[0, 1],
|
||||||
show_scalar_bar=False,
|
show_scalar_bar=False,
|
||||||
pickable=True,
|
pickable=True,
|
||||||
|
|
@ -407,7 +397,6 @@ class ModelRenderer:
|
||||||
return
|
return
|
||||||
cells: list[int] = []
|
cells: list[int] = []
|
||||||
ids: list[int] = []
|
ids: list[int] = []
|
||||||
families: list[int] = []
|
|
||||||
for el in frames:
|
for el in frames:
|
||||||
try:
|
try:
|
||||||
i = self._node_id_to_row[el.nodes[0]]
|
i = self._node_id_to_row[el.nodes[0]]
|
||||||
|
|
@ -416,7 +405,6 @@ class ModelRenderer:
|
||||||
continue
|
continue
|
||||||
cells.extend([2, i, j])
|
cells.extend([2, i, j])
|
||||||
ids.append(el.id)
|
ids.append(el.id)
|
||||||
families.append(element_family_index(el))
|
|
||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
pd = pv.PolyData()
|
pd = pv.PolyData()
|
||||||
|
|
@ -424,18 +412,16 @@ class ModelRenderer:
|
||||||
pd.lines = np.array(cells, dtype=np.int64)
|
pd.lines = np.array(cells, dtype=np.int64)
|
||||||
pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64)
|
pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64)
|
||||||
pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object)
|
pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object)
|
||||||
# Cell scalar carries the palette slot (family, or SELECTED_STATE).
|
pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8)
|
||||||
pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8)
|
|
||||||
|
|
||||||
self._frame_pd = pd
|
self._frame_pd = pd
|
||||||
self._frame_ids_ordered = ids
|
self._frame_ids_ordered = ids
|
||||||
self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)}
|
self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)}
|
||||||
self._frame_family = families
|
|
||||||
self._frame_actor = self._plotter.add_mesh(
|
self._frame_actor = self._plotter.add_mesh(
|
||||||
pd,
|
pd,
|
||||||
scalars="_oss_state",
|
scalars="_oss_state",
|
||||||
cmap=family_palette(self._style),
|
cmap=self._FRAME_LUT,
|
||||||
clim=[0, SELECTED_STATE],
|
clim=[0, 1],
|
||||||
show_scalar_bar=False,
|
show_scalar_bar=False,
|
||||||
line_width=3.0,
|
line_width=3.0,
|
||||||
pickable=True,
|
pickable=True,
|
||||||
|
|
@ -498,17 +484,10 @@ class ModelRenderer:
|
||||||
plane_axis = axis_idx
|
plane_axis = axis_idx
|
||||||
plane_offset_local = plane_off - cs_shift
|
plane_offset_local = plane_off - cs_shift
|
||||||
|
|
||||||
# Bind the per-iteration plane state as defaults so the closure
|
def _on_active_plane(local_pt: tuple[float, float, float]) -> bool:
|
||||||
# cannot read a later iteration's values (B023); the helper is
|
if plane_axis is None:
|
||||||
# only ever called from within this loop body.
|
|
||||||
def _on_active_plane(
|
|
||||||
local_pt: tuple[float, float, float],
|
|
||||||
_axis: int | None = plane_axis,
|
|
||||||
_offset: float | None = plane_offset_local,
|
|
||||||
) -> bool:
|
|
||||||
if _axis is None or _offset is None:
|
|
||||||
return True
|
return True
|
||||||
return abs(local_pt[_axis] - _offset) < 1e-6
|
return abs(local_pt[plane_axis] - plane_offset_local) < 1e-6 # type: ignore[operator]
|
||||||
|
|
||||||
# Collect active + dim segments separately so they get their
|
# Collect active + dim segments separately so they get their
|
||||||
# own polydata + actor (different opacity / color).
|
# own polydata + actor (different opacity / color).
|
||||||
|
|
@ -517,21 +496,13 @@ class ModelRenderer:
|
||||||
dim_pts: list[tuple[float, float, float]] = []
|
dim_pts: list[tuple[float, float, float]] = []
|
||||||
dim_cells: list[int] = []
|
dim_cells: list[int] = []
|
||||||
|
|
||||||
def add_seg(
|
def add_seg(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None:
|
||||||
p1: tuple[float, float, float],
|
|
||||||
p2: tuple[float, float, float],
|
|
||||||
_active_pts: list[tuple[float, float, float]] = active_pts,
|
|
||||||
_active_cells: list[int] = active_cells,
|
|
||||||
_dim_pts: list[tuple[float, float, float]] = dim_pts,
|
|
||||||
_dim_cells: list[int] = dim_cells,
|
|
||||||
_cs: Any = cs,
|
|
||||||
) -> None:
|
|
||||||
on_active = _on_active_plane(p1) and _on_active_plane(p2)
|
on_active = _on_active_plane(p1) and _on_active_plane(p2)
|
||||||
bucket_pts = _active_pts if on_active else _dim_pts
|
bucket_pts = active_pts if on_active else dim_pts
|
||||||
bucket_cells = _active_cells if on_active else _dim_cells
|
bucket_cells = active_cells if on_active else dim_cells
|
||||||
i = len(bucket_pts)
|
i = len(bucket_pts)
|
||||||
bucket_pts.append(_cs.coord.local_to_world(p1))
|
bucket_pts.append(cs.coord.local_to_world(p1))
|
||||||
bucket_pts.append(_cs.coord.local_to_world(p2))
|
bucket_pts.append(cs.coord.local_to_world(p2))
|
||||||
bucket_cells.extend([2, i, i + 1])
|
bucket_cells.extend([2, i, i + 1])
|
||||||
|
|
||||||
z_planes = zs if zs else [0.0]
|
z_planes = zs if zs else [0.0]
|
||||||
|
|
@ -643,7 +614,7 @@ class ModelRenderer:
|
||||||
continue
|
continue
|
||||||
# By design: area-only / thickness-only / material-only
|
# By design: area-only / thickness-only / material-only
|
||||||
# elements carry no section.
|
# elements carry no section.
|
||||||
if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement):
|
if isinstance(el, (TrussElement, CorotTrussElement, QuadElement, ZeroLengthElement)):
|
||||||
continue
|
continue
|
||||||
node_i = node_by_id.get(el.nodes[0])
|
node_i = node_by_id.get(el.nodes[0])
|
||||||
node_j = node_by_id.get(el.nodes[1])
|
node_j = node_by_id.get(el.nodes[1])
|
||||||
|
|
@ -694,16 +665,18 @@ class ModelRenderer:
|
||||||
diag = self._diag_of_points(self._node_original_points)
|
diag = self._diag_of_points(self._node_original_points)
|
||||||
cap = max(diag * 0.08, 1e-6)
|
cap = max(diag * 0.08, 1e-6)
|
||||||
for el in project.elements:
|
for el in project.elements:
|
||||||
if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement):
|
if isinstance(el, (QuadElement, ZeroLengthElement, ZeroLengthSectionElement)):
|
||||||
continue
|
continue
|
||||||
if not isinstance(
|
if not isinstance(
|
||||||
el,
|
el,
|
||||||
ElasticBeamColumn
|
(
|
||||||
| DispBeamColumn
|
ElasticBeamColumn,
|
||||||
| ForceBeamColumn
|
DispBeamColumn,
|
||||||
| BeamWithHingesElement
|
ForceBeamColumn,
|
||||||
| TrussElement
|
BeamWithHingesElement,
|
||||||
| CorotTrussElement,
|
TrussElement,
|
||||||
|
CorotTrussElement,
|
||||||
|
),
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
node_i = node_by_id.get(el.nodes[0])
|
node_i = node_by_id.get(el.nodes[0])
|
||||||
|
|
@ -780,8 +753,8 @@ class ModelRenderer:
|
||||||
"""Add one semi-transparent steel-blue non-pickable aux actor."""
|
"""Add one semi-transparent steel-blue non-pickable aux actor."""
|
||||||
actor = self._plotter.add_mesh(
|
actor = self._plotter.add_mesh(
|
||||||
mesh,
|
mesh,
|
||||||
color=self._style.extrusion_color,
|
color=(0.35, 0.60, 0.85), # cool steel-blue
|
||||||
opacity=self._style.extrusion_opacity,
|
opacity=0.22,
|
||||||
show_edges=True,
|
show_edges=True,
|
||||||
edge_color=(0.15, 0.25, 0.45),
|
edge_color=(0.15, 0.25, 0.45),
|
||||||
line_width=1.0,
|
line_width=1.0,
|
||||||
|
|
@ -1001,7 +974,7 @@ class ModelRenderer:
|
||||||
ndf = project.ndf
|
ndf = project.ndf
|
||||||
dof_idx = _dof_indices(ndf)
|
dof_idx = _dof_indices(ndf)
|
||||||
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
|
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
|
||||||
support_color = self._style.support_color
|
support_color = (1.0, 0.5, 0.1)
|
||||||
for node in project.nodes:
|
for node in project.nodes:
|
||||||
if not any(node.restraint[i] for i in dof_idx):
|
if not any(node.restraint[i] for i in dof_idx):
|
||||||
continue
|
continue
|
||||||
|
|
@ -1015,7 +988,7 @@ class ModelRenderer:
|
||||||
if not project.load_patterns or self._node_original_points is None:
|
if not project.load_patterns or self._node_original_points is None:
|
||||||
return
|
return
|
||||||
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
|
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
|
||||||
load_color = self._style.nodal_load_color
|
load_color = (0.2, 0.85, 0.2)
|
||||||
node_by_id = {n.id: n for n in project.nodes}
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
elem_by_id = {e.id: e for e in project.elements}
|
elem_by_id = {e.id: e for e in project.elements}
|
||||||
for pattern in project.load_patterns:
|
for pattern in project.load_patterns:
|
||||||
|
|
@ -1046,7 +1019,7 @@ class ModelRenderer:
|
||||||
# Draw N arrows along the element span, each perpendicular
|
# Draw N arrows along the element span, each perpendicular
|
||||||
# to the axis in the direction of the load. Uses the same
|
# to the axis in the direction of the load. Uses the same
|
||||||
# orange-green palette as nodal loads but with shorter arrows.
|
# orange-green palette as nodal loads but with shorter arrows.
|
||||||
elem_load_color = self._style.element_load_color # distributed loads
|
elem_load_color = (1.0, 0.55, 0.2) # orange
|
||||||
n_arrows_per_elem = 5
|
n_arrows_per_elem = 5
|
||||||
for eload in pattern.element_loads:
|
for eload in pattern.element_loads:
|
||||||
if not isinstance(eload, UniformElementLoad):
|
if not isinstance(eload, UniformElementLoad):
|
||||||
|
|
@ -1121,11 +1094,15 @@ class ModelRenderer:
|
||||||
self._clear_label_actors()
|
self._clear_label_actors()
|
||||||
for a in (self._node_actor, self._frame_actor):
|
for a in (self._node_actor, self._frame_actor):
|
||||||
if a is not None:
|
if a is not None:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.remove_actor(a, render=False)
|
self._plotter.remove_actor(a, render=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for a in self._aux_actors:
|
for a in self._aux_actors:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.remove_actor(a, render=False)
|
self._plotter.remove_actor(a, render=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._node_actor = None
|
self._node_actor = None
|
||||||
self._frame_actor = None
|
self._frame_actor = None
|
||||||
self._aux_actors.clear()
|
self._aux_actors.clear()
|
||||||
|
|
@ -1135,7 +1112,6 @@ class ModelRenderer:
|
||||||
self._node_ids_ordered = []
|
self._node_ids_ordered = []
|
||||||
self._node_id_to_row = {}
|
self._node_id_to_row = {}
|
||||||
self._frame_ids_ordered = []
|
self._frame_ids_ordered = []
|
||||||
self._frame_family = []
|
|
||||||
self._frame_id_to_row = {}
|
self._frame_id_to_row = {}
|
||||||
self._node_original_points = None
|
self._node_original_points = None
|
||||||
self._deformation = None
|
self._deformation = None
|
||||||
|
|
@ -1144,8 +1120,10 @@ class ModelRenderer:
|
||||||
def _clear_label_actors(self) -> None:
|
def _clear_label_actors(self) -> None:
|
||||||
for actor in (self._node_label_actor, self._element_label_actor):
|
for actor in (self._node_label_actor, self._element_label_actor):
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plotter.remove_actor(actor, render=False)
|
self._plotter.remove_actor(actor, render=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._node_label_actor = None
|
self._node_label_actor = None
|
||||||
self._element_label_actor = None
|
self._element_label_actor = None
|
||||||
|
|
||||||
|
|
@ -1168,7 +1146,7 @@ class ModelRenderer:
|
||||||
return self._plotter.add_point_labels(
|
return self._plotter.add_point_labels(
|
||||||
pts,
|
pts,
|
||||||
labels,
|
labels,
|
||||||
font_size=self._style.label_font_size,
|
font_size=12,
|
||||||
shape_opacity=0.15,
|
shape_opacity=0.15,
|
||||||
text_color="black",
|
text_color="black",
|
||||||
point_color="white",
|
point_color="white",
|
||||||
|
|
@ -1198,7 +1176,7 @@ class ModelRenderer:
|
||||||
return self._plotter.add_point_labels(
|
return self._plotter.add_point_labels(
|
||||||
np.asarray(centers),
|
np.asarray(centers),
|
||||||
labels,
|
labels,
|
||||||
font_size=self._style.label_font_size,
|
font_size=12,
|
||||||
shape_opacity=0.15,
|
shape_opacity=0.15,
|
||||||
text_color="black",
|
text_color="black",
|
||||||
point_color="white",
|
point_color="white",
|
||||||
|
|
|
||||||
|
|
@ -4,64 +4,11 @@ All colors, sizes, and glyph parameters live here. Changes apply
|
||||||
globally through the renderer; downstream code never hard-codes a
|
globally through the renderer; downstream code never hard-codes a
|
||||||
color or radius. A future "theme" feature can simply swap a different
|
color or radius. A future "theme" feature can simply swap a different
|
||||||
RenderStyle instance.
|
RenderStyle instance.
|
||||||
|
|
||||||
The subset in :attr:`RenderStyle.EDITABLE` is surfaced to the user through
|
|
||||||
Options → Plot Properties… and persisted in ``QSettings`` (``plot/props``).
|
|
||||||
Palette values marked "opstool" are adapted from opstool v1.0.26 (GPL-3.0);
|
|
||||||
see ``NOTICE``.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from otko.core import CorotTrussElement, TrussElement, ZeroLengthElement
|
|
||||||
|
|
||||||
#: Selected elements / nodes occupy the last slot of the palette LUT.
|
|
||||||
SELECTED_STATE: int = 3
|
|
||||||
|
|
||||||
|
|
||||||
def element_family_index(element: object) -> int:
|
|
||||||
"""Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length."""
|
|
||||||
if isinstance(element, TrussElement | CorotTrussElement):
|
|
||||||
return 1
|
|
||||||
if isinstance(element, ZeroLengthElement):
|
|
||||||
return 2
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def family_palette(style: RenderStyle) -> list[str]:
|
|
||||||
"""LUT shared by both backends: [beam, truss, link, selected]."""
|
|
||||||
return [
|
|
||||||
style.element_beam_color,
|
|
||||||
style.element_truss_color,
|
|
||||||
style.element_link_color,
|
|
||||||
style.selected_color,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
#: Colours / sizes the Plot Properties dialog exposes, in display order.
|
|
||||||
STYLE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
|
||||||
("element_beam_color", "Beam elements", "color"),
|
|
||||||
("element_truss_color", "Truss elements", "color"),
|
|
||||||
("element_link_color", "Link / zero-length", "color"),
|
|
||||||
("selected_color", "Selection highlight", "color"),
|
|
||||||
("node_color", "Nodes", "color"),
|
|
||||||
("node_selected_color", "Selected nodes", "color"),
|
|
||||||
("support_color", "Supports", "color"),
|
|
||||||
("nodal_load_color", "Nodal loads", "color"),
|
|
||||||
("element_load_color", "Element loads", "color"),
|
|
||||||
("extrusion_color", "Section extrusions", "color"),
|
|
||||||
("extrusion_opacity", "Extrusion opacity", "float"),
|
|
||||||
("background_top", "Background (top)", "color"),
|
|
||||||
("background_bottom", "Background (bottom)", "color"),
|
|
||||||
("show_axis_outline", "Axis outline (grid + ticks)", "bool"),
|
|
||||||
("label_font_size", "Label font size", "int"),
|
|
||||||
)
|
|
||||||
|
|
||||||
#: Field names the dialog edits and that are persisted between sessions.
|
|
||||||
EDITABLE_FIELDS: tuple[str, ...] = tuple(name for name, _label, _kind in STYLE_FIELDS)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -72,58 +19,20 @@ class RenderStyle:
|
||||||
background_top: str = "#dbe2ef"
|
background_top: str = "#dbe2ef"
|
||||||
background_bottom: str = "#f5f7fb"
|
background_bottom: str = "#f5f7fb"
|
||||||
|
|
||||||
node_color: str = "#d9d9d9" # normal node sphere / marker
|
node_color: str = "#f0a500" # warm gold
|
||||||
node_selected_color: str = "#00ffff" # selected node
|
node_selected_color: str = "#00d4ff" # bright cyan
|
||||||
|
|
||||||
frame_color: str = "#1f1f1f"
|
frame_color: str = "#1f1f1f"
|
||||||
truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad
|
truss_color: str = "#2e5cb8"
|
||||||
zerolength_color: str = "#a020f0"
|
zerolength_color: str = "#a020f0"
|
||||||
selected_color: str = "#00ffff" # selected element highlight
|
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
|
|
||||||
|
|
||||||
# ── overlay colours ─────────────────────────────────────────────
|
|
||||||
support_color: str = "#ff8019"
|
|
||||||
nodal_load_color: str = "#33d933"
|
|
||||||
element_load_color: str = "#ff8c33"
|
|
||||||
extrusion_color: str = "#598cff"
|
|
||||||
extrusion_opacity: float = 0.22
|
|
||||||
label_font_size: int = 12
|
|
||||||
|
|
||||||
#: Draw the plotly scene grid + tick marks around the model (the "outline"
|
|
||||||
#: of opstool's scene recipe). Off by default to keep the SAP2000-like
|
|
||||||
#: clean viewport; the coloured X/Y/Z axis lines always stay visible.
|
|
||||||
show_axis_outline: bool = False
|
|
||||||
|
|
||||||
#: Diverging scale for scalar response overlays (force diagrams today,
|
|
||||||
#: nodal / element response plots later): blue → red, evenly spaced.
|
|
||||||
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
|
|
||||||
response_scale_colors: tuple[str, ...] = (
|
|
||||||
"#313695",
|
|
||||||
"#4575b4",
|
|
||||||
"#74add1",
|
|
||||||
"#abd9e9",
|
|
||||||
"#e0f3f8",
|
|
||||||
"#fee090",
|
|
||||||
"#fdae61",
|
|
||||||
"#f46d43",
|
|
||||||
"#d73027",
|
|
||||||
"#a50026",
|
|
||||||
)
|
|
||||||
|
|
||||||
fix_color: str = "#c0392b" # firebrick
|
fix_color: str = "#c0392b" # firebrick
|
||||||
pin_color: str = "#c0392b"
|
pin_color: str = "#c0392b"
|
||||||
roller_color: str = "#e67e22"
|
roller_color: str = "#e67e22"
|
||||||
custom_support_color: str = "#7f8c8d"
|
custom_support_color: str = "#7f8c8d"
|
||||||
|
|
||||||
load_color: str = "#27ae60" # Y-axis colour of the view triad
|
load_color: str = "#27ae60"
|
||||||
mass_color: str = "#9b59b6"
|
mass_color: str = "#9b59b6"
|
||||||
|
|
||||||
# ── sizes (relative to bbox diagonal unless absolute) ────────────
|
# ── sizes (relative to bbox diagonal unless absolute) ────────────
|
||||||
|
|
@ -140,24 +49,3 @@ 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
|
||||||
|
|
||||||
#: Names of the fields the Plot Properties dialog edits.
|
|
||||||
EDITABLE: ClassVar[tuple[str, ...]] = EDITABLE_FIELDS
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────
|
|
||||||
def response_colorscale(self) -> list[tuple[float, str]]:
|
|
||||||
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
|
|
||||||
count = len(self.response_scale_colors)
|
|
||||||
if count < 2:
|
|
||||||
return [(0.0, self.response_scale_colors[0])]
|
|
||||||
return [
|
|
||||||
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
|
|
||||||
]
|
|
||||||
|
|
||||||
def with_updates(self, **changes: Any) -> RenderStyle:
|
|
||||||
"""Return a copy with ``changes`` applied (frozen-dataclass safe)."""
|
|
||||||
return replace(self, **changes)
|
|
||||||
|
|
||||||
def editable_values(self) -> dict[str, Any]:
|
|
||||||
"""The dialog-managed subset of this style, as a plain dict."""
|
|
||||||
return {name: getattr(self, name) for name in self.EDITABLE}
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
"""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: ...
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"""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}")
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
"""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))
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
"""HTML/JS runtime for the Plotly canvas.
|
|
||||||
|
|
||||||
The page is materialised once per process into a temp directory and loaded
|
|
||||||
from ``file://``: the plotly.js bundle is ~5 MB, which is past
|
|
||||||
``QWebEngineView.setHtml``'s data-URL limit, and writing it to disk also lets
|
|
||||||
the browser cache it across figure updates.
|
|
||||||
|
|
||||||
The JS side exposes three entry points to Python (called via
|
|
||||||
``QWebEnginePage.runJavaScript``):
|
|
||||||
|
|
||||||
- ``otkoUpdate(payloadJson)`` — replace data + layout with ``Plotly.react``,
|
|
||||||
which diffs client-side and leaves the interactive camera untouched.
|
|
||||||
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
|
|
||||||
parallel-projection toggle).
|
|
||||||
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview.
|
|
||||||
|
|
||||||
Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
|
|
||||||
entity picks carry the trace ``meta.kind`` and the point ``customdata``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import atexit
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
#: Materialised runtime directory (plotly.min.js + index.html + qwebchannel.js).
|
|
||||||
_ASSETS_DIR: Path | None = None
|
|
||||||
|
|
||||||
# ``qrc:///qtwebchannel/qwebchannel.js`` is served by QtWebEngine's internal
|
|
||||||
# resource scheme; it is not reachable through QFile.
|
|
||||||
_PAGE = """<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
|
||||||
<style>
|
|
||||||
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
|
|
||||||
#plot { width: 100%; height: 100%; }
|
|
||||||
.modebar { display: none !important; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="plot"></div>
|
|
||||||
<script src="plotly.min.js"></script>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var bridge = null;
|
|
||||||
var snapEnabled = false;
|
|
||||||
var handlersReady = false;
|
|
||||||
var hoverIndex = -1;
|
|
||||||
|
|
||||||
var config = {
|
|
||||||
responsive: true,
|
|
||||||
displayModeBar: false,
|
|
||||||
scrollZoom: true,
|
|
||||||
doubleClick: false,
|
|
||||||
displaylogo: false
|
|
||||||
};
|
|
||||||
|
|
||||||
new QWebChannel(qt.webChannelTransport, function (channel) {
|
|
||||||
bridge = channel.objects.otkoBridge;
|
|
||||||
window.otkoBridge = bridge;
|
|
||||||
});
|
|
||||||
|
|
||||||
function kindOf(point) {
|
|
||||||
var data = point && point.data;
|
|
||||||
return data && data.meta ? data.meta.kind : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHover() {
|
|
||||||
var gd = document.getElementById('plot');
|
|
||||||
hoverIndex = -1;
|
|
||||||
if (!gd || !gd.data) return;
|
|
||||||
for (var i = 0; i < gd.data.length; i++) {
|
|
||||||
var meta = gd.data[i] && gd.data[i].meta;
|
|
||||||
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
|
|
||||||
}
|
|
||||||
// A freshly pushed figure has an empty hover marker again.
|
|
||||||
hoverShown = false;
|
|
||||||
hoverPoint = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- snap-target marker ------------------------------------------------
|
|
||||||
// Updated synchronously but only when the visual state actually changes.
|
|
||||||
// Restyling on every hover/unhover made the plot redraw per mouse move,
|
|
||||||
// which re-fired hover and ended in a runaway redraw loop (RangeError:
|
|
||||||
// Maximum call stack size exceeded, frozen canvas). Timers were avoided
|
|
||||||
// deliberately: WebEngine throttles them to ~1 s when the page is not
|
|
||||||
// compositing, which made the preview lag.
|
|
||||||
var hoverShown = false;
|
|
||||||
var hoverPoint = null;
|
|
||||||
|
|
||||||
function samePoint(a, b) {
|
|
||||||
return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setHoverMarker(point) {
|
|
||||||
var want = snapEnabled ? point : null;
|
|
||||||
if (samePoint(want, hoverPoint) && !!want === hoverShown) return;
|
|
||||||
if (!want && !hoverShown) { hoverPoint = null; return; }
|
|
||||||
hoverPoint = want;
|
|
||||||
hoverShown = !!want;
|
|
||||||
if (hoverIndex < 0) return;
|
|
||||||
var x = want ? [want[0]] : [];
|
|
||||||
var y = want ? [want[1]] : [];
|
|
||||||
var z = want ? [want[2]] : [];
|
|
||||||
Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- view preservation -------------------------------------------------
|
|
||||||
// `Plotly.react` resets any scene attribute the incoming layout omits, so a
|
|
||||||
// non-framing update carries the live camera/ranges forward. Only plain,
|
|
||||||
// validated copies are merged: the raw `_fullLayout` objects are owned by
|
|
||||||
// plotly (react mutates them in place), and injecting an undefined value
|
|
||||||
// would make every later react fail, wedging the plot.
|
|
||||||
function toPlain(value) {
|
|
||||||
try { return JSON.parse(JSON.stringify(value)); } catch (err) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentView() {
|
|
||||||
var gd = document.getElementById('plot');
|
|
||||||
var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null;
|
|
||||||
if (!scene) return null;
|
|
||||||
var view = { ranges: {} };
|
|
||||||
var camera = toPlain(scene.camera);
|
|
||||||
if (camera && camera.eye && camera.center) {
|
|
||||||
view.camera = { eye: camera.eye, center: camera.center, up: camera.up };
|
|
||||||
if (camera.projection) view.camera.projection = camera.projection;
|
|
||||||
}
|
|
||||||
['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) {
|
|
||||||
var source = scene[axis];
|
|
||||||
if (!source || !Array.isArray(source.range) || source.range.length !== 2) return;
|
|
||||||
var low = Number(source.range[0]);
|
|
||||||
var high = Number(source.range[1]);
|
|
||||||
if (isFinite(low) && isFinite(high) && high > low) view.ranges[axis] = [low, high];
|
|
||||||
});
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeView(fig, view) {
|
|
||||||
try {
|
|
||||||
if (!view) return;
|
|
||||||
fig.layout.scene = fig.layout.scene || {};
|
|
||||||
if (view.camera) fig.layout.scene.camera = view.camera;
|
|
||||||
Object.keys(view.ranges).forEach(function (axis) {
|
|
||||||
fig.layout.scene[axis] = fig.layout.scene[axis] || {};
|
|
||||||
fig.layout.scene[axis].range = view.ranges[axis];
|
|
||||||
fig.layout.scene[axis].autorange = false;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Never let view preservation block the figure update.
|
|
||||||
if (window.console) console.warn('otko: view merge skipped', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- update queue ------------------------------------------------------
|
|
||||||
// One react at a time, latest payload wins. Overlapping reacts on the same
|
|
||||||
// graph div are what left the plot unresponsive before.
|
|
||||||
var pendingUpdate = null;
|
|
||||||
var reactBusy = false;
|
|
||||||
|
|
||||||
function runUpdate() {
|
|
||||||
if (reactBusy || !pendingUpdate) return;
|
|
||||||
var job = pendingUpdate;
|
|
||||||
pendingUpdate = null;
|
|
||||||
reactBusy = true;
|
|
||||||
|
|
||||||
var fig;
|
|
||||||
try {
|
|
||||||
fig = JSON.parse(job.payload);
|
|
||||||
} catch (err) {
|
|
||||||
reactBusy = false;
|
|
||||||
if (window.console) console.error('otko: bad payload', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mergeView(fig, job.preserve ? currentView() : null);
|
|
||||||
|
|
||||||
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
|
|
||||||
findHover();
|
|
||||||
installHandlers();
|
|
||||||
}, function (err) {
|
|
||||||
if (window.console) console.error('otko: react failed', err);
|
|
||||||
}).then(function () {
|
|
||||||
reactBusy = false;
|
|
||||||
runUpdate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.otkoUpdate = function (payloadJson, preserveView) {
|
|
||||||
pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
|
|
||||||
runUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
function installHandlers() {
|
|
||||||
if (handlersReady) return;
|
|
||||||
var gd = document.getElementById('plot');
|
|
||||||
if (!gd || !gd.on) return;
|
|
||||||
handlersReady = true;
|
|
||||||
|
|
||||||
gd.on('plotly_click', function (ev) {
|
|
||||||
var pts = ev.points || [];
|
|
||||||
if (!pts.length || !bridge) return;
|
|
||||||
var p = pts[0];
|
|
||||||
var kind = kindOf(p);
|
|
||||||
var additive = !!(ev.event && (ev.event.shiftKey || ev.event.ctrlKey || ev.event.metaKey));
|
|
||||||
if (kind === 'node' || kind === 'element') {
|
|
||||||
bridge.onPicked(kind, p.customdata, additive);
|
|
||||||
} else if (kind === 'snap') {
|
|
||||||
var c = p.customdata;
|
|
||||||
bridge.onSnapClicked(c[0], c[1], c[2]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gd.on('plotly_hover', function (ev) {
|
|
||||||
if (!snapEnabled) return;
|
|
||||||
var pts = ev.points || [];
|
|
||||||
if (!pts.length) return;
|
|
||||||
var p = pts[0];
|
|
||||||
if (kindOf(p) !== 'snap') { setHoverMarker(null); return; }
|
|
||||||
var c = p.customdata;
|
|
||||||
if (c) setHoverMarker([c[0], c[1], c[2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
gd.on('plotly_unhover', function () {
|
|
||||||
// Nothing can be visible unless snapping is armed, so stay out of the
|
|
||||||
// redraw path entirely for ordinary mouse movement.
|
|
||||||
if (!snapEnabled) return;
|
|
||||||
setHoverMarker(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.otkoSetCamera = function (cameraJson) {
|
|
||||||
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
|
|
||||||
};
|
|
||||||
|
|
||||||
window.otkoSetSnapEnabled = function (on) {
|
|
||||||
snapEnabled = !!on;
|
|
||||||
if (!snapEnabled) setHoverMarker(null);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_url() -> str:
|
|
||||||
"""Ensure the JS runtime is on disk and return the page's file path.
|
|
||||||
|
|
||||||
Called once per :class:`~otko.views.canvas_plotly.PlotlyCanvas`; the
|
|
||||||
directory is reused and removed at interpreter exit.
|
|
||||||
"""
|
|
||||||
global _ASSETS_DIR
|
|
||||||
directory = _ensure_dir()
|
|
||||||
index = directory / "index.html"
|
|
||||||
if not index.exists():
|
|
||||||
(directory / "index.html").write_text(_PAGE, encoding="utf-8")
|
|
||||||
(directory / "plotly.min.js").write_text(_plotly_js(), encoding="utf-8")
|
|
||||||
return str(index)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dir() -> Path:
|
|
||||||
global _ASSETS_DIR
|
|
||||||
if _ASSETS_DIR is None:
|
|
||||||
_ASSETS_DIR = Path(tempfile.mkdtemp(prefix="otko-plotly-"))
|
|
||||||
atexit.register(shutil.rmtree, _ASSETS_DIR, True)
|
|
||||||
return _ASSETS_DIR
|
|
||||||
|
|
||||||
|
|
||||||
def _plotly_js() -> str:
|
|
||||||
"""The offline plotly.js bundle shipped inside the ``plotly`` package."""
|
|
||||||
from plotly.offline import get_plotlyjs
|
|
||||||
|
|
||||||
return get_plotlyjs()
|
|
||||||
|
|
@ -1,417 +0,0 @@
|
||||||
"""Plotly-backed 3D canvas.
|
|
||||||
|
|
||||||
A ``QWebEngineView`` hosting plotly.js, driven over ``QWebChannel``. It
|
|
||||||
implements the same public surface as :class:`otko.views.canvas3d.ModelCanvas`
|
|
||||||
(signals, selection, working plane, view presets, display toggles) so
|
|
||||||
``MainWindow`` can swap the two at runtime.
|
|
||||||
|
|
||||||
Update strategy: figures are pushed with ``Plotly.react``. Because react
|
|
||||||
resets any scene attribute the incoming layout omits, a non-framing push is
|
|
||||||
flagged ``preserveView`` and the JS side carries the live camera and axis
|
|
||||||
ranges forward — so re-rendering on a model edit or selection change never
|
|
||||||
yanks the camera the user is orbiting. Only an explicit re-frame (new
|
|
||||||
project, view preset, ``reset_camera``, style change) sends the computed
|
|
||||||
camera plus opstool-style padded axis ranges.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import replace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QUrl, Signal
|
|
||||||
from PySide6.QtWebChannel import QWebChannel
|
|
||||||
from PySide6.QtWebEngineCore import QWebEnginePage
|
|
||||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
||||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
from otko.views.canvas3d.model_renderer import RendererMode
|
|
||||||
from otko.views.canvas3d.selection import SelectionState
|
|
||||||
from otko.views.canvas3d.style import RenderStyle
|
|
||||||
from otko.views.canvas_base import CanvasCapabilities
|
|
||||||
from otko.views.canvas_plotly import html as _html
|
|
||||||
from otko.views.canvas_plotly.bridge import _Bridge
|
|
||||||
from otko.views.canvas_plotly.trace_builder import (
|
|
||||||
PlotlyTraceBuilder,
|
|
||||||
Scene,
|
|
||||||
SceneOptions,
|
|
||||||
)
|
|
||||||
|
|
||||||
#: View preset directions (unit-ish vectors from the scene centre to the eye).
|
|
||||||
_VIEW_DIRECTIONS = {
|
|
||||||
"iso": (1.0, 1.0, 0.8),
|
|
||||||
"xy": (0.0, 0.0, 1.0),
|
|
||||||
"xz": (0.0, -1.0, 0.0),
|
|
||||||
"yz": (1.0, 0.0, 0.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _LoggingPage(QWebEnginePage):
|
|
||||||
"""A web page that forwards the JS console to the app's console dock.
|
|
||||||
|
|
||||||
A plotly failure inside the page is otherwise invisible from Python (the
|
|
||||||
``react`` promise just rejects), which makes a frozen canvas very hard to
|
|
||||||
diagnose.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
|
||||||
self._log = log
|
|
||||||
|
|
||||||
def javaScriptConsoleMessage(
|
|
||||||
self,
|
|
||||||
level: QWebEnginePage.JavaScriptConsoleMessageLevel,
|
|
||||||
message: str,
|
|
||||||
line_number: int,
|
|
||||||
source_id: str,
|
|
||||||
) -> None:
|
|
||||||
names = {
|
|
||||||
QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error",
|
|
||||||
QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning",
|
|
||||||
}
|
|
||||||
severity = names.get(level, "info")
|
|
||||||
source = source_id.rsplit("/", 1)[-1] or "plotly"
|
|
||||||
self._log(f"[web] {source}:{line_number} {message}", severity)
|
|
||||||
|
|
||||||
|
|
||||||
class _CameraShim:
|
|
||||||
"""Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere."""
|
|
||||||
|
|
||||||
def __init__(self, canvas: PlotlyCanvas) -> None:
|
|
||||||
self._canvas = canvas
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parallel_projection(self) -> bool:
|
|
||||||
return self._canvas._parallel
|
|
||||||
|
|
||||||
@parallel_projection.setter
|
|
||||||
def parallel_projection(self, value: bool) -> None:
|
|
||||||
self._canvas.set_parallel_projection(bool(value))
|
|
||||||
|
|
||||||
|
|
||||||
class _PlotlyRendererFacade:
|
|
||||||
"""Stand-in for ``ModelRenderer`` covering the calls made on ``_renderer``.
|
|
||||||
|
|
||||||
``RenderControls`` and ``DockManager`` reach into ``canvas._renderer`` for
|
|
||||||
``render`` / ``set_mode`` / ``_mode``; this keeps those code paths
|
|
||||||
backend-agnostic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, canvas: PlotlyCanvas) -> None:
|
|
||||||
self._canvas = canvas
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _mode(self) -> RendererMode:
|
|
||||||
return self._canvas._mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _project(self) -> Any:
|
|
||||||
return self._canvas._project
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _working_plane(self) -> tuple[str, float] | None:
|
|
||||||
return self._canvas._working_plane
|
|
||||||
|
|
||||||
def render(self, project: Any) -> None:
|
|
||||||
self._canvas.set_project(project)
|
|
||||||
|
|
||||||
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
|
|
||||||
self._canvas.set_mode(mode, deformation)
|
|
||||||
|
|
||||||
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
|
|
||||||
if plane is None:
|
|
||||||
self._canvas.set_working_plane(None, 0.0)
|
|
||||||
else:
|
|
||||||
self._canvas.set_working_plane(plane[0], plane[1])
|
|
||||||
|
|
||||||
def set_show_section_extrusions(self, on: bool) -> None:
|
|
||||||
self._canvas.set_show_section_extrusions(on)
|
|
||||||
|
|
||||||
def set_show_local_axes(self, on: bool) -> None:
|
|
||||||
self._canvas.set_show_local_axes(on)
|
|
||||||
|
|
||||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
|
||||||
self._canvas.set_display_options(
|
|
||||||
show_node_labels=show_node_labels,
|
|
||||||
show_element_labels=show_element_labels,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlotlyCanvas(QWidget):
|
|
||||||
"""The central 3D viewport, rendered by plotly.js in a web view."""
|
|
||||||
|
|
||||||
nodePicked = Signal(int)
|
|
||||||
elementPicked = Signal(int)
|
|
||||||
emptyClicked = Signal(float, float, float)
|
|
||||||
|
|
||||||
#: Force diagrams and off-screen video capture are not implemented on
|
|
||||||
#: this backend yet (both are PyVista-specific today).
|
|
||||||
capabilities = CanvasCapabilities(diagrams=False, animation_export=False)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent: QWidget | None = None,
|
|
||||||
style: RenderStyle | None = None,
|
|
||||||
selection: SelectionState | None = None,
|
|
||||||
log: Callable[[str, str], None] | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(parent)
|
|
||||||
self._log = log
|
|
||||||
self._style = style or RenderStyle()
|
|
||||||
self.selection = selection or SelectionState(self)
|
|
||||||
self._builder = PlotlyTraceBuilder(self._style)
|
|
||||||
self._project: Any = None
|
|
||||||
self._scene = Scene(data=[], layout={})
|
|
||||||
self._options = SceneOptions()
|
|
||||||
self._mode = RendererMode.MODEL
|
|
||||||
self._parallel = False
|
|
||||||
self._view_preset = "iso"
|
|
||||||
self._snap_enabled = False
|
|
||||||
self._default_selection_enabled = True
|
|
||||||
self._working_plane: tuple[str, float] | None = None
|
|
||||||
self._camera = _CameraShim(self)
|
|
||||||
self._framing_dirty = True
|
|
||||||
self._renderer = _PlotlyRendererFacade(self)
|
|
||||||
self._ready = False
|
|
||||||
|
|
||||||
self._build_ui()
|
|
||||||
self.selection.selectionChanged.connect(self._on_selection_changed)
|
|
||||||
|
|
||||||
# ── construction ─────────────────────────────────────────────────
|
|
||||||
def _build_ui(self) -> None:
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self._web = QWebEngineView(self)
|
|
||||||
if self._log is not None:
|
|
||||||
self._web.setPage(_LoggingPage(self._log, self._web))
|
|
||||||
self._channel = QWebChannel(self._web.page())
|
|
||||||
self._bridge = _Bridge(self)
|
|
||||||
self._channel.registerObject("otkoBridge", self._bridge)
|
|
||||||
self._web.page().setWebChannel(self._channel)
|
|
||||||
self._web.loadFinished.connect(self._on_loaded)
|
|
||||||
self._bridge.picked.connect(self._on_picked)
|
|
||||||
self._bridge.snapClicked.connect(self._on_snap_clicked)
|
|
||||||
layout.addWidget(self._web)
|
|
||||||
self._web.load(QUrl.fromLocalFile(_html.runtime_url()))
|
|
||||||
|
|
||||||
def _on_loaded(self, ok: bool) -> None:
|
|
||||||
if not ok:
|
|
||||||
return
|
|
||||||
self._ready = True
|
|
||||||
self._push_scene()
|
|
||||||
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
|
|
||||||
|
|
||||||
# ── public API (mirrors ModelCanvas) ─────────────────────────────
|
|
||||||
def show_project(self, project: Any) -> None:
|
|
||||||
"""Render (or clear) a project; frame the camera when nodes exist."""
|
|
||||||
self.set_project(project)
|
|
||||||
if project is not None and project.nodes:
|
|
||||||
self._view_preset = "iso"
|
|
||||||
self._framing_dirty = True
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def clear_model(self) -> None:
|
|
||||||
"""Remove the model but keep the view/selection machinery."""
|
|
||||||
self.selection.clear()
|
|
||||||
self.set_project(None)
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
|
||||||
"""Rebuild the figure and hand it to plotly.js.
|
|
||||||
|
|
||||||
``QWidget.render`` is overloaded for painting into a target; those
|
|
||||||
calls are forwarded untouched so the widget stays well-behaved, and
|
|
||||||
only the no-argument canvas idiom (shared with ``ModelCanvas``)
|
|
||||||
triggers a figure push.
|
|
||||||
"""
|
|
||||||
if args or kwargs:
|
|
||||||
super().render(*args, **kwargs)
|
|
||||||
return
|
|
||||||
self._push_scene()
|
|
||||||
|
|
||||||
def set_project(self, project: Any) -> None:
|
|
||||||
"""Bind a project without rendering (used by the ``_renderer`` facade)."""
|
|
||||||
self._project = project
|
|
||||||
|
|
||||||
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
|
|
||||||
"""Set MODEL / DEFORMED / MODAL plus the displacement source to apply."""
|
|
||||||
self._mode = mode
|
|
||||||
self._options = replace(self._options, deformation=deformation)
|
|
||||||
|
|
||||||
def set_parallel_projection(self, on: bool) -> None:
|
|
||||||
self._parallel = bool(on)
|
|
||||||
self._framing_dirty = True
|
|
||||||
|
|
||||||
def reset_camera(self) -> None:
|
|
||||||
self._view_preset = "iso"
|
|
||||||
self._framing_dirty = True
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def view_isometric(self) -> None:
|
|
||||||
self._view_preset = "iso"
|
|
||||||
self._framing_dirty = True
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def view_xy(self) -> None:
|
|
||||||
"""Top view: the eye sits on +Z looking down."""
|
|
||||||
self._view_preset = "xy"
|
|
||||||
self._framing_dirty = True
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def view_xz(self) -> None:
|
|
||||||
"""Front view: the eye sits on -Y."""
|
|
||||||
self._view_preset = "xz"
|
|
||||||
self._framing_dirty = True
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def view_yz(self) -> None:
|
|
||||||
"""Right view: the eye sits on +X."""
|
|
||||||
self._view_preset = "yz"
|
|
||||||
self._framing_dirty = True
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
# ── working plane ────────────────────────────────────────────────
|
|
||||||
def set_working_plane(self, plane: str | None, offset: float) -> None:
|
|
||||||
"""Filter the grid overlay to the active plan / elevation level."""
|
|
||||||
if plane is None:
|
|
||||||
self._working_plane = None
|
|
||||||
else:
|
|
||||||
if plane not in ("XY", "XZ", "YZ"):
|
|
||||||
raise ValueError(f"Unsupported working plane: {plane!r}")
|
|
||||||
self._working_plane = (plane, float(offset))
|
|
||||||
self._options = replace(self._options, working_plane=self._working_plane)
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def clear_working_plane(self) -> None:
|
|
||||||
self.set_working_plane(None, 0.0)
|
|
||||||
|
|
||||||
def working_plane_type(self) -> str | None:
|
|
||||||
return self._working_plane[0] if self._working_plane is not None else None
|
|
||||||
|
|
||||||
def working_plane_offset(self) -> float | None:
|
|
||||||
return self._working_plane[1] if self._working_plane is not None else None
|
|
||||||
|
|
||||||
# ── display toggles ──────────────────────────────────────────────
|
|
||||||
def set_snap_preview_enabled(self, enabled: bool) -> None:
|
|
||||||
"""Toggle the hover snap-target preview (draw tools turn it on)."""
|
|
||||||
self._snap_enabled = bool(enabled)
|
|
||||||
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
|
|
||||||
|
|
||||||
def set_show_section_extrusions(self, enabled: bool) -> None:
|
|
||||||
self._options = replace(self._options, show_extrusions=bool(enabled))
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def set_show_local_axes(self, enabled: bool) -> None:
|
|
||||||
self._options = replace(self._options, show_local_axes=bool(enabled))
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def set_style(self, style: RenderStyle) -> None:
|
|
||||||
"""Swap the visual style and repaint the figure.
|
|
||||||
|
|
||||||
Deliberately *not* a re-frame: colour/opacity are trace props and the
|
|
||||||
background/outline ride the layout, so a style change (including the
|
|
||||||
live preview while dragging a colour) updates without snapping the
|
|
||||||
camera the user has moved.
|
|
||||||
"""
|
|
||||||
self._style = style
|
|
||||||
self._builder.set_style(style)
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
|
||||||
self._options = replace(
|
|
||||||
self._options,
|
|
||||||
show_node_labels=bool(show_node_labels),
|
|
||||||
show_element_labels=bool(show_element_labels),
|
|
||||||
)
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def set_default_selection_enabled(self, enabled: bool) -> None:
|
|
||||||
"""When False, picks fire signals but do not touch :attr:`selection`."""
|
|
||||||
self._default_selection_enabled = bool(enabled)
|
|
||||||
|
|
||||||
# ── internals ───────────────────────────────────────────────────
|
|
||||||
def _push_scene(self) -> None:
|
|
||||||
"""Send the figure to plotly.js.
|
|
||||||
|
|
||||||
``Plotly.react`` resets any scene attribute the incoming layout omits,
|
|
||||||
which used to snap the camera *and* the padded ranges back on every
|
|
||||||
selection change. So a non-framing push marks itself
|
|
||||||
``preserveView`` and the JS side carries the live camera/ranges
|
|
||||||
forward; only an explicit re-frame (new project, view preset,
|
|
||||||
``reset_camera``) sends the computed framing. Style changes are
|
|
||||||
non-framing: they re-colour in place.
|
|
||||||
"""
|
|
||||||
self._scene = self._builder.build(self._project, self._options)
|
|
||||||
if not self._ready:
|
|
||||||
# Nothing to push yet; crucially this must come *before* the
|
|
||||||
# framing flag is consumed, or the framing scheduled before
|
|
||||||
# loadFinished would be dropped on the floor.
|
|
||||||
return
|
|
||||||
layout = dict(self._scene.layout)
|
|
||||||
re_framed = self._framing_dirty
|
|
||||||
if re_framed:
|
|
||||||
scene_layout = dict(layout["scene"])
|
|
||||||
scene_layout["camera"] = self._camera_dict(self._scene)
|
|
||||||
for axis, override in self._scene.axis_overrides().items():
|
|
||||||
scene_layout[axis] = {**scene_layout.get(axis, {}), **override}
|
|
||||||
layout["scene"] = scene_layout
|
|
||||||
self._framing_dirty = False
|
|
||||||
payload = json.dumps({"data": self._scene.data, "layout": layout})
|
|
||||||
self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
|
|
||||||
|
|
||||||
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
|
|
||||||
cx, cy, cz = scene.center
|
|
||||||
distance = max(scene.diagonal, 1e-6) * 1.6
|
|
||||||
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
|
|
||||||
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
|
|
||||||
eye = (
|
|
||||||
cx + direction[0] / norm * distance,
|
|
||||||
cy + direction[1] / norm * distance,
|
|
||||||
cz + direction[2] / norm * distance,
|
|
||||||
)
|
|
||||||
# Looking straight down the Z axis needs a non-degenerate up vector.
|
|
||||||
up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0)
|
|
||||||
return {
|
|
||||||
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
|
|
||||||
"center": {"x": cx, "y": cy, "z": cz},
|
|
||||||
"up": {"x": up[0], "y": up[1], "z": up[2]},
|
|
||||||
"projection": {"type": "orthographic" if self._parallel else "perspective"},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _eval(self, js: str) -> None:
|
|
||||||
if self._ready:
|
|
||||||
self._web.page().runJavaScript(js)
|
|
||||||
|
|
||||||
def _on_selection_changed(self, nodes: frozenset[int], elements: frozenset[int]) -> None:
|
|
||||||
self._options = replace(
|
|
||||||
self._options,
|
|
||||||
selection_nodes=frozenset(nodes),
|
|
||||||
selection_elements=frozenset(elements),
|
|
||||||
)
|
|
||||||
self.render()
|
|
||||||
|
|
||||||
def _on_picked(self, kind: str, entity_id: int, additive: bool) -> None:
|
|
||||||
if kind == "node":
|
|
||||||
if self._default_selection_enabled:
|
|
||||||
if additive:
|
|
||||||
self.selection.toggle_node(entity_id)
|
|
||||||
else:
|
|
||||||
self.selection.select_node(entity_id)
|
|
||||||
self.nodePicked.emit(entity_id)
|
|
||||||
elif kind == "element":
|
|
||||||
if self._default_selection_enabled:
|
|
||||||
if additive:
|
|
||||||
self.selection.toggle_element(entity_id)
|
|
||||||
else:
|
|
||||||
self.selection.select_element(entity_id)
|
|
||||||
self.elementPicked.emit(entity_id)
|
|
||||||
|
|
||||||
def _on_snap_clicked(self, x: float, y: float, z: float) -> None:
|
|
||||||
self.emptyClicked.emit(float(x), float(y), float(z))
|
|
||||||
|
|
||||||
|
|
||||||
def _js_bool(value: bool) -> str:
|
|
||||||
return "true" if value else "false"
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,6 @@ from otko.views.dialogs.move import MoveDialog
|
||||||
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
||||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||||
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
||||||
from otko.views.dialogs.plot_properties import PlotPropertiesDialog
|
|
||||||
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
||||||
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
||||||
from otko.views.dialogs.replicate import ReplicateDialog
|
from otko.views.dialogs.replicate import ReplicateDialog
|
||||||
|
|
@ -70,7 +69,6 @@ __all__ = [
|
||||||
"PathTimeSeriesDialog",
|
"PathTimeSeriesDialog",
|
||||||
"PatternLoadsDialog",
|
"PatternLoadsDialog",
|
||||||
"PlainPatternDialog",
|
"PlainPatternDialog",
|
||||||
"PlotPropertiesDialog",
|
|
||||||
"QuickGridLinesDialog",
|
"QuickGridLinesDialog",
|
||||||
"QuickGuideDialog",
|
"QuickGuideDialog",
|
||||||
"ReplicateDialog",
|
"ReplicateDialog",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ it maps cleanly onto our existing :class:`SetMassCommand`.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
|
@ -105,8 +103,10 @@ class AssignMassesDialog(QDialog):
|
||||||
self._my.setValue(self._mx.value())
|
self._my.setValue(self._mx.value())
|
||||||
self._mx.valueChanged.connect(self._my.setValue)
|
self._mx.valueChanged.connect(self._my.setValue)
|
||||||
else:
|
else:
|
||||||
with contextlib.suppress(RuntimeError, TypeError):
|
try:
|
||||||
self._mx.valueChanged.disconnect(self._my.setValue)
|
self._mx.valueChanged.disconnect(self._my.setValue)
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
def mass_vector(self) -> tuple[float, float, float, float, float, float]:
|
def mass_vector(self) -> tuple[float, float, float, float, float, float]:
|
||||||
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
|
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ model's actual ``ndf`` at translation time.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
|
|
@ -21,6 +22,7 @@ from PySide6.QtWidgets import (
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz)
|
# Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz)
|
||||||
PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = {
|
PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = {
|
||||||
"Free": (False, False, False, False, False, False),
|
"Free": (False, False, False, False, False, False),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
|
|
@ -20,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import (
|
||||||
AddAnalysisCasesCommand,
|
AddAnalysisCasesCommand,
|
||||||
|
|
@ -34,7 +34,7 @@ from otko.core import (
|
||||||
TransientCase,
|
TransientCase,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel
|
||||||
from otko.views.dialogs.case_forms import form_for
|
from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from otko.core import Project
|
from otko.core import Project
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ Top-level controls:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtGui import QColor
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
@ -29,6 +27,7 @@ from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
|
@ -53,6 +52,7 @@ from otko.core.geometry import (
|
||||||
GridSystem,
|
GridSystem,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_LINE_TYPE_CHOICES = ["Primary", "Secondary"]
|
_LINE_TYPE_CHOICES = ["Primary", "Secondary"]
|
||||||
_BUBBLE_LOC_CHOICES = ["Start", "End"]
|
_BUBBLE_LOC_CHOICES = ["Start", "End"]
|
||||||
|
|
||||||
|
|
@ -60,14 +60,7 @@ _BUBBLE_LOC_CHOICES = ["Start", "End"]
|
||||||
class _AxisGridTable(QWidget):
|
class _AxisGridTable(QWidget):
|
||||||
"""A single X/Y/Z spreadsheet — one row per GridLine."""
|
"""A single X/Y/Z spreadsheet — one row per GridLine."""
|
||||||
|
|
||||||
COLUMNS: ClassVar[list[str]] = [
|
COLUMNS = ["Grid ID", "Ordinate", "Line Type", "Visibility", "Bubble Loc", "Color"]
|
||||||
"Grid ID",
|
|
||||||
"Ordinate",
|
|
||||||
"Line Type",
|
|
||||||
"Visibility",
|
|
||||||
"Bubble Loc",
|
|
||||||
"Color",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, axis: str, parent: QWidget | None = None) -> None:
|
def __init__(self, axis: str, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QLabel,
|
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
QLabel,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ committed. Each Add/Edit/Delete is its own undoable step.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
|
@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import (
|
||||||
AddMaterialsCommand,
|
AddMaterialsCommand,
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ class PathTimeSeriesDialog(QDialog):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
dt, npts, vals = parse_peer_record(fname)
|
dt, npts, vals = parse_peer_record(fname)
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001
|
||||||
QMessageBox.critical(self, "PEER import failed", str(exc))
|
QMessageBox.critical(self, "PEER import failed", str(exc))
|
||||||
return
|
return
|
||||||
self._values = vals
|
self._values = vals
|
||||||
|
|
@ -142,7 +142,7 @@ class PathTimeSeriesDialog(QDialog):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
vals = parse_plain_values(fname)
|
vals = parse_plain_values(fname)
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001
|
||||||
QMessageBox.critical(self, "Import failed", str(exc))
|
QMessageBox.critical(self, "Import failed", str(exc))
|
||||||
return
|
return
|
||||||
self._values = vals
|
self._values = vals
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
|
|
||||||
|
|
||||||
The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
|
|
||||||
so adding an editable property is a one-line change there plus a default on
|
|
||||||
the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
|
|
||||||
so the canvas can preview them live; ``style()`` returns the composed result.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from PySide6.QtCore import Signal
|
|
||||||
from PySide6.QtGui import QColor
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QCheckBox,
|
|
||||||
QColorDialog,
|
|
||||||
QDialog,
|
|
||||||
QDialogButtonBox,
|
|
||||||
QDoubleSpinBox,
|
|
||||||
QFormLayout,
|
|
||||||
QLabel,
|
|
||||||
QPushButton,
|
|
||||||
QSpinBox,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
|
|
||||||
|
|
||||||
|
|
||||||
class _ColorButton(QPushButton):
|
|
||||||
"""A swatch that opens :class:`QColorDialog` and reports the hex value."""
|
|
||||||
|
|
||||||
colorChanged = Signal(str)
|
|
||||||
|
|
||||||
def __init__(self, color: str, parent: QWidget | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
|
||||||
self._color = color
|
|
||||||
self.setFixedWidth(96)
|
|
||||||
self._refresh()
|
|
||||||
self.clicked.connect(self._pick)
|
|
||||||
|
|
||||||
def color(self) -> str:
|
|
||||||
"""Current colour as a ``#rrggbb`` string."""
|
|
||||||
return self._color
|
|
||||||
|
|
||||||
def set_color(self, color: str) -> None:
|
|
||||||
"""Set the swatch colour (does not emit :attr:`colorChanged`)."""
|
|
||||||
self._color = color
|
|
||||||
self._refresh()
|
|
||||||
|
|
||||||
def _refresh(self) -> None:
|
|
||||||
self.setText(self._color.upper())
|
|
||||||
self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
|
|
||||||
|
|
||||||
def _text_color(self) -> str:
|
|
||||||
color = QColor(self._color)
|
|
||||||
luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
|
|
||||||
return "#000000" if luminance > 140 else "#ffffff"
|
|
||||||
|
|
||||||
def _pick(self) -> None:
|
|
||||||
chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
|
|
||||||
if chosen.isValid():
|
|
||||||
self.set_color(chosen.name())
|
|
||||||
self.colorChanged.emit(self._color)
|
|
||||||
|
|
||||||
|
|
||||||
class PlotPropertiesDialog(QDialog):
|
|
||||||
"""Modeless-free editor for the persisted plot style."""
|
|
||||||
|
|
||||||
#: Emitted with the composed :class:`RenderStyle` on every edit.
|
|
||||||
changed = Signal(object)
|
|
||||||
|
|
||||||
def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Plot Properties")
|
|
||||||
self._base = style
|
|
||||||
self._colors: dict[str, _ColorButton] = {}
|
|
||||||
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
|
|
||||||
self._checks: dict[str, QCheckBox] = {}
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
form = QFormLayout()
|
|
||||||
for name, label, kind in STYLE_FIELDS:
|
|
||||||
widget = self._build_widget(name, getattr(style, name), kind)
|
|
||||||
form.addRow(QLabel(f"{label}:"), widget)
|
|
||||||
layout.addLayout(form)
|
|
||||||
|
|
||||||
buttons = QDialogButtonBox(
|
|
||||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
||||||
)
|
|
||||||
reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
|
|
||||||
assert reset is not None
|
|
||||||
reset.clicked.connect(self._reset)
|
|
||||||
buttons.accepted.connect(self.accept)
|
|
||||||
buttons.rejected.connect(self.reject)
|
|
||||||
layout.addWidget(buttons)
|
|
||||||
|
|
||||||
# ── public ──────────────────────────────────────────────────────
|
|
||||||
def plot_style(self) -> RenderStyle:
|
|
||||||
"""The composed style for the current widget values.
|
|
||||||
|
|
||||||
Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
|
|
||||||
already exists and must keep its Qt meaning.
|
|
||||||
"""
|
|
||||||
updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
|
|
||||||
updates.update({name: w.value() for name, w in self._numbers.items()})
|
|
||||||
updates.update({name: w.isChecked() for name, w in self._checks.items()})
|
|
||||||
return self._base.with_updates(**updates)
|
|
||||||
|
|
||||||
# ── internals ───────────────────────────────────────────────────
|
|
||||||
def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
|
|
||||||
if kind == "color":
|
|
||||||
button = _ColorButton(str(value))
|
|
||||||
button.colorChanged.connect(self._emit_changed)
|
|
||||||
self._colors[name] = button
|
|
||||||
return button
|
|
||||||
if kind == "float":
|
|
||||||
spin = QDoubleSpinBox()
|
|
||||||
spin.setRange(0.0, 1.0)
|
|
||||||
spin.setSingleStep(0.05)
|
|
||||||
spin.setDecimals(2)
|
|
||||||
spin.setValue(float(value)) # type: ignore[arg-type]
|
|
||||||
spin.valueChanged.connect(self._emit_changed)
|
|
||||||
self._numbers[name] = spin
|
|
||||||
return spin
|
|
||||||
if kind == "bool":
|
|
||||||
check = QCheckBox()
|
|
||||||
check.setChecked(bool(value))
|
|
||||||
check.toggled.connect(self._emit_changed)
|
|
||||||
self._checks[name] = check
|
|
||||||
return check
|
|
||||||
int_spin = QSpinBox()
|
|
||||||
int_spin.setRange(6, 32)
|
|
||||||
int_spin.setValue(int(value)) # type: ignore[call-overload]
|
|
||||||
int_spin.valueChanged.connect(self._emit_changed)
|
|
||||||
self._numbers[name] = int_spin
|
|
||||||
return int_spin
|
|
||||||
|
|
||||||
def _emit_changed(self, *_args: object) -> None:
|
|
||||||
self.changed.emit(self.plot_style())
|
|
||||||
|
|
||||||
def _reset(self) -> None:
|
|
||||||
defaults = RenderStyle()
|
|
||||||
for name, button in self._colors.items():
|
|
||||||
button.set_color(getattr(defaults, name))
|
|
||||||
for name, spin in self._numbers.items():
|
|
||||||
spin.setValue(getattr(defaults, name))
|
|
||||||
for name, check in self._checks.items():
|
|
||||||
check.setChecked(getattr(defaults, name))
|
|
||||||
self._emit_changed()
|
|
||||||
|
|
@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ with <b>Define → Add Plain Load Pattern…</b> or
|
||||||
<b>Define → Add Uniform Excitation…</b> for seismic input.</p>
|
<b>Define → Add Uniform Excitation…</b> for seismic input.</p>
|
||||||
|
|
||||||
<h4>4 — Draw the model</h4>
|
<h4>4 — Draw the model</h4>
|
||||||
<p>Tools toolbar (top): <b>Select</b>, <b>Draw Node</b> (F1),
|
<p>Tools toolbar (left): <b>Select</b>, <b>Draw Node</b> (F1),
|
||||||
<b>Draw Frame</b> (F2), <b>Draw Truss</b> (F3, icon-less by design).
|
<b>Draw Frame</b> (F2), <b>Draw Truss</b> (F3, icon-less by design).
|
||||||
<b>Define → Coordinate System/Grids…</b> (Ctrl+G) and <b>Add Node…</b> (Ctrl+N)
|
<b>Define → Coordinate System/Grids…</b> (Ctrl+G) and <b>Add Node…</b> (Ctrl+N)
|
||||||
for precise layout; use the top-toolbar <b>Level</b> combo in Top/Front/Right views.</p>
|
for precise layout; use the top-toolbar <b>Level</b> combo in Top/Front/Right views.</p>
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ class RunAnalysisDialog(QDialog):
|
||||||
results_dir = self._vm.path.parent / f"{self._vm.path.stem}_results"
|
results_dir = self._vm.path.parent / f"{self._vm.path.stem}_results"
|
||||||
try:
|
try:
|
||||||
self._runner.run(self._vm.project, case, results_dir=results_dir)
|
self._runner.run(self._vm.project, case, results_dir=results_dir)
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001
|
||||||
self._log.appendPlainText(f"Could not start: {exc}")
|
self._log.appendPlainText(f"Could not start: {exc}")
|
||||||
|
|
||||||
def _on_evaluate_combo(self) -> None:
|
def _on_evaluate_combo(self) -> None:
|
||||||
|
|
@ -233,7 +233,7 @@ class RunAnalysisDialog(QDialog):
|
||||||
self._results = combined
|
self._results = combined
|
||||||
self._log.appendPlainText(f"--- Done. Returned {type(combined).__name__}. ---")
|
self._log.appendPlainText(f"--- Done. Returned {type(combined).__name__}. ---")
|
||||||
self._runner.finished.emit(combined)
|
self._runner.finished.emit(combined)
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
self._log.appendPlainText("--- FAILED ---")
|
self._log.appendPlainText("--- FAILED ---")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
@ -323,6 +324,7 @@ class FiberSectionEditor(QDialog):
|
||||||
self._props_label.setText("Add patches or layers to see the preview.")
|
self._props_label.setText("Add patches or layers to see the preview.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
f = props.fibre_yz
|
||||||
# Color by material: assign a palette index per unique material_id.
|
# Color by material: assign a palette index per unique material_id.
|
||||||
mat_ids = []
|
mat_ids = []
|
||||||
for p in self._patches:
|
for p in self._patches:
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,8 @@ from PySide6.QtWidgets import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from otko.core import (
|
from otko.core import (
|
||||||
AngleShape,
|
|
||||||
ElasticSection,
|
ElasticSection,
|
||||||
FiberSection,
|
FiberSection,
|
||||||
PipeShape,
|
|
||||||
RectShape,
|
|
||||||
SectionAggregator,
|
SectionAggregator,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -97,13 +94,14 @@ class SectionFormBase(QWidget):
|
||||||
class ElasticSectionForm(SectionFormBase):
|
class ElasticSectionForm(SectionFormBase):
|
||||||
type_label = "Elastic Section"
|
type_label = "Elastic Section"
|
||||||
|
|
||||||
#: Shape kind → visible display-only dimensions (see the core
|
#: Shape → visible display-only dimensions. ``None`` hides them all.
|
||||||
#: ``SectionShape`` union). ``None`` hides them all.
|
|
||||||
_SHAPE_DIMS: ClassVar[dict[str | None, tuple[str, ...]]] = {
|
_SHAPE_DIMS: ClassVar[dict[str | None, tuple[str, ...]]] = {
|
||||||
None: (),
|
None: (),
|
||||||
"rect": ("b", "d"),
|
"rect": ("b", "h"),
|
||||||
"angle": ("b", "d", "t"),
|
"I": ("b", "d", "tw", "tf"),
|
||||||
"pipe": ("od", "t"),
|
"H": ("b", "d", "tw", "tf"),
|
||||||
|
"pipe": ("diameter",),
|
||||||
|
"circle": ("diameter",),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
|
@ -129,14 +127,14 @@ class ElasticSectionForm(SectionFormBase):
|
||||||
self._layout.addRow(QLabel("<i>Extruded-view shape (display only).</i>"))
|
self._layout.addRow(QLabel("<i>Extruded-view shape (display only).</i>"))
|
||||||
self._shape = QComboBox()
|
self._shape = QComboBox()
|
||||||
self._shape.addItem("(none)", None)
|
self._shape.addItem("(none)", None)
|
||||||
for key in ("rect", "angle", "pipe"):
|
for key in ("rect", "I", "H", "pipe", "circle"):
|
||||||
self._shape.addItem(key, key)
|
self._shape.addItem(key, key)
|
||||||
self._shape.currentIndexChanged.connect(self._update_dim_visibility)
|
self._shape.currentIndexChanged.connect(self._update_dim_visibility)
|
||||||
self._layout.addRow("Shape:", self._shape)
|
self._layout.addRow("Shape:", self._shape)
|
||||||
|
|
||||||
self._dim_spins: dict[str, QDoubleSpinBox] = {}
|
self._dim_spins: dict[str, QDoubleSpinBox] = {}
|
||||||
self._dim_labels: dict[str, QLabel] = {}
|
self._dim_labels: dict[str, QLabel] = {}
|
||||||
for key in ("b", "d", "t", "od"):
|
for key in ("b", "h", "d", "tw", "tf", "diameter"):
|
||||||
spin = _opt_spin()
|
spin = _opt_spin()
|
||||||
lbl = QLabel(f"{key}:")
|
lbl = QLabel(f"{key}:")
|
||||||
self._dim_spins[key] = spin
|
self._dim_spins[key] = spin
|
||||||
|
|
@ -162,15 +160,20 @@ class ElasticSectionForm(SectionFormBase):
|
||||||
self._g.setValue(s.G)
|
self._g.setValue(s.G)
|
||||||
if s.J is not None:
|
if s.J is not None:
|
||||||
self._j.setValue(s.J)
|
self._j.setValue(s.J)
|
||||||
kind = getattr(s.shape, "kind", None) if s.shape is not None else None
|
idx = self._shape.findData(s.shape)
|
||||||
idx = self._shape.findData(kind)
|
|
||||||
self._shape.setCurrentIndex(idx if idx >= 0 else 0)
|
self._shape.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
for key, spin in self._dim_spins.items():
|
for key, spin in self._dim_spins.items():
|
||||||
v = getattr(s.shape, key, None) if s.shape is not None else None
|
v = getattr(s, key, None)
|
||||||
spin.setValue(v if v is not None else _UNSET_SENTINEL)
|
spin.setValue(v if v is not None else _UNSET_SENTINEL)
|
||||||
self._update_dim_visibility()
|
self._update_dim_visibility()
|
||||||
|
|
||||||
def _read_specific(self, sid: int) -> ElasticSection:
|
def _read_specific(self, sid: int) -> ElasticSection:
|
||||||
|
shape = self._shape.currentData()
|
||||||
|
visible = set(self._SHAPE_DIMS.get(shape, ()))
|
||||||
|
dims = {
|
||||||
|
key: (_opt_value(spin) if key in visible else None)
|
||||||
|
for key, spin in self._dim_spins.items()
|
||||||
|
}
|
||||||
return ElasticSection(
|
return ElasticSection(
|
||||||
id=sid,
|
id=sid,
|
||||||
name=self._name_edit.text(),
|
name=self._name_edit.text(),
|
||||||
|
|
@ -180,38 +183,10 @@ class ElasticSectionForm(SectionFormBase):
|
||||||
Iy=self._iy.value(),
|
Iy=self._iy.value(),
|
||||||
G=self._g.value(),
|
G=self._g.value(),
|
||||||
J=self._j.value(),
|
J=self._j.value(),
|
||||||
shape=self._read_shape(),
|
shape=shape,
|
||||||
|
**dims, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _read_shape(self) -> PipeShape | AngleShape | RectShape | None:
|
|
||||||
"""Build the display-only shape hint from the form fields.
|
|
||||||
|
|
||||||
Dimensions are optional in the UI, so an incomplete shape degrades
|
|
||||||
to ``None`` instead of raising — the hint is never emitted to
|
|
||||||
OpenSees and cannot change an analysis result.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def dim(key: str) -> float | None:
|
|
||||||
return _opt_value(self._dim_spins[key])
|
|
||||||
|
|
||||||
kind = self._shape.currentData()
|
|
||||||
if kind == "rect":
|
|
||||||
b, d = dim("b"), dim("d")
|
|
||||||
if b is None or d is None:
|
|
||||||
return None
|
|
||||||
return RectShape(b=b, d=d)
|
|
||||||
if kind == "angle":
|
|
||||||
b, d, t = dim("b"), dim("d"), dim("t")
|
|
||||||
if b is None or d is None or t is None:
|
|
||||||
return None
|
|
||||||
return AngleShape(b=b, d=d, t=t)
|
|
||||||
if kind == "pipe":
|
|
||||||
od, t = dim("od"), dim("t")
|
|
||||||
if od is None or t is None:
|
|
||||||
return None
|
|
||||||
return PipeShape(od=od, t=t)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class FiberSectionSummaryForm(SectionFormBase):
|
class FiberSectionSummaryForm(SectionFormBase):
|
||||||
"""Read-only overview of a :class:`FiberSection`.
|
"""Read-only overview of a :class:`FiberSection`.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
|
|
@ -18,6 +17,7 @@ from PySide6.QtWidgets import (
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import (
|
||||||
AddSectionsCommand,
|
AddSectionsCommand,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
from otko.core import Project, UniformExcitationPattern
|
from otko.core import Project, UniformExcitationPattern
|
||||||
|
|
||||||
|
|
||||||
_DIRECTION_CHOICES: list[tuple[int, str]] = [
|
_DIRECTION_CHOICES: list[tuple[int, str]] = [
|
||||||
(1, "1 — X (horizontal)"),
|
(1, "1 — X (horizontal)"),
|
||||||
(2, "2 — Y (vertical for ndm=2, lateral for ndm=3)"),
|
(2, "2 — Y (vertical for ndm=2, lateral for ndm=3)"),
|
||||||
|
|
|
||||||
|
|
@ -210,14 +210,6 @@ 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(
|
||||||
|
|
@ -268,14 +260,6 @@ 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
|
||||||
|
|
@ -380,14 +364,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ Exposes two signals:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ For 2D it's [N1, Vy1, Mz1, N2, Vy2, Mz2].
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
|
@ -338,6 +337,8 @@ class HysteresisView(QWidget):
|
||||||
|
|
||||||
def _clear_curve(self) -> None:
|
def _clear_curve(self) -> None:
|
||||||
if self._curve is not None:
|
if self._curve is not None:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plot.removeItem(self._curve)
|
self._plot.removeItem(self._curve)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._curve = None
|
self._curve = None
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ dumb: no direct Project mutation, no Qt ↔ OpenSees coupling.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
|
|
@ -25,6 +26,7 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
from otko.core import Project
|
from otko.core import Project
|
||||||
|
|
||||||
|
|
||||||
# Element types the Properties dock lets the user switch between.
|
# Element types the Properties dock lets the user switch between.
|
||||||
# "ElasticBeamColumn" needs a section; "Truss" / "CorotTruss" need a
|
# "ElasticBeamColumn" needs a section; "Truss" / "CorotTruss" need a
|
||||||
# uniaxial material + area. The Convert command drops/adds fields to
|
# uniaxial material + area. The Convert command drops/adds fields to
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from PySide6.QtWidgets import (
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from otko.core import UnitSystem, labels_for
|
from otko.core import UnitLabels, UnitSystem, labels_for
|
||||||
from otko.services.results import PushoverResults
|
from otko.services.results import PushoverResults
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Two panels:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
|
|
@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from otko.core import ResponseSpectrum
|
from otko.core import ResponseSpectrum
|
||||||
from otko.services.results import ResponseSpectrumResults
|
from otko.services.results import ResponseSpectrumResults
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ def _fmt(value: Any) -> str:
|
||||||
return "—"
|
return "—"
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
return f"{value:g}"
|
return f"{value:g}"
|
||||||
if isinstance(value, list | tuple):
|
if isinstance(value, (list, tuple)):
|
||||||
return ", ".join(_fmt(v) for v in value)
|
return ", ".join(_fmt(v) for v in value)
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
@ -1035,7 +1035,7 @@ class TableDock(QWidget):
|
||||||
try:
|
try:
|
||||||
key = int(pid)
|
key = int(pid)
|
||||||
label = name_of.get(key, str(key)) or str(key)
|
label = name_of.get(key, str(key)) or str(key)
|
||||||
parts.append(f"{label}×{float(factor):g}")
|
parts.append(f"{label}×{float(factor):g}") # noqa: RUF001
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
return ", ".join(parts) if parts else "—"
|
return ", ".join(parts) if parts else "—"
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ runner to record additional series (planned for a later phase).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
@ -208,7 +208,9 @@ class TimeHistoryView(QWidget):
|
||||||
|
|
||||||
def _clear_traces(self) -> None:
|
def _clear_traces(self) -> None:
|
||||||
for _, _, item in self._traces:
|
for _, _, item in self._traces:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
self._plot.removeItem(item)
|
self._plot.removeItem(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._traces.clear()
|
self._traces.clear()
|
||||||
self._trace_list.clear()
|
self._trace_list.clear()
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,16 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
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, QStackedWidget
|
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||||
|
|
||||||
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.canvas3d.style import RenderStyle
|
|
||||||
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
|
||||||
|
|
@ -39,9 +34,6 @@ from otko.views.tools import (
|
||||||
ToolController,
|
ToolController,
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Canvas backends, in menu order.
|
|
||||||
CANVAS_BACKENDS = ("pyvista", "plotly")
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(
|
class MainWindow(
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
|
|
@ -52,7 +44,7 @@ class MainWindow(
|
||||||
):
|
):
|
||||||
"""Top-level application shell."""
|
"""Top-level application shell."""
|
||||||
|
|
||||||
def __init__(self, settings: QSettings | None = None) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("OTKO")
|
self.setWindowTitle("OTKO")
|
||||||
self.setWindowIcon(
|
self.setWindowIcon(
|
||||||
|
|
@ -68,22 +60,12 @@ class MainWindow(
|
||||||
self._show_node_labels = False
|
self._show_node_labels = False
|
||||||
self._show_element_labels = False
|
self._show_element_labels = False
|
||||||
|
|
||||||
# One selection state shared by every canvas backend so a live swap
|
self._build_central_canvas()
|
||||||
# keeps the current selection (canvas, tree, table, properties dock).
|
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||||
self._selection = SelectionState(self)
|
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
||||||
|
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
||||||
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
self._draw_node_tool: DrawNodeTool | None = None
|
||||||
self._canvas_backend = self._stored_canvas_backend()
|
self._draw_truss_tool: DrawTrussTool | None = None
|
||||||
#: Shared, user-editable visual style (both backends read it).
|
|
||||||
self._style = self._load_plot_style()
|
|
||||||
self._canvases: dict[str, Any] = {}
|
|
||||||
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
|
||||||
# A stack holds every backend's canvas: swapping is just
|
|
||||||
# setCurrentWidget, so no backend widget is ever destroyed mid-session.
|
|
||||||
self._canvas_stack = QStackedWidget(self)
|
|
||||||
self.setCentralWidget(self._canvas_stack)
|
|
||||||
self._activate_canvas(self._canvas_backend)
|
|
||||||
self._build_tooling()
|
|
||||||
|
|
||||||
self._build_docks()
|
self._build_docks()
|
||||||
self._build_actions()
|
self._build_actions()
|
||||||
|
|
@ -95,164 +77,19 @@ class MainWindow(
|
||||||
self._refresh_action_enablement()
|
self._refresh_action_enablement()
|
||||||
self.restore_layout()
|
self.restore_layout()
|
||||||
|
|
||||||
# ── canvas backend ───────────────────────────────────────────────
|
# ── construction ─────────────────────────────────────────────────
|
||||||
def _stored_canvas_backend(self) -> str:
|
def _build_central_canvas(self) -> None:
|
||||||
"""The backend chosen last session, falling back to PyVista."""
|
self._canvas = ModelCanvas(self)
|
||||||
stored = self._settings.value("canvas/backend", "pyvista")
|
self.setCentralWidget(self._canvas)
|
||||||
name = str(stored) if stored is not None else "pyvista"
|
# Diagram overlay paints onto the same plotter as the model.
|
||||||
if name == "plotly" and plotly_available():
|
self._diagram_renderer = DiagramRenderer(self._canvas)
|
||||||
return "plotly"
|
|
||||||
return "pyvista"
|
|
||||||
|
|
||||||
def _ensure_canvas(self, backend: str) -> Any:
|
|
||||||
"""Create a backend's canvas on first use and cache it.
|
|
||||||
|
|
||||||
Both canvases are kept alive and swapped with ``setCentralWidget``
|
|
||||||
rather than destroyed: tearing a VTK render window down mid-session
|
|
||||||
leaves dangling make-current callbacks, and the widgets are cheap to
|
|
||||||
retain once built.
|
|
||||||
"""
|
|
||||||
canvas = self._canvases.get(backend)
|
|
||||||
if canvas is not None:
|
|
||||||
return canvas
|
|
||||||
if backend == "plotly":
|
|
||||||
# Imported lazily so a plotly-less install still boots PyVista.
|
|
||||||
from otko.views.canvas_plotly import PlotlyCanvas
|
|
||||||
|
|
||||||
canvas = PlotlyCanvas(
|
|
||||||
self,
|
|
||||||
style=self._style,
|
|
||||||
selection=self._selection,
|
|
||||||
log=self._log_canvas_message,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
|
|
||||||
self._canvases[backend] = canvas
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
def _activate_canvas(self, backend: str) -> None:
|
|
||||||
"""Make the backend's canvas the central widget and rewire overlays."""
|
|
||||||
if backend not in CANVAS_BACKENDS:
|
|
||||||
backend = "pyvista"
|
|
||||||
canvas = self._ensure_canvas(backend)
|
|
||||||
if self._canvas_stack.indexOf(canvas) < 0:
|
|
||||||
self._canvas_stack.addWidget(canvas)
|
|
||||||
self._canvas_stack.setCurrentWidget(canvas)
|
|
||||||
self._canvas = canvas
|
|
||||||
self._canvas_backend = backend
|
|
||||||
if backend == "pyvista":
|
|
||||||
renderer = self._diagram_renderers.get("pyvista")
|
|
||||||
if renderer is None:
|
|
||||||
# Diagram overlay paints onto the same plotter as the model.
|
|
||||||
renderer = DiagramRenderer(canvas)
|
|
||||||
self._diagram_renderers["pyvista"] = renderer
|
|
||||||
self._diagram_renderer = renderer
|
|
||||||
else:
|
|
||||||
self._diagram_renderer = None
|
|
||||||
|
|
||||||
def canvas_backend(self) -> str:
|
|
||||||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
|
||||||
return self._canvas_backend
|
|
||||||
|
|
||||||
def _log_canvas_message(self, message: str, severity: str = "info") -> None:
|
|
||||||
"""Route a JS console line from the Plotly page to the Console dock.
|
|
||||||
|
|
||||||
The dock may not exist yet while canvases are being constructed, so
|
|
||||||
the lookup is guarded.
|
|
||||||
"""
|
|
||||||
dock = getattr(self, "_console_dock", None)
|
|
||||||
if dock is not None:
|
|
||||||
dock.log(message, severity=severity)
|
|
||||||
|
|
||||||
# ── plot style ───────────────────────────────────────────────────
|
|
||||||
def _load_plot_style(self) -> RenderStyle:
|
|
||||||
"""Style from the last session, ignoring anything unrecognised."""
|
|
||||||
raw = self._settings.value("plot/props")
|
|
||||||
if not raw:
|
|
||||||
return RenderStyle()
|
|
||||||
try:
|
|
||||||
data = json.loads(str(raw))
|
|
||||||
except ValueError:
|
|
||||||
return RenderStyle()
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return RenderStyle()
|
|
||||||
allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
|
|
||||||
try:
|
|
||||||
return RenderStyle(**allowed)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return RenderStyle()
|
|
||||||
|
|
||||||
def _save_plot_style(self) -> None:
|
|
||||||
self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
|
|
||||||
|
|
||||||
def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
|
|
||||||
"""Apply a style to every canvas (live preview when ``persist`` is off)."""
|
|
||||||
self._style = style
|
|
||||||
for canvas in self._canvases.values():
|
|
||||||
canvas.set_style(style)
|
|
||||||
if persist:
|
|
||||||
self._save_plot_style()
|
|
||||||
|
|
||||||
def _build_tooling(self) -> None:
|
|
||||||
"""(Re)build the tool controller and tools against the current canvas."""
|
|
||||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
|
||||||
self._select_tool = SelectTool(self._canvas, self._vm, self)
|
|
||||||
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
|
|
||||||
self._draw_node_tool: DrawNodeTool | None = None
|
|
||||||
self._draw_truss_tool: DrawTrussTool | None = None
|
|
||||||
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
|
||||||
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
|
|
||||||
|
|
||||||
def swap_canvas_backend(self, backend: str) -> None:
|
|
||||||
"""Switch the live canvas backend, preserving model/selection state.
|
|
||||||
|
|
||||||
The camera pose is intentionally re-framed rather than transferred:
|
|
||||||
the two engines have different camera models, and a wrong pose is
|
|
||||||
worse than a clean isometric reset.
|
|
||||||
"""
|
|
||||||
if backend not in CANVAS_BACKENDS or backend == self._canvas_backend:
|
|
||||||
return
|
|
||||||
if backend == "plotly" and not plotly_available():
|
|
||||||
return
|
|
||||||
|
|
||||||
project = self._vm.project
|
|
||||||
plane = self._canvas.working_plane_type()
|
|
||||||
offset = self._canvas.working_plane_offset()
|
|
||||||
active_name = getattr(self._tool_controller.active, "name", None)
|
|
||||||
|
|
||||||
self._activate_canvas(backend)
|
|
||||||
self._build_tooling()
|
|
||||||
|
|
||||||
# Project → re-frames the camera; working plane rebuilds the grid.
|
|
||||||
self._canvas.show_project(project)
|
|
||||||
if plane is not None and offset is not None:
|
|
||||||
self._canvas.set_working_plane(plane, offset)
|
|
||||||
# Re-apply the overlays whose state lives on the toolbar actions.
|
|
||||||
self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked())
|
|
||||||
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
|
|
||||||
self._canvas.set_display_options(
|
|
||||||
show_node_labels=self._show_node_labels,
|
|
||||||
show_element_labels=self._show_element_labels,
|
|
||||||
)
|
|
||||||
# Keep the active tool (and its snap preview) alive across the swap.
|
|
||||||
# Re-entering through the action handlers recreates the tool lazily
|
|
||||||
# against the new canvas.
|
|
||||||
if active_name == "Draw Node":
|
|
||||||
self._on_draw_node_tool()
|
|
||||||
elif active_name == "Draw Frame":
|
|
||||||
self._on_draw_frame_tool()
|
|
||||||
elif active_name == "Draw Truss":
|
|
||||||
self._on_draw_truss_tool()
|
|
||||||
|
|
||||||
self._settings.setValue("canvas/backend", self._canvas_backend)
|
|
||||||
self._sync_canvas_backend_actions()
|
|
||||||
self._refresh_action_enablement()
|
|
||||||
|
|
||||||
# ── layout persistence ───────────────────────────────────────────
|
# ── 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."""
|
||||||
self._settings.setValue("geometry", self.saveGeometry())
|
settings = QSettings("OTKO", "OTKO")
|
||||||
self._settings.setValue("windowState", self.saveState())
|
settings.setValue("geometry", self.saveGeometry())
|
||||||
|
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.
|
||||||
|
|
@ -260,14 +97,15 @@ 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.
|
||||||
"""
|
"""
|
||||||
geometry = self._settings.value("geometry")
|
settings = QSettings("OTKO", "OTKO")
|
||||||
|
geometry = settings.value("geometry")
|
||||||
if geometry is not None:
|
if geometry is not None:
|
||||||
self.restoreGeometry(geometry)
|
self.restoreGeometry(geometry)
|
||||||
state = self._settings.value("windowState")
|
state = settings.value("windowState")
|
||||||
if state is not None:
|
if state is not None:
|
||||||
self.restoreState(state)
|
self.restoreState(state)
|
||||||
|
|
||||||
def closeEvent(self, event: QCloseEvent) -> None: # (Qt override)
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt override)
|
||||||
"""Prompt to save unsaved work, then persist the window layout.
|
"""Prompt to save unsaved work, then persist the window layout.
|
||||||
|
|
||||||
The prompt is shown only for a visible window: Qt also closes hidden
|
The prompt is shown only for a visible window: Qt also closes hidden
|
||||||
|
|
@ -309,7 +147,6 @@ class MainWindow(
|
||||||
self._act_about.triggered.connect(self._on_about)
|
self._act_about.triggered.connect(self._on_about)
|
||||||
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
||||||
self._act_set_units.triggered.connect(self._on_set_units)
|
self._act_set_units.triggered.connect(self._on_set_units)
|
||||||
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
|
||||||
|
|
||||||
# Edit
|
# Edit
|
||||||
self._act_delete.triggered.connect(self._on_delete)
|
self._act_delete.triggered.connect(self._on_delete)
|
||||||
|
|
@ -324,12 +161,9 @@ 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)
|
||||||
# toolChanged → toolbar/menu sync is wired in _build_tooling(), which
|
self._tool_controller.toolChanged.connect(self._on_tool_changed)
|
||||||
# also runs on a canvas-backend swap (then a fresh controller exists).
|
# Keep the Tools toolbar checked state in sync with the controller.
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -192,46 +192,9 @@ class MenuBuilder:
|
||||||
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
||||||
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)
|
||||||
self._act_plot_properties = QAction("&Plot Properties…", self)
|
|
||||||
self._act_plot_properties.setToolTip(
|
|
||||||
"Colours and sizes used by both canvas backends (persisted)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
|
||||||
from otko.views.canvas_base import plotly_available
|
|
||||||
|
|
||||||
self._canvas_backend_group = QActionGroup(self)
|
|
||||||
self._canvas_backend_group.setExclusive(True)
|
|
||||||
self._act_canvas_pyvista = QAction("&PyVista (Native)", self, checkable=True)
|
|
||||||
self._act_canvas_pyvista.setToolTip(
|
|
||||||
"Native VTK/OpenGL viewport — fastest for large models."
|
|
||||||
)
|
|
||||||
self._act_canvas_plotly = QAction("&Plotly (WebGL)", self, checkable=True)
|
|
||||||
self._act_canvas_plotly.setToolTip(
|
|
||||||
"plotly.js rendered in a WebEngine view — richer graphics, higher overhead."
|
|
||||||
)
|
|
||||||
self._canvas_backend_group.addAction(self._act_canvas_pyvista)
|
|
||||||
self._canvas_backend_group.addAction(self._act_canvas_plotly)
|
|
||||||
if not plotly_available():
|
|
||||||
self._act_canvas_plotly.setEnabled(False)
|
|
||||||
self._act_canvas_plotly.setToolTip(
|
|
||||||
'Install the optional GUI extra (pip install -e ".[gui]") to enable plotly.'
|
|
||||||
)
|
|
||||||
|
|
||||||
self._apply_toolbar_icons()
|
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.
|
||||||
|
|
||||||
|
|
@ -516,11 +479,6 @@ 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_options.addAction(self._act_plot_properties)
|
|
||||||
m_backend = m_options.addMenu("Canvas &Backend")
|
|
||||||
m_backend.addAction(self._act_canvas_pyvista)
|
|
||||||
m_backend.addAction(self._act_canvas_plotly)
|
|
||||||
self._sync_canvas_backend_actions()
|
|
||||||
|
|
||||||
m_help = mb.addMenu("&Help")
|
m_help = mb.addMenu("&Help")
|
||||||
m_help.addAction(self._act_quick_guide)
|
m_help.addAction(self._act_quick_guide)
|
||||||
|
|
@ -531,7 +489,7 @@ class MenuBuilder:
|
||||||
tb.setMovable(True)
|
tb.setMovable(True)
|
||||||
tb.setIconSize(QSize(24, 24))
|
tb.setIconSize(QSize(24, 24))
|
||||||
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
||||||
# Top edge; Tools shares the top area (built next).
|
# Top edge; Tools stays in the left area independently (built next).
|
||||||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
|
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
|
||||||
self._view_toolbar = tb
|
self._view_toolbar = tb
|
||||||
tb.addAction(self._act_zoom_extents)
|
tb.addAction(self._act_zoom_extents)
|
||||||
|
|
@ -577,8 +535,9 @@ class MenuBuilder:
|
||||||
tb.setMovable(True)
|
tb.setMovable(True)
|
||||||
tb.setIconSize(QSize(24, 24))
|
tb.setIconSize(QSize(24, 24))
|
||||||
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
||||||
# Top edge alongside the View toolbar (built just before).
|
# View lives on the top edge, so Tools stays left independently
|
||||||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
|
# in its own left-area column.
|
||||||
|
self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, tb)
|
||||||
self._tools_toolbar = tb
|
self._tools_toolbar = tb
|
||||||
tb.addAction(self._act_tool_select)
|
tb.addAction(self._act_tool_select)
|
||||||
tb.addAction(self._act_tool_draw_node)
|
tb.addAction(self._act_tool_draw_node)
|
||||||
|
|
|
||||||
|
|
@ -50,23 +50,6 @@ class RenderControls:
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── SAP2000-style view switching + working level ───────────────
|
# ── SAP2000-style view switching + working level ───────────────
|
||||||
def _on_plot_properties(self) -> None:
|
|
||||||
"""Edit the shared visual style.
|
|
||||||
|
|
||||||
Edits preview live on both canvases; Cancel restores the style the
|
|
||||||
dialog opened with, and only OK persists to ``QSettings``.
|
|
||||||
"""
|
|
||||||
from otko.views.dialogs import PlotPropertiesDialog
|
|
||||||
|
|
||||||
original = self._style
|
|
||||||
dialog = PlotPropertiesDialog(original, self)
|
|
||||||
dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False))
|
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
||||||
self.set_plot_style(dialog.plot_style())
|
|
||||||
self._log("Plot properties updated.")
|
|
||||||
else:
|
|
||||||
self.set_plot_style(original, persist=False)
|
|
||||||
|
|
||||||
def _on_toggle_parallel(self, on: bool) -> None:
|
def _on_toggle_parallel(self, on: bool) -> None:
|
||||||
cam = self._canvas.camera
|
cam = self._canvas.camera
|
||||||
cam.parallel_projection = on
|
cam.parallel_projection = on
|
||||||
|
|
@ -476,7 +459,6 @@ 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)
|
||||||
|
|
@ -500,11 +482,9 @@ 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)
|
||||||
# Force diagrams + video export are backend capabilities, not just
|
self._act_show_force_diagram.setEnabled(has_static)
|
||||||
# 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 and caps.animation_export)
|
self._act_export_th_animation.setEnabled(has_transient)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ class CanvasTool(QObject):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
canvas: ModelCanvas,
|
canvas: "ModelCanvas",
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
parent: QObject | None = None,
|
parent: QObject | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -87,7 +87,7 @@ class ToolController(QObject):
|
||||||
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
|
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, canvas: ModelCanvas, vm: ProjectViewModel, parent: QObject | None = None
|
self, canvas: "ModelCanvas", vm: "ProjectViewModel", parent: QObject | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._canvas = canvas
|
self._canvas = canvas
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ class DrawFrameTool(LinearElementDrawTool):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
canvas: ModelCanvas,
|
canvas: "ModelCanvas",
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
parent: QObject | None = None,
|
parent: "QObject | None" = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(canvas, vm, parent)
|
super().__init__(canvas, vm, parent)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,9 @@ class DrawNodeTool(CanvasTool):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
canvas: ModelCanvas,
|
canvas: "ModelCanvas",
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
parent: QObject | None = None,
|
parent: "QObject | None" = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(canvas, vm, parent)
|
super().__init__(canvas, vm, parent)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,9 @@ class DrawTrussTool(LinearElementDrawTool):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
canvas: ModelCanvas,
|
canvas: "ModelCanvas",
|
||||||
vm: ProjectViewModel,
|
vm: "ProjectViewModel",
|
||||||
parent: QObject | None = None,
|
parent: "QObject | None" = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(canvas, vm, parent)
|
super().__init__(canvas, vm, parent)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -20,27 +19,6 @@ 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."""
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import ( # noqa: E402
|
||||||
AddAnalysisCasesCommand,
|
AddAnalysisCasesCommand,
|
||||||
DeleteAnalysisCasesCommand,
|
DeleteAnalysisCasesCommand,
|
||||||
UpdateAnalysisCaseCommand,
|
UpdateAnalysisCaseCommand,
|
||||||
)
|
)
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
LinearTimeSeries,
|
LinearTimeSeries,
|
||||||
ModalCase,
|
ModalCase,
|
||||||
NodalLoad,
|
NodalLoad,
|
||||||
|
|
@ -20,7 +20,7 @@ from otko.core import (
|
||||||
StaticCase,
|
StaticCase,
|
||||||
TransientCase,
|
TransientCase,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _vm_with_pattern() -> ProjectViewModel:
|
def _vm_with_pattern() -> ProjectViewModel:
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,21 @@ import pytest
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
pytest.importorskip("openseespy.opensees")
|
pytest.importorskip("openseespy.opensees")
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import ( # noqa: E402
|
||||||
AddAnalysisCasesCommand,
|
AddAnalysisCasesCommand,
|
||||||
AddElementsCommand,
|
AddElementsCommand,
|
||||||
AddNodalLoadsCommand,
|
AddNodalLoadsCommand,
|
||||||
AddNodesCommand,
|
AddNodesCommand,
|
||||||
AddSectionsCommand,
|
AddSectionsCommand,
|
||||||
)
|
)
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
ElasticBeamColumn,
|
ElasticBeamColumn,
|
||||||
ElasticSection,
|
ElasticSection,
|
||||||
Node,
|
Node,
|
||||||
StaticCase,
|
StaticCase,
|
||||||
)
|
)
|
||||||
from otko.services.results import StaticResults
|
from otko.services.results import StaticResults # noqa: E402
|
||||||
from otko.viewmodels import AnalysisRunner, ProjectViewModel
|
from otko.viewmodels import AnalysisRunner, ProjectViewModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.commands import AddNodalLoadsCommand
|
from otko.commands import AddNodalLoadsCommand # noqa: E402
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
LinearTimeSeries,
|
LinearTimeSeries,
|
||||||
Node,
|
Node,
|
||||||
PlainLoadPattern,
|
PlainLoadPattern,
|
||||||
|
Project,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
from otko.views.dialogs.assign_load import AssignLoadDialog
|
from otko.views.dialogs.assign_load import AssignLoadDialog # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.views.dialogs.assign_masses import AssignMassesDialog
|
from otko.views.dialogs.assign_masses import AssignMassesDialog # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
ElasticSection,
|
ElasticSection,
|
||||||
Node,
|
Node,
|
||||||
Project,
|
Project,
|
||||||
)
|
)
|
||||||
from otko.views.dialogs.assign_zls import (
|
from otko.views.dialogs.assign_zls import ( # noqa: E402
|
||||||
AssignZeroLengthSectionDialog,
|
AssignZeroLengthSectionDialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -12,7 +12,7 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
CoordinateGridSystem,
|
CoordinateGridSystem,
|
||||||
CoordinateSystem,
|
CoordinateSystem,
|
||||||
GridSystem,
|
GridSystem,
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
NodalLoad,
|
NodalLoad,
|
||||||
PlainLoadPattern,
|
PlainLoadPattern,
|
||||||
StaticCase,
|
StaticCase,
|
||||||
TransientCase,
|
TransientCase,
|
||||||
)
|
)
|
||||||
from otko.views.dialogs.case_forms import TransientCaseForm
|
from otko.views.dialogs.case_forms import TransientCaseForm # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _patterns(): # type: ignore[no-untyped-def]
|
def _patterns(): # type: ignore[no-untyped-def]
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,15 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import ( # noqa: E402
|
||||||
AddElementsCommand,
|
AddElementsCommand,
|
||||||
AddNodalLoadsCommand,
|
AddNodalLoadsCommand,
|
||||||
AddNodesCommand,
|
AddNodesCommand,
|
||||||
|
DeleteElementsCommand,
|
||||||
DeleteNodesCommand,
|
DeleteNodesCommand,
|
||||||
SetRestraintCommand,
|
SetRestraintCommand,
|
||||||
)
|
)
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
LinearTimeSeries,
|
LinearTimeSeries,
|
||||||
NodalLoad,
|
NodalLoad,
|
||||||
Node,
|
Node,
|
||||||
|
|
@ -26,7 +27,7 @@ from otko.core import (
|
||||||
Steel01,
|
Steel01,
|
||||||
TrussElement,
|
TrussElement,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── helpers ────────────────────────────────
|
# ─────────────────────────── helpers ────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,21 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import ( # noqa: E402
|
||||||
AddElementsCommand,
|
AddElementsCommand,
|
||||||
AddMaterialsCommand,
|
AddMaterialsCommand,
|
||||||
AddNodesCommand,
|
AddNodesCommand,
|
||||||
AddSectionsCommand,
|
AddSectionsCommand,
|
||||||
ConvertElementTypeCommand,
|
ConvertElementTypeCommand,
|
||||||
)
|
)
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
ElasticBeamColumn,
|
ElasticBeamColumn,
|
||||||
ElasticSection,
|
ElasticSection,
|
||||||
ElasticUniaxial,
|
ElasticUniaxial,
|
||||||
Node,
|
Node,
|
||||||
TrussElement,
|
TrussElement,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _vm_setup() -> ProjectViewModel:
|
def _vm_setup() -> ProjectViewModel:
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import LinearTimeSeries, Project
|
from otko.core import LinearTimeSeries, Project # noqa: E402
|
||||||
from otko.views.dialogs.linear_time_series import LinearTimeSeriesDialog
|
from otko.views.dialogs.linear_time_series import LinearTimeSeriesDialog # noqa: E402
|
||||||
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
from otko.views.dialogs.plain_pattern import PlainPatternDialog # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.commands import AddNodesCommand
|
from otko.commands import AddNodesCommand # noqa: E402
|
||||||
from otko.core import ElasticBeamColumn, ElasticSection, Node
|
from otko.core import ElasticBeamColumn, ElasticSection, Node # noqa: E402
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
from otko.views.canvas3d import SelectionState
|
from otko.views.canvas3d import SelectionState # noqa: E402
|
||||||
from otko.views.tools.draw_frame import DrawFrameTool
|
from otko.views.tools.draw_frame import DrawFrameTool # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class _CanvasStub:
|
class _CanvasStub:
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import GridSystem, Node
|
from otko.core import GridSystem, Node, Project # noqa: E402
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
from otko.views.canvas3d.selection import SelectionState
|
from otko.views.canvas3d.selection import SelectionState # noqa: E402
|
||||||
from otko.views.tools.draw_node import (
|
from otko.views.tools.draw_node import ( # noqa: E402
|
||||||
DrawNodeTool,
|
DrawNodeTool,
|
||||||
_snap_to_grid,
|
_snap_to_grid,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,18 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
CoordinateGridSystem,
|
CoordinateGridSystem,
|
||||||
|
CoordinateSystem,
|
||||||
ElasticUniaxial,
|
ElasticUniaxial,
|
||||||
GridSystem,
|
GridSystem,
|
||||||
Node,
|
Node,
|
||||||
TrussElement,
|
TrussElement,
|
||||||
make_grid_lines,
|
make_grid_lines,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
from otko.views.canvas3d.selection import SelectionState
|
from otko.views.canvas3d.selection import SelectionState # noqa: E402
|
||||||
from otko.views.tools.draw_truss import DrawTrussTool
|
from otko.views.tools.draw_truss import DrawTrussTool # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class _CanvasStub:
|
class _CanvasStub:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
CoordinateGridSystem,
|
CoordinateGridSystem,
|
||||||
CoordinateSystem,
|
CoordinateSystem,
|
||||||
GridLine,
|
GridLine,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.core import Concrete04
|
from otko.core import Concrete04 # noqa: E402
|
||||||
from otko.views.dialogs.material_forms import (
|
from otko.views.dialogs.material_forms import ( # noqa: E402
|
||||||
FORM_REGISTRY,
|
|
||||||
Concrete04Form,
|
Concrete04Form,
|
||||||
|
FORM_REGISTRY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.views.dialogs.material_forms import (
|
from otko.views.dialogs.material_forms import ( # noqa: E402
|
||||||
Concrete01Form,
|
Concrete01Form,
|
||||||
Concrete02Form,
|
Concrete02Form,
|
||||||
Steel01Form,
|
Steel01Form,
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,17 @@ import pytest
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
from otko.commands import (
|
from otko.commands import ( # noqa: E402
|
||||||
AddLoadPatternCommand,
|
AddLoadPatternCommand,
|
||||||
AddTimeSeriesCommand,
|
AddTimeSeriesCommand,
|
||||||
)
|
)
|
||||||
from otko.core import (
|
from otko.core import ( # noqa: E402
|
||||||
|
Node,
|
||||||
PathTimeSeries,
|
PathTimeSeries,
|
||||||
Project,
|
Project,
|
||||||
UniformExcitationPattern,
|
UniformExcitationPattern,
|
||||||
)
|
)
|
||||||
from otko.viewmodels import ProjectViewModel
|
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# ─────────────── AddTimeSeriesCommand / AddLoadPatternCommand ──────────────
|
# ─────────────── AddTimeSeriesCommand / AddLoadPatternCommand ──────────────
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
"""GUI tests for Options → Plot Properties.
|
|
||||||
|
|
||||||
Covers the dialog itself plus MainWindow's live-apply / persist / revert
|
|
||||||
wiring. QSettings are injected so nothing touches real user state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
|
||||||
pytest.importorskip("pyvistaqt")
|
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QSettings, Signal
|
|
||||||
from PySide6.QtWidgets import QDialog
|
|
||||||
|
|
||||||
|
|
||||||
def _settings(tmp_path): # type: ignore[no-untyped-def]
|
|
||||||
return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
|
||||||
|
|
||||||
|
|
||||||
def _window(tmp_path, qtbot): # type: ignore[no-untyped-def]
|
|
||||||
from otko.views.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(settings=_settings(tmp_path))
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
return window
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
|
||||||
from otko.views.canvas3d.style import RenderStyle
|
|
||||||
|
|
||||||
window = _window(tmp_path, qtbot)
|
|
||||||
assert window._style == RenderStyle()
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
|
||||||
window = _window(tmp_path, qtbot)
|
|
||||||
window.swap_canvas_backend("plotly") # build the second canvas too
|
|
||||||
|
|
||||||
window.set_plot_style(
|
|
||||||
window._style.with_updates(element_beam_color="#ff0000", label_font_size=20)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert set(window._canvases) == {"pyvista", "plotly"}
|
|
||||||
for canvas in window._canvases.values():
|
|
||||||
assert canvas._style.element_beam_color == "#ff0000"
|
|
||||||
assert canvas._style.label_font_size == 20
|
|
||||||
|
|
||||||
# A fresh window sharing the settings file reloads the customisation.
|
|
||||||
from otko.views.main_window import MainWindow
|
|
||||||
|
|
||||||
fresh = MainWindow(settings=_settings(tmp_path))
|
|
||||||
qtbot.addWidget(fresh)
|
|
||||||
assert fresh._style.element_beam_color == "#ff0000"
|
|
||||||
assert fresh._style.label_font_size == 20
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
|
||||||
window = _window(tmp_path, qtbot)
|
|
||||||
window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False)
|
|
||||||
|
|
||||||
assert window._canvas._style.node_color == "#010203"
|
|
||||||
assert window._settings.value("plot/props") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
window = _window(tmp_path, qtbot)
|
|
||||||
original = window._style
|
|
||||||
preview = original.with_updates(node_color="#010203")
|
|
||||||
|
|
||||||
class _RejectingDialog(QObject):
|
|
||||||
changed = Signal(object)
|
|
||||||
|
|
||||||
def __init__(self, style: object, parent: QObject | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
|
||||||
self._style = preview
|
|
||||||
|
|
||||||
def exec(self) -> QDialog.DialogCode:
|
|
||||||
self.changed.emit(preview) # live preview happens...
|
|
||||||
return QDialog.DialogCode.Rejected # ...but the user cancels
|
|
||||||
|
|
||||||
def plot_style(self): # type: ignore[no-untyped-def]
|
|
||||||
return self._style
|
|
||||||
|
|
||||||
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog)
|
|
||||||
window._on_plot_properties()
|
|
||||||
|
|
||||||
assert window._style == original
|
|
||||||
assert window._canvas._style.node_color == original.node_color
|
|
||||||
assert window._settings.value("plot/props") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
window = _window(tmp_path, qtbot)
|
|
||||||
preview = window._style.with_updates(node_color="#010203")
|
|
||||||
|
|
||||||
class _AcceptingDialog(QObject):
|
|
||||||
changed = Signal(object)
|
|
||||||
|
|
||||||
def __init__(self, style: object, parent: QObject | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
|
||||||
self._style = preview
|
|
||||||
|
|
||||||
def exec(self) -> QDialog.DialogCode:
|
|
||||||
return QDialog.DialogCode.Accepted
|
|
||||||
|
|
||||||
def plot_style(self): # type: ignore[no-untyped-def]
|
|
||||||
return self._style
|
|
||||||
|
|
||||||
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog)
|
|
||||||
window._on_plot_properties()
|
|
||||||
|
|
||||||
assert window._style.node_color == "#010203"
|
|
||||||
assert window._canvas._style.node_color == "#010203"
|
|
||||||
assert "#010203" in str(window._settings.value("plot/props"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
|
||||||
from otko.views.canvas3d.style import RenderStyle
|
|
||||||
from otko.views.dialogs import PlotPropertiesDialog
|
|
||||||
|
|
||||||
dialog = PlotPropertiesDialog(RenderStyle())
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
assert dialog.plot_style() == RenderStyle()
|
|
||||||
|
|
||||||
dialog._colors["node_color"].set_color("#abcdef")
|
|
||||||
assert dialog.plot_style().node_color == "#abcdef"
|
|
||||||
|
|
||||||
dialog._reset()
|
|
||||||
assert dialog.plot_style() == RenderStyle()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue