Compare commits

...
156 changed files with 3866 additions and 506 deletions

View file

@ -4,6 +4,7 @@ on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint:
@ -12,8 +13,9 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff
python-version: "3.11"
# Pinned to the pre-commit revisions so CI and local hooks agree.
- run: pip install ruff==0.4.4
- run: ruff check src tests
- run: ruff format --check src tests
@ -23,11 +25,14 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[gui,dev]"
- run: mypy src/otko/core src/otko/services src/otko/viewmodels
python-version: "3.11"
# Pinned to the pre-commit revision (see .pre-commit-config.yaml).
- run: pip install mypy==1.10.0 pydantic numpy
- run: pip install -e .
- run: mypy src/otko/core src/otko/services
test-headless:
needs: [lint, type]
strategy:
fail-fast: false
matrix:
@ -43,19 +48,14 @@ jobs:
- run: pytest tests/unit tests/services -m "not slow"
test-gui:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12"]
runs-on: ${{ matrix.os }}
needs: [lint, type]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: "3.11"
- name: Install Linux Qt deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 \
@ -63,24 +63,19 @@ jobs:
libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \
libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb
- run: pip install -e ".[gui,dev]"
- 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
- run: xvfb-run -a pytest tests/gui -m "not slow"
test-integration:
needs: [lint, type]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[gui,dev]"
- run: pytest tests/integration -m slow
python-version: "3.11"
- run: pip install -e ".[dev]"
- run: pytest tests/integration -m "not slow"

4
.gitignore vendored
View file

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

9
.ignore Normal file
View file

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

15
.slim/clonedeps.json Normal file
View file

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

View file

@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ
## Install
```bash
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
python -m otko # launch GUI (src/otko/app.py:run)
```
@ -19,6 +19,8 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
- `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.**
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`.
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
@ -56,3 +58,17 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git
- `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip).
- Projects persist as single Pydantic-validated JSON `.osmodel` (diffable); analysis output goes to `*.osresults.h5` (HDF5, one group per case).
- Quick smoke: open `examples/cantilever.osmodel` → run `Tip-Load` → M3 peaks 50 kN·m at fixed end.
## Cloned Dependency Source
Read-only dependency source repositories are available under
`.slim/clonedeps/repos/` for inspection. Do not edit these clones. The
structured manifest is `.slim/clonedeps.json`.
- `.slim/clonedeps/repos/yexiang92__opstool/` — `yexiang92/opstool` at `v1.0.26`; the OpenSeesPy
pre/post-processor whose PyVista and Plotly visualization settings
(`opstool/vis/{pyvista,plotly}/plot_utils.py`, `plot_resp_base.py`, `vis_model.py`) are the
reference for otko's canvas look-and-feel. GPL-3.0, and GPLv3 §13 explicitly permits combining
it with otko's AGPL-3.0. Any code actually ported from it must keep the opstool copyright notice
and record that it was modified (GPLv3 §5a/b) — add that entry to `NOTICE` when the port lands.

15
NOTICE
View file

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

View file

@ -42,6 +42,7 @@ gui = [
"vtk>=9.3",
"pyqtgraph>=0.13",
"imageio[ffmpeg]>=2.34",
"plotly>=5.18",
]
dev = [
"pytest>=7.4",
@ -89,7 +90,25 @@ select = [
"SIM", # simplify
"RUF", # ruff-specific
]
ignore = ["E501"] # line length handled by formatter
ignore = [
"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]
quote-style = "double"

View file

@ -29,14 +29,6 @@ from otko.commands.grid import (
SetCoordSystemsCommand,
SetGridSystemCommand,
)
from otko.commands.series_and_patterns import (
AddLoadPatternCommand,
AddTimeSeriesCommand,
DeleteLoadPatternCommand,
DeleteTimeSeriesCommand,
UpdateLoadPatternCommand,
UpdateTimeSeriesCommand,
)
from otko.commands.loads import (
AddElementLoadsCommand,
AddNodalLoadsCommand,
@ -63,6 +55,14 @@ from otko.commands.sections import (
DeleteSectionsCommand,
UpdateSectionCommand,
)
from otko.commands.series_and_patterns import (
AddLoadPatternCommand,
AddTimeSeriesCommand,
DeleteLoadPatternCommand,
DeleteTimeSeriesCommand,
UpdateLoadPatternCommand,
UpdateTimeSeriesCommand,
)
from otko.commands.transforms import (
MirrorCommand,
MoveNodesCommand,

View file

@ -13,9 +13,7 @@ if TYPE_CHECKING:
class AddAnalysisCasesCommand(ProjectCommand):
"""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)")
self._cases = list(cases)
@ -36,7 +34,7 @@ class AddAnalysisCasesCommand(ProjectCommand):
class DeleteAnalysisCasesCommand(ProjectCommand):
"""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)")
self._case_ids = set(case_ids)
self._removed: list[tuple[int, Any]] = []
@ -58,7 +56,7 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
class UpdateAnalysisCaseCommand(ProjectCommand):
"""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}")
self._new = new_case
self._old: Any | None = None

View file

@ -27,16 +27,16 @@ class ProjectCommand(QUndoCommand):
: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)
self._vm = vm
@property
def vm(self) -> "ProjectViewModel":
def vm(self) -> ProjectViewModel:
return self._vm
@property
def project(self) -> "Project":
def project(self) -> Project:
if self._vm.project is None:
raise RuntimeError(f"Cannot apply '{self.text()}': no active project.")
return self._vm.project

View file

@ -13,9 +13,7 @@ if TYPE_CHECKING:
class AddCombinationsCommand(ProjectCommand):
"""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)")
self._combos = list(combos)
@ -36,7 +34,7 @@ class AddCombinationsCommand(ProjectCommand):
class DeleteCombinationsCommand(ProjectCommand):
"""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)")
self._combo_ids = set(combo_ids)
self._removed: list[tuple[int, Any]] = []
@ -60,7 +58,7 @@ class DeleteCombinationsCommand(ProjectCommand):
class UpdateCombinationCommand(ProjectCommand):
"""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}")
self._new = new_combo
self._old: Any | None = None

View file

@ -14,7 +14,7 @@ if TYPE_CHECKING:
class AddEqualDOFConstraintCommand(ProjectCommand):
"""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__(
vm,
f"Add equalDOF {constraint.retained_node}->{constraint.constrained_node}",
@ -48,7 +48,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
"""
def __init__(
self, vm: "ProjectViewModel", index: int, new_constraint: EqualDOFConstraint
self, vm: ProjectViewModel, index: int, new_constraint: EqualDOFConstraint
) -> None:
super().__init__(vm, f"Edit equalDOF row {index + 1}")
self._index = index
@ -71,7 +71,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
class DeleteEqualDOFConstraintCommand(ProjectCommand):
"""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)")
self._indices = set(indices)
self._removed: list[tuple[int, EqualDOFConstraint]] = []

View file

@ -14,7 +14,7 @@ class AddElementsCommand(ProjectCommand):
"""Add one or more elements in a single undoable step."""
def __init__(
self, vm: "ProjectViewModel", elements: list[Any], *, text: str | None = None
self, vm: ProjectViewModel, elements: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(elements)} element(s)")
self._elements = list(elements)
@ -40,7 +40,7 @@ class AddElementsCommand(ProjectCommand):
class DeleteElementsCommand(ProjectCommand):
"""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)")
self._element_ids = set(element_ids)
self._removed: list[tuple[int, Any]] = []
@ -68,7 +68,7 @@ class AssignSectionCommand(ProjectCommand):
(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)")
self._element_ids = set(element_ids)
self._section_id = section_id
@ -106,7 +106,7 @@ class ConvertElementTypeCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
element_ids: set[int],
target_type: str,
defaults: dict[str, Any] | None = None,
@ -185,7 +185,7 @@ class ReplaceElementsCommand(ProjectCommand):
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)")
self._replacements = {el.id: el for el in replacements}
self._previous: dict[int, Any] = {}
@ -217,7 +217,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
element_id: int,
fields: dict[str, Any],
) -> None:
@ -254,7 +254,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
class AssignMaterialCommand(ProjectCommand):
"""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)")
self._element_ids = set(element_ids)
self._material_id = material_id

View file

@ -20,7 +20,7 @@ class SetCoordSystemsCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
new_systems: list[CoordinateGridSystem],
) -> None:
super().__init__(vm, "Update coordinate/grid systems")
@ -46,7 +46,7 @@ class SetGridSystemCommand(ProjectCommand):
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")
self._new_grid = new_grid
self._previous: GridSystem | None = None

View file

@ -35,7 +35,7 @@ class AddNodalLoadsCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_ids: set[int],
forces: tuple[float, float, float, float, float, float],
pattern_id: int | None = None,
@ -122,7 +122,7 @@ class AddElementLoadsCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
element_ids: set[int],
wy: float = 0.0,
wz: float = 0.0,

View file

@ -14,7 +14,7 @@ class AddMaterialsCommand(ProjectCommand):
"""Add one or more materials in a single undoable step."""
def __init__(
self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None
self, vm: ProjectViewModel, materials: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(materials)} material(s)")
self._materials = list(materials)
@ -36,7 +36,7 @@ class AddMaterialsCommand(ProjectCommand):
class DeleteMaterialsCommand(ProjectCommand):
"""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)")
self._material_ids = set(material_ids)
self._removed: list[tuple[int, Any]] = []
@ -64,7 +64,7 @@ class UpdateMaterialCommand(ProjectCommand):
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}")
self._new = new_material
self._old: Any | None = None

View file

@ -14,9 +14,7 @@ if TYPE_CHECKING:
class AddNodesCommand(ProjectCommand):
"""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)")
self._nodes = list(nodes)
@ -41,7 +39,7 @@ class DeleteNodesCommand(ProjectCommand):
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)")
self._node_ids = set(node_ids)
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
@ -95,7 +93,7 @@ class SetRestraintCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_ids: set[int],
restraint: tuple[bool, bool, bool, bool, bool, bool],
) -> None:
@ -130,7 +128,7 @@ class UpdateNodeFieldsCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_id: int,
fields: dict[str, Any],
) -> None:
@ -168,7 +166,7 @@ class SetMassCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_ids: set[int],
mass: tuple[float, float, float, float, float, float],
) -> None:

View file

@ -19,7 +19,7 @@ class SetUnitsCommand(ProjectCommand):
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}")
self._units = units
self._previous: UnitSystem | None = None

View file

@ -14,7 +14,7 @@ class AddSectionsCommand(ProjectCommand):
"""Add one or more sections in a single undoable step."""
def __init__(
self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None
self, vm: ProjectViewModel, sections: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(sections)} section(s)")
self._sections = list(sections)
@ -36,7 +36,7 @@ class AddSectionsCommand(ProjectCommand):
class DeleteSectionsCommand(ProjectCommand):
"""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)")
self._section_ids = set(section_ids)
self._removed: list[tuple[int, Any]] = []
@ -60,7 +60,7 @@ class DeleteSectionsCommand(ProjectCommand):
class UpdateSectionCommand(ProjectCommand):
"""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}")
self._new = new_section
self._old: Any | None = None

View file

@ -19,7 +19,7 @@ if TYPE_CHECKING:
class AddTimeSeriesCommand(ProjectCommand):
"""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}'")
self._ts = ts
@ -41,7 +41,7 @@ class UpdateTimeSeriesCommand(ProjectCommand):
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}")
self._new = new_ts
self._old: Any | None = None
@ -67,7 +67,7 @@ class DeleteTimeSeriesCommand(ProjectCommand):
"""Remove time series (no cascade — patterns referencing them must be
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)")
self._ts_ids = set(ts_ids)
self._removed: list[tuple[int, Any]] = []
@ -91,7 +91,7 @@ class DeleteTimeSeriesCommand(ProjectCommand):
class AddLoadPatternCommand(ProjectCommand):
"""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}'")
self._pattern = pattern
@ -117,7 +117,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
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}")
self._new = new_pattern
self._old: Any | None = None
@ -142,7 +142,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
class DeleteLoadPatternCommand(ProjectCommand):
"""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)")
self._pattern_ids = set(pattern_ids)
self._removed: list[tuple[int, Any]] = []

View file

@ -15,10 +15,9 @@ All copies inherit the source node's restraint, mass, and name.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Literal
from otko.commands.base import ProjectCommand
from otko.core import Node
if TYPE_CHECKING:
from otko.viewmodels import ProjectViewModel
@ -30,7 +29,7 @@ class MoveNodesCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_ids: set[int],
offset: tuple[float, float, float],
) -> None:
@ -63,7 +62,7 @@ class ReplicateCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_ids: set[int],
element_ids: set[int],
offset: tuple[float, float, float],
@ -147,7 +146,7 @@ class MirrorCommand(ProjectCommand):
def __init__(
self,
vm: "ProjectViewModel",
vm: ProjectViewModel,
node_ids: set[int],
element_ids: set[int],
plane: Plane,

View file

@ -9,7 +9,7 @@ in Phase 6/8.
from __future__ import annotations
from typing import Annotated, Literal, Union
from typing import Annotated, Literal
from pydantic import Field, PositiveFloat, PositiveInt, model_validator
@ -269,6 +269,6 @@ class ResponseSpectrumCase(Entity):
AnalysisCase = Annotated[
Union[StaticCase, ModalCase, TransientCase, PushoverCase, ResponseSpectrumCase],
StaticCase | ModalCase | TransientCase | PushoverCase | ResponseSpectrumCase,
Field(discriminator="type"),
]

View file

@ -10,7 +10,7 @@ Conventions follow OpenSeesPy ``element ...`` commands.
from __future__ import annotations
from typing import Annotated, Literal, Union
from typing import Annotated, Literal
from pydantic import Field, PositiveFloat, PositiveInt
@ -198,16 +198,14 @@ class QuadElement(Entity):
Element = Annotated[
Union[
TrussElement,
CorotTrussElement,
ElasticBeamColumn,
ForceBeamColumn,
DispBeamColumn,
ZeroLengthElement,
ZeroLengthSectionElement,
BeamWithHingesElement,
QuadElement,
],
TrussElement
| CorotTrussElement
| ElasticBeamColumn
| ForceBeamColumn
| DispBeamColumn
| ZeroLengthElement
| ZeroLengthSectionElement
| BeamWithHingesElement
| QuadElement,
Field(discriminator="type"),
]

View file

@ -97,7 +97,7 @@ class GridSystem(BaseModel):
return data
@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"):
lines: list[GridLine] = list(getattr(self, name))
lines.sort(key=lambda ln: ln.ordinate)

View file

@ -12,7 +12,7 @@ pattern is deleted, its loads go with it.
from __future__ import annotations
from typing import Annotated, Literal, Union
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
@ -110,7 +110,7 @@ class ResponseSpectrum(Entity):
TimeSeries = Annotated[
Union[LinearTimeSeries, ConstantTimeSeries, PathTimeSeries],
LinearTimeSeries | ConstantTimeSeries | PathTimeSeries,
Field(discriminator="type"),
]
@ -229,7 +229,7 @@ class LoadCombination(Entity):
)
@model_validator(mode="after")
def _check_items(self) -> "LoadCombination":
def _check_items(self) -> LoadCombination:
"""Reject duplicate case ids and non-finite factors."""
import math
@ -250,6 +250,6 @@ class LoadCombination(Entity):
LoadPattern = Annotated[
Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern],
PlainLoadPattern | UniformExcitationPattern | ImposedSupportMotionPattern,
Field(discriminator="type"),
]

View file

@ -11,7 +11,7 @@ https://openseespydoc.readthedocs.io/en/latest/src/uniaxialMaterial.html
from __future__ import annotations
from typing import Annotated, Literal, Union
from typing import Annotated, Literal
from pydantic import Field, PositiveFloat, model_validator
@ -130,7 +130,7 @@ class Concrete04(Entity):
)
@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:
raise ValueError("et is required when fct is given.")
if self.et is not None and self.fct is None:
@ -229,18 +229,16 @@ class HystereticSM(Entity):
# ──────────────────────────── Discriminated union ────────────────────────────
Material = Annotated[
Union[
ElasticIsotropic,
ElasticUniaxial,
Steel01,
Steel02,
Concrete01,
Concrete02,
Concrete04,
ElasticPP,
HystereticMaterial,
HystereticSM,
],
ElasticIsotropic
| ElasticUniaxial
| Steel01
| Steel02
| Concrete01
| Concrete02
| Concrete04
| ElasticPP
| HystereticMaterial
| HystereticSM,
Field(discriminator="type"),
]
"""Tagged union of every material kind. Pydantic uses ``type`` to dispatch on JSON load."""

View file

@ -166,7 +166,7 @@ class Project(BaseModel):
return data
@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."""
has_global = any(cs.name == "Global" for cs in self.coord_systems)
if not has_global:
@ -174,7 +174,7 @@ class Project(BaseModel):
return self
@model_validator(mode="after")
def _ensure_indexed(self) -> "Project":
def _ensure_indexed(self) -> Project:
"""Wrap every collection in an :class:`_IndexedList` and drop caches.
Runs after each validation pass (construction, ``model_validate``,
@ -223,7 +223,7 @@ class Project(BaseModel):
# ─────────────────── invariants ───────────────────
@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)}
if (self.ndm, self.ndf) not in valid:
raise ValueError(
@ -233,7 +233,7 @@ class Project(BaseModel):
return self
@model_validator(mode="after")
def _check_unique_ids(self) -> "Project":
def _check_unique_ids(self) -> Project:
for label, items in (
("node", self.nodes),
("material", self.materials),

View file

@ -16,7 +16,7 @@ exactly so the runner can emit them without further translation.
from __future__ import annotations
from typing import Annotated, Literal, Union
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt
@ -61,7 +61,7 @@ class RectShape(BaseModel):
SectionShape = Annotated[
Union[PipeShape, AngleShape, RectShape],
PipeShape | AngleShape | RectShape,
Field(discriminator="kind"),
]
@ -165,12 +165,12 @@ class StraightLayer(BaseModel):
Patch = Annotated[
Union[RectangularPatch, CircularPatch],
RectangularPatch | CircularPatch,
Field(discriminator="kind"),
]
Layer = Annotated[
Union[StraightLayer],
StraightLayer,
Field(discriminator="kind"),
]
@ -225,6 +225,6 @@ class SectionAggregator(Entity):
Section = Annotated[
Union[ElasticSection, FiberSection, SectionAggregator],
ElasticSection | FiberSection | SectionAggregator,
Field(discriminator="type"),
]

View file

@ -12,8 +12,9 @@ the main thread.
from __future__ import annotations
import math
from collections.abc import Callable
from pathlib import Path
from typing import Any, Callable
from typing import Any
import numpy as np

View file

@ -238,7 +238,7 @@ def _record_calls(project: Project, case_id: int | None) -> list[RecordedCall]:
runner.build()
else:
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
# harvesting code finish; queries are filtered at render.
runner.run(case)

View file

@ -11,6 +11,7 @@ building the PathTimeSeries.
from __future__ import annotations
import contextlib
import re
from pathlib import Path
@ -64,10 +65,9 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
values: list[float] = []
for line in lines[data_start:]:
for tok in line.split():
try:
# Skip stray non-numeric tokens.
with contextlib.suppress(ValueError):
values.append(float(tok))
except ValueError:
pass # skip stray tokens
if not values:
raise ValueError("Header parsed but no numeric data lines found.")
return dt, npts, values
@ -83,10 +83,8 @@ def parse_plain_values(path: str | Path) -> list[float]:
vals: list[float] = []
for line in text.splitlines():
for tok in line.split():
try:
with contextlib.suppress(ValueError):
vals.append(float(tok))
except ValueError:
pass
if not vals:
raise ValueError(f"{path} contains no numeric values.")
return vals

View file

@ -54,7 +54,7 @@ def _angle_in_arc(angle: float, start: float, span: float) -> bool:
def bbox_for_section(
section: object,
project: "Project | None" = None,
project: Project | None = None,
) -> tuple[float, float] | None:
"""Return ``(width_y, height_z)`` of the section's local bounding box.

View file

@ -21,7 +21,6 @@ import numpy as np
from otko.core.sections import (
CircularPatch,
FiberSection,
Fibre,
RectangularPatch,
StraightLayer,
)

View file

@ -11,6 +11,7 @@ a re-paint without losing camera/selection state.
from __future__ import annotations
import contextlib
from pathlib import Path
from PySide6.QtCore import QObject, Signal, Slot
@ -40,10 +41,8 @@ class ProjectViewModel(QObject):
@Slot(bool)
def _on_stack_clean_changed(self, clean: bool) -> None:
# Guard against the late-fire that Qt sends during destruction.
try:
with contextlib.suppress(RuntimeError):
self._set_dirty(not clean)
except RuntimeError:
pass
# ── read ─────────────────────────────────────────────────────────
@property

View file

@ -47,9 +47,9 @@ from otko.views.dialogs import (
AssignSectionDialog,
AssignSupportDialog,
AssignZeroLengthSectionDialog,
LoadCombinationManagerDialog,
CoordinateGridSystemsDialog,
LinearTimeSeriesDialog,
LoadCombinationManagerDialog,
MaterialLibraryDialog,
MaterialTesterDialog,
MirrorDialog,

View file

@ -22,6 +22,7 @@ Convention:
from __future__ import annotations
import contextlib
import logging
from typing import Any
@ -30,6 +31,7 @@ import pyvista as pv
from otko.core import Project
from otko.services.element_forces import DiagramData, ForceComponent
from otko.views.canvas3d.style import RenderStyle
_LOG = logging.getLogger("otko.diagram")
@ -61,8 +63,9 @@ class DiagramRenderer:
- ``clear()`` removes the overlay.
"""
def __init__(self, plotter: Any) -> None:
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
self._actor: Any = None
self._label_actor: Any = None
@ -157,7 +160,9 @@ class DiagramRenderer:
self._actor = self._plotter.add_mesh(
mesh,
scalars="value",
cmap="coolwarm",
# Shared response palette (opstool's default diverging scale), so
# the PyVista and Plotly backends stay visually consistent.
cmap=list(self._style.response_scale_colors),
clim=(-vmax, vmax),
show_scalar_bar=True,
scalar_bar_args={"title": comp_label, "n_labels": 5},
@ -254,10 +259,8 @@ class DiagramRenderer:
pass
self._actor = None
if self._label_actor is not None:
try:
with contextlib.suppress(Exception):
self._plotter.remove_actor(self._label_actor, render=False)
except Exception:
pass
self._label_actor = None
# ── helpers ─────────────────────────────────────────────────────

View file

@ -24,6 +24,7 @@ from otko.core import Project
from otko.views.canvas3d.model_renderer import ModelRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_base import CanvasCapabilities
PICK_DEBUG = False
@ -31,6 +32,9 @@ PICK_DEBUG = False
class ModelCanvas(QtInteractor): # type: ignore[misc]
"""The central 3D viewport widget."""
#: VTK supports every optional overlay layer.
capabilities = CanvasCapabilities()
# Convenience signals re-emitted from SelectionState.
nodePicked = Signal(int)
elementPicked = Signal(int)
@ -338,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._renderer.set_show_local_axes(enabled)
self.render()
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the scene.
Applies the background immediately (it lives outside the renderer)
and rebuilds the actors whose colours come from the style LUTs.
"""
self._style = style
self.set_background(style.background_bottom, top=style.background_top)
self._renderer.set_style(style)
self.render()
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
"""Toggle viewport labels such as node and element names."""
self._renderer.set_display_options(

View file

@ -10,6 +10,7 @@ Mode-aware: MODEL / DEFORMED / MODAL change only the points array.
from __future__ import annotations
import contextlib
import enum
from dataclasses import dataclass
from typing import Any
@ -24,15 +25,20 @@ from otko.core import (
ElasticBeamColumn,
ForceBeamColumn,
NodalLoad,
QuadElement,
UniformElementLoad,
PlainLoadPattern,
Project,
QuadElement,
TrussElement,
UniformElementLoad,
ZeroLengthElement,
ZeroLengthSectionElement,
)
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas3d.style import (
SELECTED_STATE,
RenderStyle,
element_family_index,
family_palette,
)
class RendererMode(enum.Enum):
@ -184,9 +190,6 @@ class ModelRenderer:
r, g, b = (int(round(x * 255)) for x in rgb)
return f"#{r:02x}{g:02x}{b:02x}"
_NODE_LUT = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected
_FRAME_LUT = ["#338cd9", "#00ffff"] # blue normal, cyan selected
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter
self._style = style or RenderStyle()
@ -205,6 +208,8 @@ class ModelRenderer:
self._frame_actor: Any = None
self._frame_ids_ordered: list[int] = []
self._frame_id_to_row: dict[int, int] = {}
#: Per-frame palette index (family), parallel to ``_frame_ids_ordered``.
self._frame_family: list[int] = []
self._node_label_actor: Any = None
self._element_label_actor: Any = None
self._show_node_labels: bool = False
@ -219,10 +224,8 @@ class ModelRenderer:
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
self._working_plane: tuple[str, float] | None = None
try:
with contextlib.suppress(Exception):
self._plotter.enable_anti_aliasing("ssaa")
except Exception:
pass
# ── public API ───────────────────────────────────────────────────
def render(self, project: Project | None) -> None:
@ -279,6 +282,16 @@ class ModelRenderer:
if self._project is not None:
self.render(self._project)
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and rebuild the scene it colours.
Colours are baked into actor LUTs at build time, so a style change
needs a full re-render rather than a scalar-array tweak.
"""
self._style = style
if self._project is not None:
self.render(self._project)
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
"""Update the working-plane filter and rebuild the grid overlay.
@ -307,11 +320,12 @@ class ModelRenderer:
self._reglyph_nodes()
if self._frame_pd is not None and self._frame_ids_ordered:
states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8)
# Restore each cell to its family slot, then flag the selected ones.
states = np.array(self._frame_family, dtype=np.int8)
for eid in element_ids:
row = self._frame_id_to_row.get(eid)
if row is not None:
states[row] = 1
states[row] = SELECTED_STATE
self._frame_pd.cell_data["_oss_state"] = states
self._frame_pd.Modified()
@ -329,10 +343,8 @@ class ModelRenderer:
"""
# Remove any previous marker.
if self._hover_actor is not None:
try:
with contextlib.suppress(Exception):
self._plotter.remove_actor(self._hover_actor, render=False)
except Exception:
pass
self._hover_actor = None
if world_point is None:
@ -376,15 +388,13 @@ class ModelRenderer:
sphere = pv.Sphere(radius=radius, theta_resolution=8, phi_resolution=8)
glyph = self._node_pd.glyph(geom=sphere, scale=False, orient=False)
if self._node_actor is not None:
try:
with contextlib.suppress(Exception):
self._plotter.remove_actor(self._node_actor, render=False)
except Exception:
pass
self._node_glyph = glyph
self._node_actor = self._plotter.add_mesh(
glyph,
scalars="_oss_state",
cmap=self._NODE_LUT,
cmap=[self._style.node_color, self._style.node_selected_color],
clim=[0, 1],
show_scalar_bar=False,
pickable=True,
@ -397,6 +407,7 @@ class ModelRenderer:
return
cells: list[int] = []
ids: list[int] = []
families: list[int] = []
for el in frames:
try:
i = self._node_id_to_row[el.nodes[0]]
@ -405,6 +416,7 @@ class ModelRenderer:
continue
cells.extend([2, i, j])
ids.append(el.id)
families.append(element_family_index(el))
if not ids:
return
pd = pv.PolyData()
@ -412,16 +424,18 @@ class ModelRenderer:
pd.lines = np.array(cells, dtype=np.int64)
pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64)
pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object)
pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8)
# Cell scalar carries the palette slot (family, or SELECTED_STATE).
pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8)
self._frame_pd = pd
self._frame_ids_ordered = ids
self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)}
self._frame_family = families
self._frame_actor = self._plotter.add_mesh(
pd,
scalars="_oss_state",
cmap=self._FRAME_LUT,
clim=[0, 1],
cmap=family_palette(self._style),
clim=[0, SELECTED_STATE],
show_scalar_bar=False,
line_width=3.0,
pickable=True,
@ -484,10 +498,17 @@ class ModelRenderer:
plane_axis = axis_idx
plane_offset_local = plane_off - cs_shift
def _on_active_plane(local_pt: tuple[float, float, float]) -> bool:
if plane_axis is None:
# Bind the per-iteration plane state as defaults so the closure
# cannot read a later iteration's values (B023); the helper is
# 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 abs(local_pt[plane_axis] - plane_offset_local) < 1e-6 # type: ignore[operator]
return abs(local_pt[_axis] - _offset) < 1e-6
# Collect active + dim segments separately so they get their
# own polydata + actor (different opacity / color).
@ -496,13 +517,21 @@ class ModelRenderer:
dim_pts: list[tuple[float, float, float]] = []
dim_cells: list[int] = []
def add_seg(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None:
def add_seg(
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)
bucket_pts = active_pts if on_active else dim_pts
bucket_cells = active_cells if on_active else dim_cells
bucket_pts = _active_pts if on_active else _dim_pts
bucket_cells = _active_cells if on_active else _dim_cells
i = len(bucket_pts)
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(p1))
bucket_pts.append(_cs.coord.local_to_world(p2))
bucket_cells.extend([2, i, i + 1])
z_planes = zs if zs else [0.0]
@ -614,7 +643,7 @@ class ModelRenderer:
continue
# By design: area-only / thickness-only / material-only
# elements carry no section.
if isinstance(el, (TrussElement, CorotTrussElement, QuadElement, ZeroLengthElement)):
if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement):
continue
node_i = node_by_id.get(el.nodes[0])
node_j = node_by_id.get(el.nodes[1])
@ -665,18 +694,16 @@ class ModelRenderer:
diag = self._diag_of_points(self._node_original_points)
cap = max(diag * 0.08, 1e-6)
for el in project.elements:
if isinstance(el, (QuadElement, ZeroLengthElement, ZeroLengthSectionElement)):
if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement):
continue
if not isinstance(
el,
(
ElasticBeamColumn,
DispBeamColumn,
ForceBeamColumn,
BeamWithHingesElement,
TrussElement,
CorotTrussElement,
),
ElasticBeamColumn
| DispBeamColumn
| ForceBeamColumn
| BeamWithHingesElement
| TrussElement
| CorotTrussElement,
):
continue
node_i = node_by_id.get(el.nodes[0])
@ -753,8 +780,8 @@ class ModelRenderer:
"""Add one semi-transparent steel-blue non-pickable aux actor."""
actor = self._plotter.add_mesh(
mesh,
color=(0.35, 0.60, 0.85), # cool steel-blue
opacity=0.22,
color=self._style.extrusion_color,
opacity=self._style.extrusion_opacity,
show_edges=True,
edge_color=(0.15, 0.25, 0.45),
line_width=1.0,
@ -974,7 +1001,7 @@ class ModelRenderer:
ndf = project.ndf
dof_idx = _dof_indices(ndf)
size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6)
support_color = (1.0, 0.5, 0.1)
support_color = self._style.support_color
for node in project.nodes:
if not any(node.restraint[i] for i in dof_idx):
continue
@ -988,7 +1015,7 @@ class ModelRenderer:
if not project.load_patterns or self._node_original_points is None:
return
scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6)
load_color = (0.2, 0.85, 0.2)
load_color = self._style.nodal_load_color
node_by_id = {n.id: n for n in project.nodes}
elem_by_id = {e.id: e for e in project.elements}
for pattern in project.load_patterns:
@ -1019,7 +1046,7 @@ class ModelRenderer:
# Draw N arrows along the element span, each perpendicular
# to the axis in the direction of the load. Uses the same
# orange-green palette as nodal loads but with shorter arrows.
elem_load_color = (1.0, 0.55, 0.2) # orange
elem_load_color = self._style.element_load_color # distributed loads
n_arrows_per_elem = 5
for eload in pattern.element_loads:
if not isinstance(eload, UniformElementLoad):
@ -1094,15 +1121,11 @@ class ModelRenderer:
self._clear_label_actors()
for a in (self._node_actor, self._frame_actor):
if a is not None:
try:
with contextlib.suppress(Exception):
self._plotter.remove_actor(a, render=False)
except Exception:
pass
for a in self._aux_actors:
try:
with contextlib.suppress(Exception):
self._plotter.remove_actor(a, render=False)
except Exception:
pass
self._node_actor = None
self._frame_actor = None
self._aux_actors.clear()
@ -1112,6 +1135,7 @@ class ModelRenderer:
self._node_ids_ordered = []
self._node_id_to_row = {}
self._frame_ids_ordered = []
self._frame_family = []
self._frame_id_to_row = {}
self._node_original_points = None
self._deformation = None
@ -1120,10 +1144,8 @@ class ModelRenderer:
def _clear_label_actors(self) -> None:
for actor in (self._node_label_actor, self._element_label_actor):
if actor is not None:
try:
with contextlib.suppress(Exception):
self._plotter.remove_actor(actor, render=False)
except Exception:
pass
self._node_label_actor = None
self._element_label_actor = None
@ -1146,7 +1168,7 @@ class ModelRenderer:
return self._plotter.add_point_labels(
pts,
labels,
font_size=12,
font_size=self._style.label_font_size,
shape_opacity=0.15,
text_color="black",
point_color="white",
@ -1176,7 +1198,7 @@ class ModelRenderer:
return self._plotter.add_point_labels(
np.asarray(centers),
labels,
font_size=12,
font_size=self._style.label_font_size,
shape_opacity=0.15,
text_color="black",
point_color="white",

View file

@ -4,11 +4,64 @@ All colors, sizes, and glyph parameters live here. Changes apply
globally through the renderer; downstream code never hard-codes a
color or radius. A future "theme" feature can simply swap a different
RenderStyle instance.
The subset in :attr:`RenderStyle.EDITABLE` is surfaced to the user through
Options → Plot Properties… and persisted in ``QSettings`` (``plot/props``).
Palette values marked "opstool" are adapted from opstool v1.0.26 (GPL-3.0);
see ``NOTICE``.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any, ClassVar
from otko.core import CorotTrussElement, TrussElement, ZeroLengthElement
#: Selected elements / nodes occupy the last slot of the palette LUT.
SELECTED_STATE: int = 3
def element_family_index(element: object) -> int:
"""Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length."""
if isinstance(element, TrussElement | CorotTrussElement):
return 1
if isinstance(element, ZeroLengthElement):
return 2
return 0
def family_palette(style: RenderStyle) -> list[str]:
"""LUT shared by both backends: [beam, truss, link, selected]."""
return [
style.element_beam_color,
style.element_truss_color,
style.element_link_color,
style.selected_color,
]
#: Colours / sizes the Plot Properties dialog exposes, in display order.
STYLE_FIELDS: tuple[tuple[str, str, str], ...] = (
("element_beam_color", "Beam elements", "color"),
("element_truss_color", "Truss elements", "color"),
("element_link_color", "Link / zero-length", "color"),
("selected_color", "Selection highlight", "color"),
("node_color", "Nodes", "color"),
("node_selected_color", "Selected nodes", "color"),
("support_color", "Supports", "color"),
("nodal_load_color", "Nodal loads", "color"),
("element_load_color", "Element loads", "color"),
("extrusion_color", "Section extrusions", "color"),
("extrusion_opacity", "Extrusion opacity", "float"),
("background_top", "Background (top)", "color"),
("background_bottom", "Background (bottom)", "color"),
("show_axis_outline", "Axis outline (grid + ticks)", "bool"),
("label_font_size", "Label font size", "int"),
)
#: Field names the dialog edits and that are persisted between sessions.
EDITABLE_FIELDS: tuple[str, ...] = tuple(name for name, _label, _kind in STYLE_FIELDS)
@dataclass(frozen=True, slots=True)
@ -19,20 +72,58 @@ class RenderStyle:
background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb"
node_color: str = "#f0a500" # warm gold
node_selected_color: str = "#00d4ff" # bright cyan
node_color: str = "#d9d9d9" # normal node sphere / marker
node_selected_color: str = "#00ffff" # selected node
frame_color: str = "#1f1f1f"
truss_color: str = "#2e5cb8"
truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad
zerolength_color: str = "#a020f0"
selected_color: str = "#00d4ff"
selected_color: str = "#00ffff" # selected element highlight
# ── element palette (adapted from opstool v1.0.26, GPL-3.0) ───────
# opstool colours wireframe elements by *family* rather than painting
# every frame the same colour, which makes mixed beam/truss/link models
# readable at a glance. Values come from ``PLOT_ARGS_DEFAULT`` in
# ``opstool/vis/{pyvista,plotly}/plot_utils.py``; see ``NOTICE``.
element_beam_color: str = "#0652ff" # beam-column family
element_truss_color: str = "#FF8C00" # truss family
element_link_color: str = "#39FF14" # zero-length / link family
# ── overlay colours ─────────────────────────────────────────────
support_color: str = "#ff8019"
nodal_load_color: str = "#33d933"
element_load_color: str = "#ff8c33"
extrusion_color: str = "#598cff"
extrusion_opacity: float = 0.22
label_font_size: int = 12
#: Draw the plotly scene grid + tick marks around the model (the "outline"
#: of opstool's scene recipe). Off by default to keep the SAP2000-like
#: clean viewport; the coloured X/Y/Z axis lines always stay visible.
show_axis_outline: bool = False
#: Diverging scale for scalar response overlays (force diagrams today,
#: nodal / element response plots later): blue → red, evenly spaced.
#: This is opstool's ``default_cmap`` (RdYlBu reversed).
response_scale_colors: tuple[str, ...] = (
"#313695",
"#4575b4",
"#74add1",
"#abd9e9",
"#e0f3f8",
"#fee090",
"#fdae61",
"#f46d43",
"#d73027",
"#a50026",
)
fix_color: str = "#c0392b" # firebrick
pin_color: str = "#c0392b"
roller_color: str = "#e67e22"
custom_support_color: str = "#7f8c8d"
load_color: str = "#27ae60"
load_color: str = "#27ae60" # Y-axis colour of the view triad
mass_color: str = "#9b59b6"
# ── sizes (relative to bbox diagonal unless absolute) ────────────
@ -49,3 +140,24 @@ class RenderStyle:
load_min_length: float = 0.5
selection_thickness_factor: float = 1.6 # multiplier for selected actors
#: Names of the fields the Plot Properties dialog edits.
EDITABLE: ClassVar[tuple[str, ...]] = EDITABLE_FIELDS
# ── helpers ──────────────────────────────────────────────────────
def response_colorscale(self) -> list[tuple[float, str]]:
"""Plotly ``colorscale`` form of :attr:`response_scale_colors`."""
count = len(self.response_scale_colors)
if count < 2:
return [(0.0, self.response_scale_colors[0])]
return [
(index / (count - 1), color) for index, color in enumerate(self.response_scale_colors)
]
def with_updates(self, **changes: Any) -> RenderStyle:
"""Return a copy with ``changes`` applied (frozen-dataclass safe)."""
return replace(self, **changes)
def editable_values(self) -> dict[str, Any]:
"""The dialog-managed subset of this style, as a plain dict."""
return {name: getattr(self, name) for name in self.EDITABLE}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,417 @@
"""Plotly-backed 3D canvas.
A ``QWebEngineView`` hosting plotly.js, driven over ``QWebChannel``. It
implements the same public surface as :class:`otko.views.canvas3d.ModelCanvas`
(signals, selection, working plane, view presets, display toggles) so
``MainWindow`` can swap the two at runtime.
Update strategy: figures are pushed with ``Plotly.react``. Because react
resets any scene attribute the incoming layout omits, a non-framing push is
flagged ``preserveView`` and the JS side carries the live camera and axis
ranges forward — so re-rendering on a model edit or selection change never
yanks the camera the user is orbiting. Only an explicit re-frame (new
project, view preset, ``reset_camera``, style change) sends the computed
camera plus opstool-style padded axis ranges.
"""
from __future__ import annotations
import json
import math
from collections.abc import Callable
from dataclasses import replace
from typing import Any
from PySide6.QtCore import QObject, QUrl, Signal
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEnginePage
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QVBoxLayout, QWidget
from otko.views.canvas3d.model_renderer import RendererMode
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_base import CanvasCapabilities
from otko.views.canvas_plotly import html as _html
from otko.views.canvas_plotly.bridge import _Bridge
from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder,
Scene,
SceneOptions,
)
#: View preset directions (unit-ish vectors from the scene centre to the eye).
_VIEW_DIRECTIONS = {
"iso": (1.0, 1.0, 0.8),
"xy": (0.0, 0.0, 1.0),
"xz": (0.0, -1.0, 0.0),
"yz": (1.0, 0.0, 0.0),
}
class _LoggingPage(QWebEnginePage):
"""A web page that forwards the JS console to the app's console dock.
A plotly failure inside the page is otherwise invisible from Python (the
``react`` promise just rejects), which makes a frozen canvas very hard to
diagnose.
"""
def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None:
super().__init__(parent)
self._log = log
def javaScriptConsoleMessage(
self,
level: QWebEnginePage.JavaScriptConsoleMessageLevel,
message: str,
line_number: int,
source_id: str,
) -> None:
names = {
QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error",
QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning",
}
severity = names.get(level, "info")
source = source_id.rsplit("/", 1)[-1] or "plotly"
self._log(f"[web] {source}:{line_number} {message}", severity)
class _CameraShim:
"""Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere."""
def __init__(self, canvas: PlotlyCanvas) -> None:
self._canvas = canvas
@property
def parallel_projection(self) -> bool:
return self._canvas._parallel
@parallel_projection.setter
def parallel_projection(self, value: bool) -> None:
self._canvas.set_parallel_projection(bool(value))
class _PlotlyRendererFacade:
"""Stand-in for ``ModelRenderer`` covering the calls made on ``_renderer``.
``RenderControls`` and ``DockManager`` reach into ``canvas._renderer`` for
``render`` / ``set_mode`` / ``_mode``; this keeps those code paths
backend-agnostic.
"""
def __init__(self, canvas: PlotlyCanvas) -> None:
self._canvas = canvas
@property
def _mode(self) -> RendererMode:
return self._canvas._mode
@property
def _project(self) -> Any:
return self._canvas._project
@property
def _working_plane(self) -> tuple[str, float] | None:
return self._canvas._working_plane
def render(self, project: Any) -> None:
self._canvas.set_project(project)
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
self._canvas.set_mode(mode, deformation)
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
if plane is None:
self._canvas.set_working_plane(None, 0.0)
else:
self._canvas.set_working_plane(plane[0], plane[1])
def set_show_section_extrusions(self, on: bool) -> None:
self._canvas.set_show_section_extrusions(on)
def set_show_local_axes(self, on: bool) -> None:
self._canvas.set_show_local_axes(on)
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
self._canvas.set_display_options(
show_node_labels=show_node_labels,
show_element_labels=show_element_labels,
)
class PlotlyCanvas(QWidget):
"""The central 3D viewport, rendered by plotly.js in a web view."""
nodePicked = Signal(int)
elementPicked = Signal(int)
emptyClicked = Signal(float, float, float)
#: Force diagrams and off-screen video capture are not implemented on
#: this backend yet (both are PyVista-specific today).
capabilities = CanvasCapabilities(diagrams=False, animation_export=False)
def __init__(
self,
parent: QWidget | None = None,
style: RenderStyle | None = None,
selection: SelectionState | None = None,
log: Callable[[str, str], None] | None = None,
) -> None:
super().__init__(parent)
self._log = log
self._style = style or RenderStyle()
self.selection = selection or SelectionState(self)
self._builder = PlotlyTraceBuilder(self._style)
self._project: Any = None
self._scene = Scene(data=[], layout={})
self._options = SceneOptions()
self._mode = RendererMode.MODEL
self._parallel = False
self._view_preset = "iso"
self._snap_enabled = False
self._default_selection_enabled = True
self._working_plane: tuple[str, float] | None = None
self._camera = _CameraShim(self)
self._framing_dirty = True
self._renderer = _PlotlyRendererFacade(self)
self._ready = False
self._build_ui()
self.selection.selectionChanged.connect(self._on_selection_changed)
# ── construction ─────────────────────────────────────────────────
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._web = QWebEngineView(self)
if self._log is not None:
self._web.setPage(_LoggingPage(self._log, self._web))
self._channel = QWebChannel(self._web.page())
self._bridge = _Bridge(self)
self._channel.registerObject("otkoBridge", self._bridge)
self._web.page().setWebChannel(self._channel)
self._web.loadFinished.connect(self._on_loaded)
self._bridge.picked.connect(self._on_picked)
self._bridge.snapClicked.connect(self._on_snap_clicked)
layout.addWidget(self._web)
self._web.load(QUrl.fromLocalFile(_html.runtime_url()))
def _on_loaded(self, ok: bool) -> None:
if not ok:
return
self._ready = True
self._push_scene()
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
# ── public API (mirrors ModelCanvas) ─────────────────────────────
def show_project(self, project: Any) -> None:
"""Render (or clear) a project; frame the camera when nodes exist."""
self.set_project(project)
if project is not None and project.nodes:
self._view_preset = "iso"
self._framing_dirty = True
self.render()
def clear_model(self) -> None:
"""Remove the model but keep the view/selection machinery."""
self.selection.clear()
self.set_project(None)
self.render()
def render(self, *args: Any, **kwargs: Any) -> None:
"""Rebuild the figure and hand it to plotly.js.
``QWidget.render`` is overloaded for painting into a target; those
calls are forwarded untouched so the widget stays well-behaved, and
only the no-argument canvas idiom (shared with ``ModelCanvas``)
triggers a figure push.
"""
if args or kwargs:
super().render(*args, **kwargs)
return
self._push_scene()
def set_project(self, project: Any) -> None:
"""Bind a project without rendering (used by the ``_renderer`` facade)."""
self._project = project
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
"""Set MODEL / DEFORMED / MODAL plus the displacement source to apply."""
self._mode = mode
self._options = replace(self._options, deformation=deformation)
def set_parallel_projection(self, on: bool) -> None:
self._parallel = bool(on)
self._framing_dirty = True
def reset_camera(self) -> None:
self._view_preset = "iso"
self._framing_dirty = True
self.render()
def view_isometric(self) -> None:
self._view_preset = "iso"
self._framing_dirty = True
self.render()
def view_xy(self) -> None:
"""Top view: the eye sits on +Z looking down."""
self._view_preset = "xy"
self._framing_dirty = True
self.render()
def view_xz(self) -> None:
"""Front view: the eye sits on -Y."""
self._view_preset = "xz"
self._framing_dirty = True
self.render()
def view_yz(self) -> None:
"""Right view: the eye sits on +X."""
self._view_preset = "yz"
self._framing_dirty = True
self.render()
# ── working plane ────────────────────────────────────────────────
def set_working_plane(self, plane: str | None, offset: float) -> None:
"""Filter the grid overlay to the active plan / elevation level."""
if plane is None:
self._working_plane = None
else:
if plane not in ("XY", "XZ", "YZ"):
raise ValueError(f"Unsupported working plane: {plane!r}")
self._working_plane = (plane, float(offset))
self._options = replace(self._options, working_plane=self._working_plane)
self.render()
def clear_working_plane(self) -> None:
self.set_working_plane(None, 0.0)
def working_plane_type(self) -> str | None:
return self._working_plane[0] if self._working_plane is not None else None
def working_plane_offset(self) -> float | None:
return self._working_plane[1] if self._working_plane is not None else None
# ── display toggles ──────────────────────────────────────────────
def set_snap_preview_enabled(self, enabled: bool) -> None:
"""Toggle the hover snap-target preview (draw tools turn it on)."""
self._snap_enabled = bool(enabled)
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
def set_show_section_extrusions(self, enabled: bool) -> None:
self._options = replace(self._options, show_extrusions=bool(enabled))
self.render()
def set_show_local_axes(self, enabled: bool) -> None:
self._options = replace(self._options, show_local_axes=bool(enabled))
self.render()
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the figure.
Deliberately *not* a re-frame: colour/opacity are trace props and the
background/outline ride the layout, so a style change (including the
live preview while dragging a colour) updates without snapping the
camera the user has moved.
"""
self._style = style
self._builder.set_style(style)
self.render()
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
self._options = replace(
self._options,
show_node_labels=bool(show_node_labels),
show_element_labels=bool(show_element_labels),
)
self.render()
def set_default_selection_enabled(self, enabled: bool) -> None:
"""When False, picks fire signals but do not touch :attr:`selection`."""
self._default_selection_enabled = bool(enabled)
# ── internals ───────────────────────────────────────────────────
def _push_scene(self) -> None:
"""Send the figure to plotly.js.
``Plotly.react`` resets any scene attribute the incoming layout omits,
which used to snap the camera *and* the padded ranges back on every
selection change. So a non-framing push marks itself
``preserveView`` and the JS side carries the live camera/ranges
forward; only an explicit re-frame (new project, view preset,
``reset_camera``) sends the computed framing. Style changes are
non-framing: they re-colour in place.
"""
self._scene = self._builder.build(self._project, self._options)
if not self._ready:
# Nothing to push yet; crucially this must come *before* the
# framing flag is consumed, or the framing scheduled before
# loadFinished would be dropped on the floor.
return
layout = dict(self._scene.layout)
re_framed = self._framing_dirty
if re_framed:
scene_layout = dict(layout["scene"])
scene_layout["camera"] = self._camera_dict(self._scene)
for axis, override in self._scene.axis_overrides().items():
scene_layout[axis] = {**scene_layout.get(axis, {}), **override}
layout["scene"] = scene_layout
self._framing_dirty = False
payload = json.dumps({"data": self._scene.data, "layout": layout})
self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
cx, cy, cz = scene.center
distance = max(scene.diagonal, 1e-6) * 1.6
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
eye = (
cx + direction[0] / norm * distance,
cy + direction[1] / norm * distance,
cz + direction[2] / norm * distance,
)
# Looking straight down the Z axis needs a non-degenerate up vector.
up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0)
return {
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
"center": {"x": cx, "y": cy, "z": cz},
"up": {"x": up[0], "y": up[1], "z": up[2]},
"projection": {"type": "orthographic" if self._parallel else "perspective"},
}
def _eval(self, js: str) -> None:
if self._ready:
self._web.page().runJavaScript(js)
def _on_selection_changed(self, nodes: frozenset[int], elements: frozenset[int]) -> None:
self._options = replace(
self._options,
selection_nodes=frozenset(nodes),
selection_elements=frozenset(elements),
)
self.render()
def _on_picked(self, kind: str, entity_id: int, additive: bool) -> None:
if kind == "node":
if self._default_selection_enabled:
if additive:
self.selection.toggle_node(entity_id)
else:
self.selection.select_node(entity_id)
self.nodePicked.emit(entity_id)
elif kind == "element":
if self._default_selection_enabled:
if additive:
self.selection.toggle_element(entity_id)
else:
self.selection.select_element(entity_id)
self.elementPicked.emit(entity_id)
def _on_snap_clicked(self, x: float, y: float, z: float) -> None:
self.emptyClicked.emit(float(x), float(y), float(z))
def _js_bool(value: bool) -> str:
return "true" if value else "false"

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
from otko.views.dialogs.plain_pattern import PlainPatternDialog
from otko.views.dialogs.plot_properties import PlotPropertiesDialog
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
from otko.views.dialogs.quick_guide import QuickGuideDialog
from otko.views.dialogs.replicate import ReplicateDialog
@ -69,6 +70,7 @@ __all__ = [
"PathTimeSeriesDialog",
"PatternLoadsDialog",
"PlainPatternDialog",
"PlotPropertiesDialog",
"QuickGridLinesDialog",
"QuickGuideDialog",
"ReplicateDialog",

View file

@ -8,7 +8,6 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QInputDialog,
QLabel,
QLineEdit,
QVBoxLayout,

View file

@ -8,6 +8,8 @@ it maps cleanly onto our existing :class:`SetMassCommand`.
from __future__ import annotations
import contextlib
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
@ -103,10 +105,8 @@ class AssignMassesDialog(QDialog):
self._my.setValue(self._mx.value())
self._mx.valueChanged.connect(self._my.setValue)
else:
try:
with contextlib.suppress(RuntimeError, TypeError):
self._mx.valueChanged.disconnect(self._my.setValue)
except (RuntimeError, TypeError):
pass
def mass_vector(self) -> tuple[float, float, float, float, float, float]:
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""

View file

@ -8,7 +8,6 @@ model's actual ``ndf`` at translation time.
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
@ -22,7 +21,6 @@ from PySide6.QtWidgets import (
QWidget,
)
# Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz)
PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = {
"Free": (False, False, False, False, False, False),

View file

@ -21,7 +21,6 @@ from PySide6.QtWidgets import (
QListWidget,
QListWidgetItem,
QSpinBox,
QVBoxLayout,
QWidget,
)

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import ValidationError
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
@ -19,7 +20,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from pydantic import ValidationError
from otko.commands import (
AddAnalysisCasesCommand,
@ -34,7 +34,7 @@ from otko.core import (
TransientCase,
)
from otko.viewmodels import ProjectViewModel
from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for
from otko.views.dialogs.case_forms import form_for
if TYPE_CHECKING:
from otko.core import Project

View file

@ -18,6 +18,8 @@ Top-level controls:
from __future__ import annotations
from typing import ClassVar
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
@ -27,7 +29,6 @@ from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
@ -52,7 +53,6 @@ from otko.core.geometry import (
GridSystem,
)
_LINE_TYPE_CHOICES = ["Primary", "Secondary"]
_BUBBLE_LOC_CHOICES = ["Start", "End"]
@ -60,7 +60,14 @@ _BUBBLE_LOC_CHOICES = ["Start", "End"]
class _AxisGridTable(QWidget):
"""A single X/Y/Z spreadsheet — one row per GridLine."""
COLUMNS = ["Grid ID", "Ordinate", "Line Type", "Visibility", "Bubble Loc", "Color"]
COLUMNS: ClassVar[list[str]] = [
"Grid ID",
"Ordinate",
"Line Type",
"Visibility",
"Bubble Loc",
"Color",
]
def __init__(self, axis: str, parent: QWidget | None = None) -> None:
super().__init__(parent)

View file

@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLineEdit,
QLabel,
QLineEdit,
QVBoxLayout,
QWidget,
)

View file

@ -13,7 +13,6 @@ from PySide6.QtWidgets import (
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget,

View file

@ -7,9 +7,9 @@ committed. Each Add/Edit/Delete is its own undoable step.
from __future__ import annotations
from pydantic import ValidationError
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
@ -23,7 +23,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from pydantic import ValidationError
from otko.commands import (
AddMaterialsCommand,

View file

@ -120,7 +120,7 @@ class PathTimeSeriesDialog(QDialog):
return
try:
dt, npts, vals = parse_peer_record(fname)
except Exception as exc: # noqa: BLE001
except Exception as exc:
QMessageBox.critical(self, "PEER import failed", str(exc))
return
self._values = vals
@ -142,7 +142,7 @@ class PathTimeSeriesDialog(QDialog):
return
try:
vals = parse_plain_values(fname)
except Exception as exc: # noqa: BLE001
except Exception as exc:
QMessageBox.critical(self, "Import failed", str(exc))
return
self._values = vals

View file

@ -0,0 +1,150 @@
"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
so adding an editable property is a one-line change there plus a default on
the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
so the canvas can preview them live; ``style()`` returns the composed result.
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
class _ColorButton(QPushButton):
"""A swatch that opens :class:`QColorDialog` and reports the hex value."""
colorChanged = Signal(str)
def __init__(self, color: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._color = color
self.setFixedWidth(96)
self._refresh()
self.clicked.connect(self._pick)
def color(self) -> str:
"""Current colour as a ``#rrggbb`` string."""
return self._color
def set_color(self, color: str) -> None:
"""Set the swatch colour (does not emit :attr:`colorChanged`)."""
self._color = color
self._refresh()
def _refresh(self) -> None:
self.setText(self._color.upper())
self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
def _text_color(self) -> str:
color = QColor(self._color)
luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
return "#000000" if luminance > 140 else "#ffffff"
def _pick(self) -> None:
chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
if chosen.isValid():
self.set_color(chosen.name())
self.colorChanged.emit(self._color)
class PlotPropertiesDialog(QDialog):
"""Modeless-free editor for the persisted plot style."""
#: Emitted with the composed :class:`RenderStyle` on every edit.
changed = Signal(object)
def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Plot Properties")
self._base = style
self._colors: dict[str, _ColorButton] = {}
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
self._checks: dict[str, QCheckBox] = {}
layout = QVBoxLayout(self)
form = QFormLayout()
for name, label, kind in STYLE_FIELDS:
widget = self._build_widget(name, getattr(style, name), kind)
form.addRow(QLabel(f"{label}:"), widget)
layout.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
assert reset is not None
reset.clicked.connect(self._reset)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# ── public ──────────────────────────────────────────────────────
def plot_style(self) -> RenderStyle:
"""The composed style for the current widget values.
Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
already exists and must keep its Qt meaning.
"""
updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
updates.update({name: w.value() for name, w in self._numbers.items()})
updates.update({name: w.isChecked() for name, w in self._checks.items()})
return self._base.with_updates(**updates)
# ── internals ───────────────────────────────────────────────────
def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
if kind == "color":
button = _ColorButton(str(value))
button.colorChanged.connect(self._emit_changed)
self._colors[name] = button
return button
if kind == "float":
spin = QDoubleSpinBox()
spin.setRange(0.0, 1.0)
spin.setSingleStep(0.05)
spin.setDecimals(2)
spin.setValue(float(value)) # type: ignore[arg-type]
spin.valueChanged.connect(self._emit_changed)
self._numbers[name] = spin
return spin
if kind == "bool":
check = QCheckBox()
check.setChecked(bool(value))
check.toggled.connect(self._emit_changed)
self._checks[name] = check
return check
int_spin = QSpinBox()
int_spin.setRange(6, 32)
int_spin.setValue(int(value)) # type: ignore[call-overload]
int_spin.valueChanged.connect(self._emit_changed)
self._numbers[name] = int_spin
return int_spin
def _emit_changed(self, *_args: object) -> None:
self.changed.emit(self.plot_style())
def _reset(self) -> None:
defaults = RenderStyle()
for name, button in self._colors.items():
button.set_color(getattr(defaults, name))
for name, spin in self._numbers.items():
spin.setValue(getattr(defaults, name))
for name, check in self._checks.items():
check.setChecked(getattr(defaults, name))
self._emit_changed()

View file

@ -11,7 +11,6 @@ from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,

View file

@ -29,7 +29,7 @@ with <b>Define → Add Plain Load Pattern…</b> or
<b>Define → Add Uniform Excitation…</b> for seismic input.</p>
<h4>4&nbsp;— Draw the model</h4>
<p>Tools toolbar (left): <b>Select</b>, <b>Draw Node</b> (F1),
<p>Tools toolbar (top): <b>Select</b>, <b>Draw Node</b> (F1),
<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)
for precise layout; use the top-toolbar <b>Level</b> combo in Top/Front/Right views.</p>

View file

@ -193,7 +193,7 @@ class RunAnalysisDialog(QDialog):
results_dir = self._vm.path.parent / f"{self._vm.path.stem}_results"
try:
self._runner.run(self._vm.project, case, results_dir=results_dir)
except Exception as exc: # noqa: BLE001
except Exception as exc:
self._log.appendPlainText(f"Could not start: {exc}")
def _on_evaluate_combo(self) -> None:
@ -233,7 +233,7 @@ class RunAnalysisDialog(QDialog):
self._results = combined
self._log.appendPlainText(f"--- Done. Returned {type(combined).__name__}. ---")
self._runner.finished.emit(combined)
except Exception as exc: # noqa: BLE001
except Exception as exc:
import traceback
self._log.appendPlainText("--- FAILED ---")

View file

@ -12,7 +12,6 @@ from __future__ import annotations
from typing import Any
import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
@ -324,7 +323,6 @@ class FiberSectionEditor(QDialog):
self._props_label.setText("Add patches or layers to see the preview.")
return
f = props.fibre_yz
# Color by material: assign a palette index per unique material_id.
mat_ids = []
for p in self._patches:

View file

@ -19,8 +19,11 @@ from PySide6.QtWidgets import (
)
from otko.core import (
AngleShape,
ElasticSection,
FiberSection,
PipeShape,
RectShape,
SectionAggregator,
)
@ -94,14 +97,13 @@ class SectionFormBase(QWidget):
class ElasticSectionForm(SectionFormBase):
type_label = "Elastic Section"
#: Shape → visible display-only dimensions. ``None`` hides them all.
#: Shape kind → visible display-only dimensions (see the core
#: ``SectionShape`` union). ``None`` hides them all.
_SHAPE_DIMS: ClassVar[dict[str | None, tuple[str, ...]]] = {
None: (),
"rect": ("b", "h"),
"I": ("b", "d", "tw", "tf"),
"H": ("b", "d", "tw", "tf"),
"pipe": ("diameter",),
"circle": ("diameter",),
"rect": ("b", "d"),
"angle": ("b", "d", "t"),
"pipe": ("od", "t"),
}
def __init__(self, parent: QWidget | None = None) -> None:
@ -127,14 +129,14 @@ class ElasticSectionForm(SectionFormBase):
self._layout.addRow(QLabel("<i>Extruded-view shape (display only).</i>"))
self._shape = QComboBox()
self._shape.addItem("(none)", None)
for key in ("rect", "I", "H", "pipe", "circle"):
for key in ("rect", "angle", "pipe"):
self._shape.addItem(key, key)
self._shape.currentIndexChanged.connect(self._update_dim_visibility)
self._layout.addRow("Shape:", self._shape)
self._dim_spins: dict[str, QDoubleSpinBox] = {}
self._dim_labels: dict[str, QLabel] = {}
for key in ("b", "h", "d", "tw", "tf", "diameter"):
for key in ("b", "d", "t", "od"):
spin = _opt_spin()
lbl = QLabel(f"{key}:")
self._dim_spins[key] = spin
@ -160,20 +162,15 @@ class ElasticSectionForm(SectionFormBase):
self._g.setValue(s.G)
if s.J is not None:
self._j.setValue(s.J)
idx = self._shape.findData(s.shape)
kind = getattr(s.shape, "kind", None) if s.shape is not None else None
idx = self._shape.findData(kind)
self._shape.setCurrentIndex(idx if idx >= 0 else 0)
for key, spin in self._dim_spins.items():
v = getattr(s, key, None)
v = getattr(s.shape, key, None) if s.shape is not None else None
spin.setValue(v if v is not None else _UNSET_SENTINEL)
self._update_dim_visibility()
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(
id=sid,
name=self._name_edit.text(),
@ -183,10 +180,38 @@ class ElasticSectionForm(SectionFormBase):
Iy=self._iy.value(),
G=self._g.value(),
J=self._j.value(),
shape=shape,
**dims, # type: ignore[arg-type]
shape=self._read_shape(),
)
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):
"""Read-only overview of a :class:`FiberSection`.

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from pydantic import ValidationError
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
@ -17,7 +18,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from pydantic import ValidationError
from otko.commands import (
AddSectionsCommand,

View file

@ -23,7 +23,6 @@ from PySide6.QtWidgets import (
from otko.core import Project, UniformExcitationPattern
_DIRECTION_CHOICES: list[tuple[int, str]] = [
(1, "1 — X (horizontal)"),
(2, "2 — Y (vertical for ndm=2, lateral for ndm=3)"),

View file

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

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (

View file

@ -10,8 +10,6 @@ Exposes two signals:
from __future__ import annotations
import math
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,

View file

@ -15,6 +15,7 @@ For 2D it's [N1, Vy1, Mz1, N2, Vy2, Mz2].
from __future__ import annotations
import contextlib
from typing import Any
import pyqtgraph as pg
@ -337,8 +338,6 @@ class HysteresisView(QWidget):
def _clear_curve(self) -> None:
if self._curve is not None:
try:
with contextlib.suppress(Exception):
self._plot.removeItem(self._curve)
except Exception:
pass
self._curve = None

View file

@ -8,9 +8,8 @@ dumb: no direct Project mutation, no Qt ↔ OpenSees coupling.
from __future__ import annotations
from typing import Callable
from collections.abc import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDoubleSpinBox,
@ -26,7 +25,6 @@ from PySide6.QtWidgets import (
from otko.core import Project
# Element types the Properties dock lets the user switch between.
# "ElasticBeamColumn" needs a section; "Truss" / "CorotTruss" need a
# uniaxial material + area. The Convert command drops/adds fields to

View file

@ -26,7 +26,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from otko.core import UnitLabels, UnitSystem, labels_for
from otko.core import UnitSystem, labels_for
from otko.services.results import PushoverResults
if TYPE_CHECKING:

View file

@ -8,7 +8,7 @@ Two panels:
from __future__ import annotations
import pyqtgraph as pg
from PySide6.QtCore import Signal
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QHeaderView,
@ -20,7 +20,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from PySide6.QtCore import Qt
from otko.core import ResponseSpectrum
from otko.services.results import ResponseSpectrumResults

View file

@ -197,7 +197,7 @@ def _fmt(value: Any) -> str:
return "—"
if isinstance(value, float):
return f"{value:g}"
if isinstance(value, (list, tuple)):
if isinstance(value, list | tuple):
return ", ".join(_fmt(v) for v in value)
return str(value)
@ -1035,7 +1035,7 @@ class TableDock(QWidget):
try:
key = int(pid)
label = name_of.get(key, str(key)) or str(key)
parts.append(f"{label}×{float(factor):g}") # noqa: RUF001
parts.append(f"{label}×{float(factor):g}")
except (TypeError, ValueError):
continue
return ", ".join(parts) if parts else "—"

View file

@ -10,9 +10,9 @@ runner to record additional series (planned for a later phase).
from __future__ import annotations
import contextlib
from typing import Any
import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
@ -208,9 +208,7 @@ class TimeHistoryView(QWidget):
def _clear_traces(self) -> None:
for _, _, item in self._traces:
try:
with contextlib.suppress(Exception):
self._plot.removeItem(item)
except Exception:
pass
self._traces.clear()
self._trace_list.clear()

View file

@ -13,16 +13,21 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from PySide6.QtCore import QSettings
from PySide6.QtGui import QCloseEvent, QIcon
from PySide6.QtWidgets import QMainWindow, QMessageBox
from PySide6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers
from otko.views.canvas3d import ModelCanvas
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_base import plotly_available
from otko.views.dock_manager import DockManager
from otko.views.menu_builder import MenuBuilder
from otko.views.render_controls import RenderControls
@ -34,6 +39,9 @@ from otko.views.tools import (
ToolController,
)
#: Canvas backends, in menu order.
CANVAS_BACKENDS = ("pyvista", "plotly")
class MainWindow(
QMainWindow,
@ -44,7 +52,7 @@ class MainWindow(
):
"""Top-level application shell."""
def __init__(self) -> None:
def __init__(self, settings: QSettings | None = None) -> None:
super().__init__()
self.setWindowTitle("OTKO")
self.setWindowIcon(
@ -60,12 +68,22 @@ class MainWindow(
self._show_node_labels = False
self._show_element_labels = False
self._build_central_canvas()
self._tool_controller = ToolController(self._canvas, self._vm, self)
self._select_tool = SelectTool(self._canvas, self._vm, self)
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
self._draw_node_tool: DrawNodeTool | None = None
self._draw_truss_tool: DrawTrussTool | None = None
# One selection state shared by every canvas backend so a live swap
# keeps the current selection (canvas, tree, table, properties dock).
self._selection = SelectionState(self)
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
self._canvas_backend = self._stored_canvas_backend()
#: Shared, user-editable visual style (both backends read it).
self._style = self._load_plot_style()
self._canvases: dict[str, Any] = {}
self._diagram_renderers: dict[str, DiagramRenderer] = {}
# A stack holds every backend's canvas: swapping is just
# setCurrentWidget, so no backend widget is ever destroyed mid-session.
self._canvas_stack = QStackedWidget(self)
self.setCentralWidget(self._canvas_stack)
self._activate_canvas(self._canvas_backend)
self._build_tooling()
self._build_docks()
self._build_actions()
@ -77,19 +95,164 @@ class MainWindow(
self._refresh_action_enablement()
self.restore_layout()
# ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None:
self._canvas = ModelCanvas(self)
self.setCentralWidget(self._canvas)
# Diagram overlay paints onto the same plotter as the model.
self._diagram_renderer = DiagramRenderer(self._canvas)
# ── canvas backend ───────────────────────────────────────────────
def _stored_canvas_backend(self) -> str:
"""The backend chosen last session, falling back to PyVista."""
stored = self._settings.value("canvas/backend", "pyvista")
name = str(stored) if stored is not None else "pyvista"
if name == "plotly" and plotly_available():
return "plotly"
return "pyvista"
def _ensure_canvas(self, backend: str) -> Any:
"""Create a backend's canvas on first use and cache it.
Both canvases are kept alive and swapped with ``setCentralWidget``
rather than destroyed: tearing a VTK render window down mid-session
leaves dangling make-current callbacks, and the widgets are cheap to
retain once built.
"""
canvas = self._canvases.get(backend)
if canvas is not None:
return canvas
if backend == "plotly":
# Imported lazily so a plotly-less install still boots PyVista.
from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas(
self,
style=self._style,
selection=self._selection,
log=self._log_canvas_message,
)
else:
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
self._canvases[backend] = canvas
return canvas
def _activate_canvas(self, backend: str) -> None:
"""Make the backend's canvas the central widget and rewire overlays."""
if backend not in CANVAS_BACKENDS:
backend = "pyvista"
canvas = self._ensure_canvas(backend)
if self._canvas_stack.indexOf(canvas) < 0:
self._canvas_stack.addWidget(canvas)
self._canvas_stack.setCurrentWidget(canvas)
self._canvas = canvas
self._canvas_backend = backend
if backend == "pyvista":
renderer = self._diagram_renderers.get("pyvista")
if renderer is None:
# Diagram overlay paints onto the same plotter as the model.
renderer = DiagramRenderer(canvas)
self._diagram_renderers["pyvista"] = renderer
self._diagram_renderer = renderer
else:
self._diagram_renderer = None
def canvas_backend(self) -> str:
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
return self._canvas_backend
def _log_canvas_message(self, message: str, severity: str = "info") -> None:
"""Route a JS console line from the Plotly page to the Console dock.
The dock may not exist yet while canvases are being constructed, so
the lookup is guarded.
"""
dock = getattr(self, "_console_dock", None)
if dock is not None:
dock.log(message, severity=severity)
# ── plot style ───────────────────────────────────────────────────
def _load_plot_style(self) -> RenderStyle:
"""Style from the last session, ignoring anything unrecognised."""
raw = self._settings.value("plot/props")
if not raw:
return RenderStyle()
try:
data = json.loads(str(raw))
except ValueError:
return RenderStyle()
if not isinstance(data, dict):
return RenderStyle()
allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
try:
return RenderStyle(**allowed)
except (TypeError, ValueError):
return RenderStyle()
def _save_plot_style(self) -> None:
self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
"""Apply a style to every canvas (live preview when ``persist`` is off)."""
self._style = style
for canvas in self._canvases.values():
canvas.set_style(style)
if persist:
self._save_plot_style()
def _build_tooling(self) -> None:
"""(Re)build the tool controller and tools against the current canvas."""
self._tool_controller = ToolController(self._canvas, self._vm, self)
self._select_tool = SelectTool(self._canvas, self._vm, self)
self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation
self._draw_node_tool: DrawNodeTool | None = None
self._draw_truss_tool: DrawTrussTool | None = None
self._tool_controller.toolChanged.connect(self._on_tool_changed)
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
def swap_canvas_backend(self, backend: str) -> None:
"""Switch the live canvas backend, preserving model/selection state.
The camera pose is intentionally re-framed rather than transferred:
the two engines have different camera models, and a wrong pose is
worse than a clean isometric reset.
"""
if backend not in CANVAS_BACKENDS or backend == self._canvas_backend:
return
if backend == "plotly" and not plotly_available():
return
project = self._vm.project
plane = self._canvas.working_plane_type()
offset = self._canvas.working_plane_offset()
active_name = getattr(self._tool_controller.active, "name", None)
self._activate_canvas(backend)
self._build_tooling()
# Project → re-frames the camera; working plane rebuilds the grid.
self._canvas.show_project(project)
if plane is not None and offset is not None:
self._canvas.set_working_plane(plane, offset)
# Re-apply the overlays whose state lives on the toolbar actions.
self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked())
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
self._canvas.set_display_options(
show_node_labels=self._show_node_labels,
show_element_labels=self._show_element_labels,
)
# Keep the active tool (and its snap preview) alive across the swap.
# Re-entering through the action handlers recreates the tool lazily
# against the new canvas.
if active_name == "Draw Node":
self._on_draw_node_tool()
elif active_name == "Draw Frame":
self._on_draw_frame_tool()
elif active_name == "Draw Truss":
self._on_draw_truss_tool()
self._settings.setValue("canvas/backend", self._canvas_backend)
self._sync_canvas_backend_actions()
self._refresh_action_enablement()
# ── layout persistence ───────────────────────────────────────────
def save_layout(self) -> None:
"""Persist window geometry and dock/toolbar state for next launch."""
settings = QSettings("OTKO", "OTKO")
settings.setValue("geometry", self.saveGeometry())
settings.setValue("windowState", self.saveState())
self._settings.setValue("geometry", self.saveGeometry())
self._settings.setValue("windowState", self.saveState())
def restore_layout(self) -> None:
"""Restore a previously saved geometry/dock layout, if any.
@ -97,15 +260,14 @@ class MainWindow(
On first run no values are stored, so the defaults from ``__init__``
(``resize`` + dock construction) stay in effect.
"""
settings = QSettings("OTKO", "OTKO")
geometry = settings.value("geometry")
geometry = self._settings.value("geometry")
if geometry is not None:
self.restoreGeometry(geometry)
state = settings.value("windowState")
state = self._settings.value("windowState")
if state is not None:
self.restoreState(state)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt override)
def closeEvent(self, event: QCloseEvent) -> None: # (Qt override)
"""Prompt to save unsaved work, then persist the window layout.
The prompt is shown only for a visible window: Qt also closes hidden
@ -147,6 +309,7 @@ class MainWindow(
self._act_about.triggered.connect(self._on_about)
self._act_quick_guide.triggered.connect(self._on_quick_guide)
self._act_set_units.triggered.connect(self._on_set_units)
self._act_plot_properties.triggered.connect(self._on_plot_properties)
# Edit
self._act_delete.triggered.connect(self._on_delete)
@ -161,9 +324,12 @@ class MainWindow(
self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool)
self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_tool)
self._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool)
self._tool_controller.toolChanged.connect(self._on_tool_changed)
# Keep the Tools toolbar checked state in sync with the controller.
self._tool_controller.toolChanged.connect(self._sync_tool_actions)
# toolChanged → toolbar/menu sync is wired in _build_tooling(), which
# also runs on a canvas-backend swap (then a fresh controller exists).
# Canvas backend
self._act_canvas_pyvista.triggered.connect(lambda: self.swap_canvas_backend("pyvista"))
self._act_canvas_plotly.triggered.connect(lambda: self.swap_canvas_backend("plotly"))
# Define
self._act_grid.triggered.connect(self._on_grid_system)

View file

@ -192,9 +192,46 @@ class MenuBuilder:
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
self._act_quick_guide = QAction("&Quick Guide", self)
self._act_set_units = QAction("Set Display &Units…", self)
self._act_plot_properties = QAction("&Plot Properties…", self)
self._act_plot_properties.setToolTip(
"Colours and sizes used by both canvas backends (persisted)."
)
# Canvas backend — exclusive radio pair live-switching the 3D view.
from otko.views.canvas_base import plotly_available
self._canvas_backend_group = QActionGroup(self)
self._canvas_backend_group.setExclusive(True)
self._act_canvas_pyvista = QAction("&PyVista (Native)", self, checkable=True)
self._act_canvas_pyvista.setToolTip(
"Native VTK/OpenGL viewport — fastest for large models."
)
self._act_canvas_plotly = QAction("&Plotly (WebGL)", self, checkable=True)
self._act_canvas_plotly.setToolTip(
"plotly.js rendered in a WebEngine view — richer graphics, higher overhead."
)
self._canvas_backend_group.addAction(self._act_canvas_pyvista)
self._canvas_backend_group.addAction(self._act_canvas_plotly)
if not plotly_available():
self._act_canvas_plotly.setEnabled(False)
self._act_canvas_plotly.setToolTip(
'Install the optional GUI extra (pip install -e ".[gui]") to enable plotly.'
)
self._apply_toolbar_icons()
def _sync_canvas_backend_actions(self) -> None:
"""Mirror the active canvas backend onto the Options radio pair."""
active = getattr(
self,
"_act_canvas_plotly" if self._canvas_backend == "plotly" else "_act_canvas_pyvista",
)
self._canvas_backend_group.blockSignals(True)
try:
active.setChecked(True)
finally:
self._canvas_backend_group.blockSignals(False)
def _apply_toolbar_icons(self) -> None:
"""Assign icons + self-documenting tips to every toolbar action.
@ -479,6 +516,11 @@ class MenuBuilder:
m_options = mb.addMenu("&Options")
m_options.addAction(self._act_set_units)
m_options.addAction(self._act_plot_properties)
m_backend = m_options.addMenu("Canvas &Backend")
m_backend.addAction(self._act_canvas_pyvista)
m_backend.addAction(self._act_canvas_plotly)
self._sync_canvas_backend_actions()
m_help = mb.addMenu("&Help")
m_help.addAction(self._act_quick_guide)
@ -489,7 +531,7 @@ class MenuBuilder:
tb.setMovable(True)
tb.setIconSize(QSize(24, 24))
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
# Top edge; Tools stays in the left area independently (built next).
# Top edge; Tools shares the top area (built next).
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
self._view_toolbar = tb
tb.addAction(self._act_zoom_extents)
@ -535,9 +577,8 @@ class MenuBuilder:
tb.setMovable(True)
tb.setIconSize(QSize(24, 24))
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
# View lives on the top edge, so Tools stays left independently
# in its own left-area column.
self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, tb)
# Top edge alongside the View toolbar (built just before).
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
self._tools_toolbar = tb
tb.addAction(self._act_tool_select)
tb.addAction(self._act_tool_draw_node)

View file

@ -50,6 +50,23 @@ class RenderControls:
)
# ── SAP2000-style view switching + working level ───────────────
def _on_plot_properties(self) -> None:
"""Edit the shared visual style.
Edits preview live on both canvases; Cancel restores the style the
dialog opened with, and only OK persists to ``QSettings``.
"""
from otko.views.dialogs import PlotPropertiesDialog
original = self._style
dialog = PlotPropertiesDialog(original, self)
dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False))
if dialog.exec() == QDialog.DialogCode.Accepted:
self.set_plot_style(dialog.plot_style())
self._log("Plot properties updated.")
else:
self.set_plot_style(original, persist=False)
def _on_toggle_parallel(self, on: bool) -> None:
cam = self._canvas.camera
cam.parallel_projection = on
@ -459,6 +476,7 @@ class RenderControls:
has_pushover = isinstance(self._latest_results, PushoverResults)
has_rs = isinstance(self._latest_results, ResponseSpectrumResults)
n_sel = len(self._canvas.selection.nodes)
caps = self._canvas.capabilities
self._act_save.setEnabled(has_project)
self._act_save_as.setEnabled(has_project)
self._act_export_opspy.setEnabled(has_project)
@ -482,9 +500,11 @@ class RenderControls:
self._act_tool_draw_truss.setEnabled(has_project)
self._act_show_deformed.setEnabled(has_static)
self._act_show_mode_shape.setEnabled(has_modal)
self._act_show_force_diagram.setEnabled(has_static)
# Force diagrams + video export are backend capabilities, not just
# data availability (Plotly cannot do either yet).
self._act_show_force_diagram.setEnabled(has_static and caps.diagrams)
self._act_show_time_history.setEnabled(has_transient)
self._act_export_th_animation.setEnabled(has_transient)
self._act_export_th_animation.setEnabled(has_transient and caps.animation_export)
self._act_show_hysteresis.setEnabled(has_transient)
self._act_show_pushover.setEnabled(has_pushover)
self._act_show_response_spectrum.setEnabled(has_rs)

View file

@ -35,8 +35,8 @@ class CanvasTool(QObject):
def __init__(
self,
canvas: "ModelCanvas",
vm: "ProjectViewModel",
canvas: ModelCanvas,
vm: ProjectViewModel,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
@ -87,7 +87,7 @@ class ToolController(QObject):
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
def __init__(
self, canvas: "ModelCanvas", vm: "ProjectViewModel", parent: QObject | None = None
self, canvas: ModelCanvas, vm: ProjectViewModel, parent: QObject | None = None
) -> None:
super().__init__(parent)
self._canvas = canvas

View file

@ -41,9 +41,9 @@ class DrawFrameTool(LinearElementDrawTool):
def __init__(
self,
canvas: "ModelCanvas",
vm: "ProjectViewModel",
parent: "QObject | None" = None,
canvas: ModelCanvas,
vm: ProjectViewModel,
parent: QObject | None = None,
) -> None:
super().__init__(canvas, vm, parent)

View file

@ -115,9 +115,9 @@ class DrawNodeTool(CanvasTool):
def __init__(
self,
canvas: "ModelCanvas",
vm: "ProjectViewModel",
parent: "QObject | None" = None,
canvas: ModelCanvas,
vm: ProjectViewModel,
parent: QObject | None = None,
) -> None:
super().__init__(canvas, vm, parent)

View file

@ -37,9 +37,9 @@ class DrawTrussTool(LinearElementDrawTool):
def __init__(
self,
canvas: "ModelCanvas",
vm: "ProjectViewModel",
parent: "QObject | None" = None,
canvas: ModelCanvas,
vm: ProjectViewModel,
parent: QObject | None = None,
) -> None:
super().__init__(canvas, vm, parent)

View file

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

View file

@ -6,12 +6,12 @@ import pytest
pytest.importorskip("PySide6")
from otko.commands import ( # noqa: E402
from otko.commands import (
AddAnalysisCasesCommand,
DeleteAnalysisCasesCommand,
UpdateAnalysisCaseCommand,
)
from otko.core import ( # noqa: E402
from otko.core import (
LinearTimeSeries,
ModalCase,
NodalLoad,
@ -20,7 +20,7 @@ from otko.core import ( # noqa: E402
StaticCase,
TransientCase,
)
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.viewmodels import ProjectViewModel
def _vm_with_pattern() -> ProjectViewModel:

View file

@ -12,21 +12,21 @@ import pytest
pytest.importorskip("PySide6")
pytest.importorskip("openseespy.opensees")
from otko.commands import ( # noqa: E402
from otko.commands import (
AddAnalysisCasesCommand,
AddElementsCommand,
AddNodalLoadsCommand,
AddNodesCommand,
AddSectionsCommand,
)
from otko.core import ( # noqa: E402
from otko.core import (
ElasticBeamColumn,
ElasticSection,
Node,
StaticCase,
)
from otko.services.results import StaticResults # noqa: E402
from otko.viewmodels import AnalysisRunner, ProjectViewModel # noqa: E402
from otko.services.results import StaticResults
from otko.viewmodels import AnalysisRunner, ProjectViewModel
@pytest.fixture

View file

@ -6,15 +6,14 @@ import pytest
pytest.importorskip("PySide6")
from otko.commands import AddNodalLoadsCommand # noqa: E402
from otko.core import ( # noqa: E402
from otko.commands import AddNodalLoadsCommand
from otko.core import (
LinearTimeSeries,
Node,
PlainLoadPattern,
Project,
)
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.views.dialogs.assign_load import AssignLoadDialog # noqa: E402
from otko.viewmodels import ProjectViewModel
from otko.views.dialogs.assign_load import AssignLoadDialog
@pytest.mark.gui

View file

@ -6,7 +6,7 @@ import pytest
pytest.importorskip("PySide6")
from otko.views.dialogs.assign_masses import AssignMassesDialog # noqa: E402
from otko.views.dialogs.assign_masses import AssignMassesDialog
@pytest.mark.gui

View file

@ -6,12 +6,12 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import ( # noqa: E402
from otko.core import (
ElasticSection,
Node,
Project,
)
from otko.views.dialogs.assign_zls import ( # noqa: E402
from otko.views.dialogs.assign_zls import (
AssignZeroLengthSectionDialog,
)

View file

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

View file

@ -12,7 +12,7 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import ( # noqa: E402
from otko.core import (
CoordinateGridSystem,
CoordinateSystem,
GridSystem,

View file

@ -6,13 +6,13 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import ( # noqa: E402
from otko.core import (
NodalLoad,
PlainLoadPattern,
StaticCase,
TransientCase,
)
from otko.views.dialogs.case_forms import TransientCaseForm # noqa: E402
from otko.views.dialogs.case_forms import TransientCaseForm
def _patterns(): # type: ignore[no-untyped-def]

View file

@ -11,15 +11,14 @@ import pytest
pytest.importorskip("PySide6")
from otko.commands import ( # noqa: E402
from otko.commands import (
AddElementsCommand,
AddNodalLoadsCommand,
AddNodesCommand,
DeleteElementsCommand,
DeleteNodesCommand,
SetRestraintCommand,
)
from otko.core import ( # noqa: E402
from otko.core import (
LinearTimeSeries,
NodalLoad,
Node,
@ -27,7 +26,7 @@ from otko.core import ( # noqa: E402
Steel01,
TrussElement,
)
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.viewmodels import ProjectViewModel
# ─────────────────────────── helpers ────────────────────────────────

View file

@ -6,21 +6,21 @@ import pytest
pytest.importorskip("PySide6")
from otko.commands import ( # noqa: E402
from otko.commands import (
AddElementsCommand,
AddMaterialsCommand,
AddNodesCommand,
AddSectionsCommand,
ConvertElementTypeCommand,
)
from otko.core import ( # noqa: E402
from otko.core import (
ElasticBeamColumn,
ElasticSection,
ElasticUniaxial,
Node,
TrussElement,
)
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.viewmodels import ProjectViewModel
def _vm_setup() -> ProjectViewModel:

View file

@ -6,9 +6,9 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import LinearTimeSeries, Project # noqa: E402
from otko.views.dialogs.linear_time_series import LinearTimeSeriesDialog # noqa: E402
from otko.views.dialogs.plain_pattern import PlainPatternDialog # noqa: E402
from otko.core import LinearTimeSeries, Project
from otko.views.dialogs.linear_time_series import LinearTimeSeriesDialog
from otko.views.dialogs.plain_pattern import PlainPatternDialog
@pytest.mark.gui

View file

@ -11,11 +11,11 @@ import pytest
pytest.importorskip("PySide6")
from otko.commands import AddNodesCommand # noqa: E402
from otko.core import ElasticBeamColumn, ElasticSection, Node # noqa: E402
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.views.canvas3d import SelectionState # noqa: E402
from otko.views.tools.draw_frame import DrawFrameTool # noqa: E402
from otko.commands import AddNodesCommand
from otko.core import ElasticBeamColumn, ElasticSection, Node
from otko.viewmodels import ProjectViewModel
from otko.views.canvas3d import SelectionState
from otko.views.tools.draw_frame import DrawFrameTool
class _CanvasStub:

View file

@ -10,10 +10,10 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import GridSystem, Node, Project # noqa: E402
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.views.canvas3d.selection import SelectionState # noqa: E402
from otko.views.tools.draw_node import ( # noqa: E402
from otko.core import GridSystem, Node
from otko.viewmodels import ProjectViewModel
from otko.views.canvas3d.selection import SelectionState
from otko.views.tools.draw_node import (
DrawNodeTool,
_snap_to_grid,
)

View file

@ -6,18 +6,17 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import ( # noqa: E402
from otko.core import (
CoordinateGridSystem,
CoordinateSystem,
ElasticUniaxial,
GridSystem,
Node,
TrussElement,
make_grid_lines,
)
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.views.canvas3d.selection import SelectionState # noqa: E402
from otko.views.tools.draw_truss import DrawTrussTool # noqa: E402
from otko.viewmodels import ProjectViewModel
from otko.views.canvas3d.selection import SelectionState
from otko.views.tools.draw_truss import DrawTrussTool
class _CanvasStub:

View file

@ -6,7 +6,7 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import ( # noqa: E402
from otko.core import (
CoordinateGridSystem,
CoordinateSystem,
GridLine,

View file

@ -6,10 +6,10 @@ import pytest
pytest.importorskip("PySide6")
from otko.core import Concrete04 # noqa: E402
from otko.views.dialogs.material_forms import ( # noqa: E402
Concrete04Form,
from otko.core import Concrete04
from otko.views.dialogs.material_forms import (
FORM_REGISTRY,
Concrete04Form,
)

View file

@ -10,7 +10,7 @@ import pytest
pytest.importorskip("PySide6")
from otko.views.dialogs.material_forms import ( # noqa: E402
from otko.views.dialogs.material_forms import (
Concrete01Form,
Concrete02Form,
Steel01Form,

View file

@ -8,17 +8,16 @@ import pytest
pytest.importorskip("PySide6")
from otko.commands import ( # noqa: E402
from otko.commands import (
AddLoadPatternCommand,
AddTimeSeriesCommand,
)
from otko.core import ( # noqa: E402
Node,
from otko.core import (
PathTimeSeries,
Project,
UniformExcitationPattern,
)
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.viewmodels import ProjectViewModel
# ─────────────── AddTimeSeriesCommand / AddLoadPatternCommand ──────────────

View file

@ -0,0 +1,131 @@
"""GUI tests for Options → Plot Properties.
Covers the dialog itself plus MainWindow's live-apply / persist / revert
wiring. QSettings are injected so nothing touches real user state.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6")
pytest.importorskip("pyvistaqt")
from PySide6.QtCore import QObject, QSettings, Signal
from PySide6.QtWidgets import QDialog
def _settings(tmp_path): # type: ignore[no-untyped-def]
return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
def _window(tmp_path, qtbot): # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
window = MainWindow(settings=_settings(tmp_path))
qtbot.addWidget(window)
return window
def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.style import RenderStyle
window = _window(tmp_path, qtbot)
assert window._style == RenderStyle()
def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
window.swap_canvas_backend("plotly") # build the second canvas too
window.set_plot_style(
window._style.with_updates(element_beam_color="#ff0000", label_font_size=20)
)
assert set(window._canvases) == {"pyvista", "plotly"}
for canvas in window._canvases.values():
assert canvas._style.element_beam_color == "#ff0000"
assert canvas._style.label_font_size == 20
# A fresh window sharing the settings file reloads the customisation.
from otko.views.main_window import MainWindow
fresh = MainWindow(settings=_settings(tmp_path))
qtbot.addWidget(fresh)
assert fresh._style.element_beam_color == "#ff0000"
assert fresh._style.label_font_size == 20
def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False)
assert window._canvas._style.node_color == "#010203"
assert window._settings.value("plot/props") is None
def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
original = window._style
preview = original.with_updates(node_color="#010203")
class _RejectingDialog(QObject):
changed = Signal(object)
def __init__(self, style: object, parent: QObject | None = None) -> None:
super().__init__(parent)
self._style = preview
def exec(self) -> QDialog.DialogCode:
self.changed.emit(preview) # live preview happens...
return QDialog.DialogCode.Rejected # ...but the user cancels
def plot_style(self): # type: ignore[no-untyped-def]
return self._style
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog)
window._on_plot_properties()
assert window._style == original
assert window._canvas._style.node_color == original.node_color
assert window._settings.value("plot/props") is None
def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
window = _window(tmp_path, qtbot)
preview = window._style.with_updates(node_color="#010203")
class _AcceptingDialog(QObject):
changed = Signal(object)
def __init__(self, style: object, parent: QObject | None = None) -> None:
super().__init__(parent)
self._style = preview
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def plot_style(self): # type: ignore[no-untyped-def]
return self._style
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog)
window._on_plot_properties()
assert window._style.node_color == "#010203"
assert window._canvas._style.node_color == "#010203"
assert "#010203" in str(window._settings.value("plot/props"))
def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.style import RenderStyle
from otko.views.dialogs import PlotPropertiesDialog
dialog = PlotPropertiesDialog(RenderStyle())
qtbot.addWidget(dialog)
assert dialog.plot_style() == RenderStyle()
dialog._colors["node_color"].set_color("#abcdef")
assert dialog.plot_style().node_color == "#abcdef"
dialog._reset()
assert dialog.plot_style() == RenderStyle()

Some files were not shown because too many files have changed in this diff Show more