diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56068ce..2c834e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c82640e..eac3bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..32dbf4f --- /dev/null +++ b/.ignore @@ -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 diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json new file mode 100644 index 0000000..ddddcab --- /dev/null +++ b/.slim/clonedeps.json @@ -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)." + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index bdc5f2e..babac38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ ## Install ```bash -pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools +pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends) python -m otko # launch GUI (src/otko/app.py:run) ``` @@ -19,6 +19,8 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`. - `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.** - `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.** - `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service. +- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`. +- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`. - `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses. - Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5). @@ -56,3 +58,17 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git - `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip). - Projects persist as single Pydantic-validated JSON `.osmodel` (diffable); analysis output goes to `*.osresults.h5` (HDF5, one group per case). - Quick smoke: open `examples/cantilever.osmodel` → run `Tip-Load` → M3 peaks 50 kN·m at fixed end. + +## Cloned Dependency Source + +Read-only dependency source repositories are available under +`.slim/clonedeps/repos/` for inspection. Do not edit these clones. The +structured manifest is `.slim/clonedeps.json`. + +- `.slim/clonedeps/repos/yexiang92__opstool/` — `yexiang92/opstool` at `v1.0.26`; the OpenSeesPy + pre/post-processor whose PyVista and Plotly visualization settings + (`opstool/vis/{pyvista,plotly}/plot_utils.py`, `plot_resp_base.py`, `vis_model.py`) are the + reference for otko's canvas look-and-feel. GPL-3.0, and GPLv3 §13 explicitly permits combining + it with otko's AGPL-3.0. Any code actually ported from it must keep the opstool copyright notice + and record that it was modified (GPLv3 §5a/b) — add that entry to `NOTICE` when the port lands. + diff --git a/NOTICE b/NOTICE index 10a928c..24b2dd1 100644 --- a/NOTICE +++ b/NOTICE @@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Ported / adapted code: `opstool` + +The canvas element colour palette in `src/otko/views/canvas3d/style.py` +(per-family element colours and the diverging response colour scale) and the +resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were +adapted from the `opstool` project, which is distributed under the **GNU +General Public License v3.0**. opstool is Copyright © Yexiang Yan and +contributors. + +In accordance with GPLv3 §5(a)/(b) this notice records that the material was +modified and adapted for OTKO. Combining the GPLv3-covered material with +OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is +conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the +opstool-derived portions. + ## Runtime dependencies OTKO depends on third-party software that is not covered by OTKO's diff --git a/pyproject.toml b/pyproject.toml index 5e1e4bf..d67c45f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/otko/commands/__init__.py b/src/otko/commands/__init__.py index 82ccef5..eddf86f 100644 --- a/src/otko/commands/__init__.py +++ b/src/otko/commands/__init__.py @@ -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, diff --git a/src/otko/commands/analysis.py b/src/otko/commands/analysis.py index 5950b40..444f34c 100644 --- a/src/otko/commands/analysis.py +++ b/src/otko/commands/analysis.py @@ -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 diff --git a/src/otko/commands/base.py b/src/otko/commands/base.py index 15f616f..0e8b3dd 100644 --- a/src/otko/commands/base.py +++ b/src/otko/commands/base.py @@ -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 diff --git a/src/otko/commands/combinations.py b/src/otko/commands/combinations.py index 0af9a08..23f1aa8 100644 --- a/src/otko/commands/combinations.py +++ b/src/otko/commands/combinations.py @@ -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 diff --git a/src/otko/commands/constraints.py b/src/otko/commands/constraints.py index e3df65a..3f94205 100644 --- a/src/otko/commands/constraints.py +++ b/src/otko/commands/constraints.py @@ -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]] = [] diff --git a/src/otko/commands/elements.py b/src/otko/commands/elements.py index 6487a9d..f1967ba 100644 --- a/src/otko/commands/elements.py +++ b/src/otko/commands/elements.py @@ -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 diff --git a/src/otko/commands/grid.py b/src/otko/commands/grid.py index 894c56b..e271746 100644 --- a/src/otko/commands/grid.py +++ b/src/otko/commands/grid.py @@ -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 diff --git a/src/otko/commands/loads.py b/src/otko/commands/loads.py index d24fabf..789b6c8 100644 --- a/src/otko/commands/loads.py +++ b/src/otko/commands/loads.py @@ -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, diff --git a/src/otko/commands/materials.py b/src/otko/commands/materials.py index 836ade9..9729ced 100644 --- a/src/otko/commands/materials.py +++ b/src/otko/commands/materials.py @@ -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 diff --git a/src/otko/commands/nodes.py b/src/otko/commands/nodes.py index b737265..e24840c 100644 --- a/src/otko/commands/nodes.py +++ b/src/otko/commands/nodes.py @@ -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: diff --git a/src/otko/commands/project.py b/src/otko/commands/project.py index 362e2a6..3fd0065 100644 --- a/src/otko/commands/project.py +++ b/src/otko/commands/project.py @@ -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 diff --git a/src/otko/commands/sections.py b/src/otko/commands/sections.py index e75a54f..94142a6 100644 --- a/src/otko/commands/sections.py +++ b/src/otko/commands/sections.py @@ -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 diff --git a/src/otko/commands/series_and_patterns.py b/src/otko/commands/series_and_patterns.py index 1d1062c..1d03ab5 100644 --- a/src/otko/commands/series_and_patterns.py +++ b/src/otko/commands/series_and_patterns.py @@ -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]] = [] diff --git a/src/otko/commands/transforms.py b/src/otko/commands/transforms.py index 3e48448..07c335d 100644 --- a/src/otko/commands/transforms.py +++ b/src/otko/commands/transforms.py @@ -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, diff --git a/src/otko/core/analysis/__init__.py b/src/otko/core/analysis/__init__.py index b809bad..e8835b7 100644 --- a/src/otko/core/analysis/__init__.py +++ b/src/otko/core/analysis/__init__.py @@ -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"), ] diff --git a/src/otko/core/geometry/elements.py b/src/otko/core/geometry/elements.py index f79ae39..35a2026 100644 --- a/src/otko/core/geometry/elements.py +++ b/src/otko/core/geometry/elements.py @@ -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"), ] diff --git a/src/otko/core/geometry/grid.py b/src/otko/core/geometry/grid.py index cf6ecc7..f7d61ea 100644 --- a/src/otko/core/geometry/grid.py +++ b/src/otko/core/geometry/grid.py @@ -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) diff --git a/src/otko/core/loads/__init__.py b/src/otko/core/loads/__init__.py index 283dfad..68a1557 100644 --- a/src/otko/core/loads/__init__.py +++ b/src/otko/core/loads/__init__.py @@ -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"), ] diff --git a/src/otko/core/materials/__init__.py b/src/otko/core/materials/__init__.py index be71418..daa84f6 100644 --- a/src/otko/core/materials/__init__.py +++ b/src/otko/core/materials/__init__.py @@ -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.""" diff --git a/src/otko/core/project.py b/src/otko/core/project.py index 175f7a4..f2d030a 100644 --- a/src/otko/core/project.py +++ b/src/otko/core/project.py @@ -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), diff --git a/src/otko/core/sections/__init__.py b/src/otko/core/sections/__init__.py index 595b09b..530e0ca 100644 --- a/src/otko/core/sections/__init__.py +++ b/src/otko/core/sections/__init__.py @@ -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"), ] diff --git a/src/otko/services/animation_export.py b/src/otko/services/animation_export.py index 20c3f53..fa80790 100644 --- a/src/otko/services/animation_export.py +++ b/src/otko/services/animation_export.py @@ -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 diff --git a/src/otko/services/export.py b/src/otko/services/export.py index 0f3a22c..5886360 100644 --- a/src/otko/services/export.py +++ b/src/otko/services/export.py @@ -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) diff --git a/src/otko/services/peer_record.py b/src/otko/services/peer_record.py index b6f6e6c..ce99a54 100644 --- a/src/otko/services/peer_record.py +++ b/src/otko/services/peer_record.py @@ -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 diff --git a/src/otko/services/section_bbox.py b/src/otko/services/section_bbox.py index b3438b8..18f5a20 100644 --- a/src/otko/services/section_bbox.py +++ b/src/otko/services/section_bbox.py @@ -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. diff --git a/src/otko/services/section_properties.py b/src/otko/services/section_properties.py index a38d4e4..a637428 100644 --- a/src/otko/services/section_properties.py +++ b/src/otko/services/section_properties.py @@ -21,7 +21,6 @@ import numpy as np from otko.core.sections import ( CircularPatch, FiberSection, - Fibre, RectangularPatch, StraightLayer, ) diff --git a/src/otko/viewmodels/project_vm.py b/src/otko/viewmodels/project_vm.py index 6ed5dbd..9ea06d1 100644 --- a/src/otko/viewmodels/project_vm.py +++ b/src/otko/viewmodels/project_vm.py @@ -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 diff --git a/src/otko/views/action_handlers.py b/src/otko/views/action_handlers.py index bcd9305..4352b00 100644 --- a/src/otko/views/action_handlers.py +++ b/src/otko/views/action_handlers.py @@ -47,9 +47,9 @@ from otko.views.dialogs import ( AssignSectionDialog, AssignSupportDialog, AssignZeroLengthSectionDialog, - LoadCombinationManagerDialog, CoordinateGridSystemsDialog, LinearTimeSeriesDialog, + LoadCombinationManagerDialog, MaterialLibraryDialog, MaterialTesterDialog, MirrorDialog, diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py index 28b6f82..57c0a70 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -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 ───────────────────────────────────────────────────── diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index b1b3417..3e00425 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -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( diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 568c079..12c88cb 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -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", diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index f25d674..a55d43b 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -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} diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py new file mode 100644 index 0000000..0352961 --- /dev/null +++ b/src/otko/views/canvas_base.py @@ -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: ... diff --git a/src/otko/views/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py new file mode 100644 index 0000000..7fabb6d --- /dev/null +++ b/src/otko/views/canvas_plotly/__init__.py @@ -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}") diff --git a/src/otko/views/canvas_plotly/bridge.py b/src/otko/views/canvas_plotly/bridge.py new file mode 100644 index 0000000..9dafbce --- /dev/null +++ b/src/otko/views/canvas_plotly/bridge.py @@ -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)) diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py new file mode 100644 index 0000000..46e145e --- /dev/null +++ b/src/otko/views/canvas_plotly/html.py @@ -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 = """ + +
+ + + + + + + + + + +""" + + +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() diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py new file mode 100644 index 0000000..ddecb15 --- /dev/null +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -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" diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py new file mode 100644 index 0000000..fa5fc94 --- /dev/null +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -0,0 +1,1028 @@ +"""Project → Plotly figure dictionaries. + +Pure data transformation: no Qt, no pyvista, no plotly import. The +:class:`PlotlyCanvas` serialises the returned :class:`Scene` to JSON and +hands it to plotly.js; the unit tests exercise it headless. + +Geometry conventions mirror :mod:`otko.views.canvas3d.model_renderer` so the +two backends draw the same model, but the output medium differs: + +- Nodes / supports / labels are ``scatter3d`` markers, whose size is in + *pixels* (plotly.js does not scale markers with world units). +- Frames are ``scatter3d`` line traces. Plotly cannot colour individual + segments of one line trace, so selection is expressed as two traces + (normal + selected) whose segment lists are regrouped on every update. +- Loads and local axes are ``cone`` traces (plotly.js has no 3D arrow glyph). +- Section extrusions are ``mesh3d`` box sweeps from + :func:`otko.services.section_bbox.bbox_for_section` — the same bbox + fallback the PyVista backend uses when a shape hint is not an exact fit. + +Picking rides on ``customdata`` + trace ``meta``: every pickable trace is +tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads +``point.data.meta.kind`` to decide what the click meant. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from otko.core import ( + BeamWithHingesElement, + CorotTrussElement, + DispBeamColumn, + ElasticBeamColumn, + ForceBeamColumn, + NodalLoad, + PlainLoadPattern, + Project, + QuadElement, + TrussElement, + UniformElementLoad, + ZeroLengthElement, + ZeroLengthSectionElement, +) +from otko.views.canvas3d.style import ( + RenderStyle, + element_family_index, + family_palette, +) + +_FRAME_CLASSES = ( + ElasticBeamColumn, + DispBeamColumn, + ForceBeamColumn, + BeamWithHingesElement, + TrussElement, + CorotTrussElement, + ZeroLengthElement, +) + +#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`. +_BOX_TRIS = ( + (0, 1, 2), + (0, 2, 3), + (4, 5, 6), + (4, 6, 7), + (0, 1, 5), + (0, 5, 4), + (3, 2, 6), + (3, 6, 7), + (0, 3, 7), + (0, 7, 4), + (1, 2, 6), + (1, 6, 5), +) + +#: Support kind → plotly 3D marker symbol. +_SUPPORT_SYMBOLS = { + "fix": "square", + "pin": "triangle-up", + "roller": "circle", + "custom": "diamond", +} + +_NODE_MARKER_SIZE = 7.0 +_SUPPORT_MARKER_SIZE = 11.0 +_SNAP_MARKER_SIZE = 8.0 + + +@dataclass(frozen=True) +class SceneOptions: + """Everything the builder needs beyond the project itself.""" + + selection_nodes: frozenset[int] = frozenset() + selection_elements: frozenset[int] = frozenset() + #: Anything exposing ``shifted(points, node_ids)`` (e.g. ``DeformationSource``). + deformation: Any = None + working_plane: tuple[str, float] | None = None + show_node_labels: bool = False + show_element_labels: bool = False + show_extrusions: bool = False + show_local_axes: bool = False + + +@dataclass +class Scene: + """A transport-ready plotly figure plus framing metadata.""" + + data: list[dict[str, Any]] + layout: dict[str, Any] + center: tuple[float, float, float] = (0.0, 0.0, 0.0) + diagonal: float = 1.0 + #: Trace index (in ``data``) of the empty hover-snap marker, or -1. + hover_trace: int = -1 + #: Padded ``(min, max)`` per axis, used to frame the camera deterministically. + axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict) + + def to_payload(self) -> dict[str, Any]: + """Figure dict without the camera — camera is owned by the widget.""" + return {"data": self.data, "layout": self.layout} + + def axis_overrides(self) -> dict[str, Any]: + """Nested ``scene`` range pins for the framed data window. + + Only pushed together with the camera (view presets / new project), so + the user's own zoom and pan survive ordinary data updates. The padding + comes from opstool's scene recipe (``pad_ratio`` 0.15). + """ + return { + f"{name}axis": {"range": [low, high], "autorange": False} + for name, (low, high) in self.axis_bounds.items() + } + + +@dataclass +class _Mesh: + """Accumulates a triangle soup for a single ``mesh3d`` trace.""" + + x: list[float] = field(default_factory=list) + y: list[float] = field(default_factory=list) + z: list[float] = field(default_factory=list) + i: list[int] = field(default_factory=list) + j: list[int] = field(default_factory=list) + k: list[int] = field(default_factory=list) + + def add_box(self, corners: np.ndarray) -> None: + base = len(self.x) + for cx, cy, cz in corners: + self.x.append(float(cx)) + self.y.append(float(cy)) + self.z.append(float(cz)) + for a, b, c in _BOX_TRIS: + self.i.append(base + a) + self.j.append(base + b) + self.k.append(base + c) + + @property + def is_empty(self) -> bool: + return not self.x + + def as_trace(self, *, color: str, opacity: float, name: str) -> dict[str, Any]: + return { + "type": "mesh3d", + "x": self.x, + "y": self.y, + "z": self.z, + "i": self.i, + "j": self.j, + "k": self.k, + "color": color, + "opacity": opacity, + "flatshading": True, + "hoverinfo": "skip", + "name": name, + "showscale": False, + } + + +# ── small geometry helpers ──────────────────────────────────────────────── +def _diag_of_points(pts: np.ndarray | None) -> float: + if pts is None or len(pts) == 0: + return 1.0 + mn, mx = pts.min(axis=0), pts.max(axis=0) + d = float(np.linalg.norm(mx - mn)) + return d if d > 0 else 1.0 + + +#: Padding fraction added around the model when framing the view. +#: From opstool's scene recipe (``pad_ratio=0.15`` for model views). +_PAD_RATIO = 0.15 + + +def _padded_axis_bounds(pts: np.ndarray | None) -> dict[str, tuple[float, float]]: + """Padded ``(min, max)`` per axis; degenerate axes get unit slack.""" + if pts is None or len(pts) == 0: + return {name: (-1.0, 1.0) for name in ("x", "y", "z")} + lower = np.asarray(pts, dtype=float).min(axis=0) + upper = np.asarray(pts, dtype=float).max(axis=0) + pad = (upper - lower) * _PAD_RATIO + bounds: dict[str, tuple[float, float]] = {} + for index, name in enumerate(("x", "y", "z")): + low = float(lower[index] - pad[index]) + high = float(upper[index] + pad[index]) + if high - low < 1e-9: # planar / single-point model + low, high = low - 1.0, high + 1.0 + bounds[name] = (low, high) + return bounds + + +def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float: + """Padded bounding-box diagonal — the framing distance yardstick.""" + diagonal = float(np.linalg.norm([high - low for low, high in bounds.values()])) + return diagonal if diagonal > 0 else 1.0 + + +def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``.""" + x = x_local / float(np.linalg.norm(x_local)) + vecxz = getattr(el, "vecxz", None) + if vecxz is not None: + try: + v = np.asarray(vecxz, dtype=float) + z_local = v - float(np.dot(v, x)) * x + n = float(np.linalg.norm(z_local)) + if n > 1e-9: + z_local /= n + y_local = np.cross(z_local, x) + m = float(np.linalg.norm(y_local)) + if m > 1e-9: + return y_local / m, z_local + except (TypeError, ValueError): + pass + z_global = np.array([0.0, 0.0, 1.0]) + y_local = np.cross(z_global, x) + if float(np.linalg.norm(y_local)) < 1e-6: + y_local = np.cross(np.array([1.0, 0.0, 0.0]), x) + y_local /= float(np.linalg.norm(y_local)) + z_local = np.cross(x, y_local) + return y_local, z_local + + +def _dof_indices(ndf: int) -> tuple[int, ...]: + if ndf == 6: + return (0, 1, 2, 3, 4, 5) + if ndf == 3: + return (0, 1, 5) + if ndf == 2: + return (0, 1) + return tuple(range(ndf)) + + +def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) -> str: + flags = [restraint[i] for i in dof_idx] + if all(flags): + return "fix" + trans_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx < 3] + rot_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx >= 3] + if trans_flags and all(trans_flags) and not any(rot_flags): + return "pin" + if sum(flags) == 1: + return "roller" + return "custom" + + +def _line_trace( + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], + *, + color: str, + width: float, + name: str, + opacity: float = 1.0, + meta: dict[str, Any] | None = None, + customdata: list[Any] | None = None, +) -> dict[str, Any]: + """One ``scatter3d`` line trace from None-separated segment endpoints.""" + x: list[float | None] = [] + y: list[float | None] = [] + z: list[float | None] = [] + for a, b in segments: + x.extend([float(a[0]), float(b[0]), None]) + y.extend([float(a[1]), float(b[1]), None]) + z.extend([float(a[2]), float(b[2]), None]) + trace: dict[str, Any] = { + "type": "scatter3d", + "mode": "lines", + "x": x, + "y": y, + "z": z, + "line": {"color": color, "width": width}, + "opacity": opacity, + "hoverinfo": "skip", + "name": name, + "showlegend": False, + } + if meta is not None: + trace["meta"] = meta + if customdata is not None: + trace["customdata"] = customdata + return trace + + +class PlotlyTraceBuilder: + """Builds the full figure for a project.""" + + def __init__(self, style: RenderStyle | None = None) -> None: + self._style = style or RenderStyle() + + def set_style(self, style: RenderStyle) -> None: + """Swap the visual style; the next :meth:`build` picks it up.""" + self._style = style + + # ── public ─────────────────────────────────────────────────────── + def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene: + opts = options or SceneOptions() + if project is None: + return Scene(data=[], layout=self._layout()) + + nodes = list(project.nodes) + node_ids = [n.id for n in nodes] + points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3)) + if opts.deformation is not None and len(points): + points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float) + node_row = {nid: i for i, nid in enumerate(node_ids)} + + data: list[dict[str, Any]] = [] + grid_pts = self._build_grid(project, data, opts) + self._build_extrusions(project, data, opts) + self._build_local_axes(project, data, opts) + self._build_loads(project, data, opts) + self._build_supports(project, data, opts) + self._build_frames(project, data, opts, points, node_row) + self._build_nodes(data, opts, points, node_ids) + self._build_labels(project, data, opts, points, node_row) + hover_trace = self._build_hover_marker(data) + + candidates = [points] if len(points) else [] + if grid_pts is not None: + candidates.append(grid_pts) + all_pts = np.vstack(candidates) if candidates else np.empty((0, 3)) + center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0) + bounds = _padded_axis_bounds(all_pts) + return Scene( + data=data, + layout=self._layout(), + center=(float(center[0]), float(center[1]), float(center[2])), + diagonal=_diag_of_bounds(bounds), + hover_trace=hover_trace, + axis_bounds=bounds, + ) + + # ── layout ─────────────────────────────────────────────────────── + def _layout(self) -> dict[str, Any]: + style = self._style + outline = style.show_axis_outline + + def axis(color: str, title: str) -> dict[str, Any]: + # Coloured axis lines + titles stay visible as the orientation cue + # (plotly has no corner triad); the grid/ticks are the optional + # "outline" from opstool's scene recipe. + return { + "title": {"text": title, "font": {"color": color, "size": 12}}, + "showgrid": outline, + "showbackground": False, + "zeroline": outline, + "showticklabels": outline, + "showspikes": False, + "visible": True, + "linecolor": color, + "linewidth": 2, + } + + return { + "paper_bgcolor": style.background_bottom, + "plot_bgcolor": style.background_bottom, + "showlegend": False, + "margin": {"l": 0, "r": 0, "t": 0, "b": 0}, + "uirevision": "otko", + "scene": { + "bgcolor": style.background_bottom, + "aspectmode": "data", + "dragmode": "orbit", + "xaxis": axis(style.fix_color, "X"), + "yaxis": axis(style.load_color, "Y"), + "zaxis": axis(style.truss_color, "Z"), + }, + } + + # ── grid ───────────────────────────────────────────────────────── + def _build_grid( + self, project: Project, data: list[dict[str, Any]], opts: SceneOptions + ) -> np.ndarray | None: + coord_systems = getattr(project, "coord_systems", None) or [] + palette = [ + ((0.08, 0.08, 0.08), (0.85, 0.55, 0.00)), + ((0.20, 0.35, 0.55), (0.85, 0.55, 0.00)), + ((0.20, 0.55, 0.30), (0.85, 0.55, 0.00)), + ((0.55, 0.20, 0.40), (0.85, 0.55, 0.00)), + ((0.35, 0.20, 0.55), (0.85, 0.55, 0.00)), + ] + all_dots: list[np.ndarray] = [] + + for idx, cs in enumerate(coord_systems): + grid = cs.grid + if not grid.visible or getattr(grid, "hide_all", False): + continue + xs = list(grid.x_lines) + ys = list(grid.y_lines) + zs = list(grid.z_lines) + if not (xs or ys or zs): + continue + palette_idx = 0 if cs.is_global() else (idx % (len(palette) - 1)) + 1 + grid_color, dot_color = palette[palette_idx] + grid_hex = _rgb_to_hex(grid_color) + dot_hex = _rgb_to_hex(dot_color) + + xmin, xmax = (min(xs), max(xs)) if xs else (-1.0, 1.0) + ymin, ymax = (min(ys), max(ys)) if ys else (-1.0, 1.0) + if xmin == xmax: + xmin, xmax = xmin - 1.0, xmax + 1.0 + if ymin == ymax: + ymin, ymax = ymin - 1.0, ymax + 1.0 + + plane_axis: int | None = None + plane_offset: float | None = None + if opts.working_plane is not None: + name, off = opts.working_plane + axis_idx = {"XY": 2, "XZ": 1, "YZ": 0}[name] + plane_axis = axis_idx + plane_offset = off - cs.coord.origin[axis_idx] + + def on_plane( + local: tuple[float, float, float], + _axis: int | None = plane_axis, + _offset: float | None = plane_offset, + ) -> bool: + if _axis is None or _offset is None: + return True + return abs(local[_axis] - _offset) < 1e-6 + + active: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + dim: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + + def add_seg( + p1: tuple[float, float, float], + p2: tuple[float, float, float], + _active: list = active, + _dim: list = dim, + _on_plane: Any = on_plane, + _cs: Any = cs, + ) -> None: + on = _on_plane(p1) and _on_plane(p2) + bucket = _active if on else _dim + bucket.append((_cs.coord.local_to_world(p1), _cs.coord.local_to_world(p2))) + + z_planes = zs if zs else [0.0] + for z in z_planes: + for x in xs: + add_seg((x, ymin, z), (x, ymax, z)) + for y in ys: + add_seg((xmin, y, z), (xmax, y, z)) + if opts.working_plane is None and zs and xs and ys: + for x in xs: + for y in ys: + add_seg((x, y, zs[0]), (x, y, zs[-1])) + + if dim: + data.append(_line_trace(dim, color=grid_hex, width=1, name="grid", opacity=0.18)) + if active: + data.append(_line_trace(active, color=grid_hex, width=2, name="grid-active")) + + active_dots: list[tuple[float, float, float]] = [] + dim_dots: list[tuple[float, float, float]] = [] + for z in z_planes: + for x in xs or [0.0]: + for y in ys or [0.0]: + pt = cs.coord.local_to_world((x, y, z)) + (active_dots if on_plane((x, y, z)) else dim_dots).append(pt) + if dim_dots: + data.append(self._dot_trace(dim_dots, color="#999999", opacity=0.35)) + if active_dots: + data.append( + self._dot_trace( + active_dots, + color=dot_hex, + opacity=1.0, + meta={"kind": "snap"}, + ) + ) + all_dots.extend(np.asarray(active_dots, dtype=float)) + + return np.vstack(all_dots) if all_dots else None + + @staticmethod + def _dot_trace( + points: list[tuple[float, float, float]], + *, + color: str, + opacity: float, + meta: dict[str, Any] | None = None, + ) -> dict[str, Any]: + customdata = ( + [[float(p[0]), float(p[1]), float(p[2])] for p in points] if meta is not None else None + ) + trace: dict[str, Any] = { + "type": "scatter3d", + "mode": "markers", + "x": [float(p[0]) for p in points], + "y": [float(p[1]) for p in points], + "z": [float(p[2]) for p in points], + "marker": {"color": color, "size": _SNAP_MARKER_SIZE, "line": {"width": 0}}, + "opacity": opacity, + "hoverinfo": "skip", + "name": "snap", + "showlegend": False, + } + if meta is not None: + trace["meta"] = meta + trace["customdata"] = customdata + return trace + + # ── model entities ─────────────────────────────────────────────── + def _build_nodes( + self, + data: list[dict[str, Any]], + opts: SceneOptions, + points: np.ndarray, + node_ids: list[int], + ) -> None: + if not len(points): + return + colors = [ + self._style.node_selected_color + if nid in opts.selection_nodes + else self._style.node_color + for nid in node_ids + ] + data.append( + { + "type": "scatter3d", + "mode": "markers", + "x": [float(p[0]) for p in points], + "y": [float(p[1]) for p in points], + "z": [float(p[2]) for p in points], + "marker": { + "color": colors, + "size": _NODE_MARKER_SIZE, + "line": {"color": "#4d4d4d", "width": 1}, + }, + "customdata": list(node_ids), + "meta": {"kind": "node"}, + # Engineering-notation hover (opstool's trace recipe) so a + # hover identifies the entity instead of showing the raw id. + "hovertemplate": "Node #%{customdata}Tools toolbar (left): Select, Draw Node (F1), +
Tools toolbar (top): Select, Draw Node (F1), Draw Frame (F2), Draw Truss (F3, icon-less by design). Define → Coordinate System/Grids… (Ctrl+G) and Add Node… (Ctrl+N) for precise layout; use the top-toolbar Level combo in Top/Front/Right views.
diff --git a/src/otko/views/dialogs/run_analysis.py b/src/otko/views/dialogs/run_analysis.py index f31ec6f..b1beb12 100644 --- a/src/otko/views/dialogs/run_analysis.py +++ b/src/otko/views/dialogs/run_analysis.py @@ -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 ---") diff --git a/src/otko/views/dialogs/section_editor.py b/src/otko/views/dialogs/section_editor.py index 4bb185f..fdb9031 100644 --- a/src/otko/views/dialogs/section_editor.py +++ b/src/otko/views/dialogs/section_editor.py @@ -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: diff --git a/src/otko/views/dialogs/section_forms.py b/src/otko/views/dialogs/section_forms.py index c824db7..ec386e4 100644 --- a/src/otko/views/dialogs/section_forms.py +++ b/src/otko/views/dialogs/section_forms.py @@ -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("Extruded-view shape (display only).")) 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`. diff --git a/src/otko/views/dialogs/section_library.py b/src/otko/views/dialogs/section_library.py index c525a50..198c821 100644 --- a/src/otko/views/dialogs/section_library.py +++ b/src/otko/views/dialogs/section_library.py @@ -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, diff --git a/src/otko/views/dialogs/uniform_excitation.py b/src/otko/views/dialogs/uniform_excitation.py index 6bdc94b..b7cc471 100644 --- a/src/otko/views/dialogs/uniform_excitation.py +++ b/src/otko/views/dialogs/uniform_excitation.py @@ -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)"), diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index df4b70e..cd34449 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -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 diff --git a/src/otko/views/docks/deformed_shape.py b/src/otko/views/docks/deformed_shape.py index 497dc85..de89664 100644 --- a/src/otko/views/docks/deformed_shape.py +++ b/src/otko/views/docks/deformed_shape.py @@ -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 ( diff --git a/src/otko/views/docks/force_diagram.py b/src/otko/views/docks/force_diagram.py index 2580e36..a19e99d 100644 --- a/src/otko/views/docks/force_diagram.py +++ b/src/otko/views/docks/force_diagram.py @@ -10,8 +10,6 @@ Exposes two signals: from __future__ import annotations -import math - from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QComboBox, diff --git a/src/otko/views/docks/hysteresis.py b/src/otko/views/docks/hysteresis.py index cc56c3e..c8f0a79 100644 --- a/src/otko/views/docks/hysteresis.py +++ b/src/otko/views/docks/hysteresis.py @@ -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 diff --git a/src/otko/views/docks/property_editor.py b/src/otko/views/docks/property_editor.py index 10cb7f7..b6c5ed0 100644 --- a/src/otko/views/docks/property_editor.py +++ b/src/otko/views/docks/property_editor.py @@ -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 diff --git a/src/otko/views/docks/pushover_curve.py b/src/otko/views/docks/pushover_curve.py index 3dc0e11..86beff2 100644 --- a/src/otko/views/docks/pushover_curve.py +++ b/src/otko/views/docks/pushover_curve.py @@ -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: diff --git a/src/otko/views/docks/response_spectrum.py b/src/otko/views/docks/response_spectrum.py index 42cb523..7664171 100644 --- a/src/otko/views/docks/response_spectrum.py +++ b/src/otko/views/docks/response_spectrum.py @@ -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 diff --git a/src/otko/views/docks/table_dock.py b/src/otko/views/docks/table_dock.py index c8d3342..3af8383 100644 --- a/src/otko/views/docks/table_dock.py +++ b/src/otko/views/docks/table_dock.py @@ -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 "—" diff --git a/src/otko/views/docks/time_history.py b/src/otko/views/docks/time_history.py index ba285fe..560dba0 100644 --- a/src/otko/views/docks/time_history.py +++ b/src/otko/views/docks/time_history.py @@ -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() diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index ec9f487..5241869 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -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) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index 83f5433..5e7a271 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -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) diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 1d574c1..06db479 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -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) diff --git a/src/otko/views/tools/base.py b/src/otko/views/tools/base.py index 04abc8e..bfb6072 100644 --- a/src/otko/views/tools/base.py +++ b/src/otko/views/tools/base.py @@ -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 diff --git a/src/otko/views/tools/draw_frame.py b/src/otko/views/tools/draw_frame.py index 958abe3..980716a 100644 --- a/src/otko/views/tools/draw_frame.py +++ b/src/otko/views/tools/draw_frame.py @@ -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) diff --git a/src/otko/views/tools/draw_node.py b/src/otko/views/tools/draw_node.py index 5e7ee0f..0f286be 100644 --- a/src/otko/views/tools/draw_node.py +++ b/src/otko/views/tools/draw_node.py @@ -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) diff --git a/src/otko/views/tools/draw_truss.py b/src/otko/views/tools/draw_truss.py index 1eeb69d..4ee8254 100644 --- a/src/otko/views/tools/draw_truss.py +++ b/src/otko/views/tools/draw_truss.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 82dd86a..c91c064 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/gui/test_analysis_commands.py b/tests/gui/test_analysis_commands.py index 9750537..fed67e3 100644 --- a/tests/gui/test_analysis_commands.py +++ b/tests/gui/test_analysis_commands.py @@ -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: diff --git a/tests/gui/test_analysis_runner.py b/tests/gui/test_analysis_runner.py index 2d19389..14c100a 100644 --- a/tests/gui/test_analysis_runner.py +++ b/tests/gui/test_analysis_runner.py @@ -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 diff --git a/tests/gui/test_assign_load_pattern_name.py b/tests/gui/test_assign_load_pattern_name.py index 0f95da2..b0b4fba 100644 --- a/tests/gui/test_assign_load_pattern_name.py +++ b/tests/gui/test_assign_load_pattern_name.py @@ -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 diff --git a/tests/gui/test_assign_masses.py b/tests/gui/test_assign_masses.py index 87a3a7a..3ba0dda 100644 --- a/tests/gui/test_assign_masses.py +++ b/tests/gui/test_assign_masses.py @@ -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 diff --git a/tests/gui/test_assign_zls.py b/tests/gui/test_assign_zls.py index 850e0a8..9a8cb66 100644 --- a/tests/gui/test_assign_zls.py +++ b/tests/gui/test_assign_zls.py @@ -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, ) diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py new file mode 100644 index 0000000..ed77be1 --- /dev/null +++ b/tests/gui/test_canvas_backend_switch.py @@ -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") diff --git a/tests/gui/test_canvas_grid_snap.py b/tests/gui/test_canvas_grid_snap.py index 8410edf..02ddb4b 100644 --- a/tests/gui/test_canvas_grid_snap.py +++ b/tests/gui/test_canvas_grid_snap.py @@ -12,7 +12,7 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( # noqa: E402 +from otko.core import ( CoordinateGridSystem, CoordinateSystem, GridSystem, diff --git a/tests/gui/test_case_forms.py b/tests/gui/test_case_forms.py index c911141..6672806 100644 --- a/tests/gui/test_case_forms.py +++ b/tests/gui/test_case_forms.py @@ -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] diff --git a/tests/gui/test_commands.py b/tests/gui/test_commands.py index 4584ed9..0457390 100644 --- a/tests/gui/test_commands.py +++ b/tests/gui/test_commands.py @@ -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 ──────────────────────────────── diff --git a/tests/gui/test_convert_element_type.py b/tests/gui/test_convert_element_type.py index 4fa11ff..426c23b 100644 --- a/tests/gui/test_convert_element_type.py +++ b/tests/gui/test_convert_element_type.py @@ -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: diff --git a/tests/gui/test_define_dialogs.py b/tests/gui/test_define_dialogs.py index 57c1bfc..4b6f9b3 100644 --- a/tests/gui/test_define_dialogs.py +++ b/tests/gui/test_define_dialogs.py @@ -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 diff --git a/tests/gui/test_draw_frame_tool.py b/tests/gui/test_draw_frame_tool.py index 6161eb2..ff6370c 100644 --- a/tests/gui/test_draw_frame_tool.py +++ b/tests/gui/test_draw_frame_tool.py @@ -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: diff --git a/tests/gui/test_draw_node_tool.py b/tests/gui/test_draw_node_tool.py index d7d0023..aab389d 100644 --- a/tests/gui/test_draw_node_tool.py +++ b/tests/gui/test_draw_node_tool.py @@ -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, ) diff --git a/tests/gui/test_draw_truss_tool.py b/tests/gui/test_draw_truss_tool.py index 4c67164..bcc2879 100644 --- a/tests/gui/test_draw_truss_tool.py +++ b/tests/gui/test_draw_truss_tool.py @@ -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: diff --git a/tests/gui/test_grid_dialog.py b/tests/gui/test_grid_dialog.py index c73e852..0013ad4 100644 --- a/tests/gui/test_grid_dialog.py +++ b/tests/gui/test_grid_dialog.py @@ -6,7 +6,7 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( # noqa: E402 +from otko.core import ( CoordinateGridSystem, CoordinateSystem, GridLine, diff --git a/tests/gui/test_material_dialog_concrete04.py b/tests/gui/test_material_dialog_concrete04.py index 4dd34d5..23523bc 100644 --- a/tests/gui/test_material_dialog_concrete04.py +++ b/tests/gui/test_material_dialog_concrete04.py @@ -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, ) diff --git a/tests/gui/test_material_form_ranges.py b/tests/gui/test_material_form_ranges.py index cb47096..54c1899 100644 --- a/tests/gui/test_material_form_ranges.py +++ b/tests/gui/test_material_form_ranges.py @@ -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, diff --git a/tests/gui/test_path_ts_dialogs.py b/tests/gui/test_path_ts_dialogs.py index 7245068..5f1e132 100644 --- a/tests/gui/test_path_ts_dialogs.py +++ b/tests/gui/test_path_ts_dialogs.py @@ -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 ────────────── diff --git a/tests/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py new file mode 100644 index 0000000..3ef3601 --- /dev/null +++ b/tests/gui/test_plot_properties.py @@ -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() diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py new file mode 100644 index 0000000..78d5160 --- /dev/null +++ b/tests/gui/test_plotly_hover.py @@ -0,0 +1,93 @@ +"""Regression tests for the Plotly hover/snap-marker contract. + +``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves. +Touching the plot on each one made it redraw per mouse move, re-firing hover +until the stack blew (``RangeError: Maximum call stack size exceeded``) and the +canvas froze. The marker is therefore coalesced and only touched when its +shown/hidden state actually changes. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("plotly") + +from otko.services import load_project + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + +#: 200 mouse-move events over a *non-snap* point with snapping armed. +_CHURN_PROBE = """(function(){ + var gd = document.getElementById('plot'); + var n = 0; var original = Plotly.restyle; + Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); }; + var point = { data: gd.data[0], customdata: [0, 0, 0] }; + try { + for (var i = 0; i < 200; i++) { + gd.emit('plotly_hover', { points: [point] }); + gd.emit('plotly_unhover', {}); + } + return n; + } finally { Plotly.restyle = original; } +})()""" + +_HOVER_MARKER_X = ( + "JSON.stringify((document.getElementById('plot').data" + ".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)" +) + +_EMIT_SNAP_HOVER = """(function(){ + var gd = document.getElementById('plot'); + var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0]; + var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] }; + gd.emit('plotly_hover', { points: [point] }); + return true; +})()""" + + +def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def] + from otko.views.canvas_plotly import PlotlyCanvas + + canvas = PlotlyCanvas() + qtbot.addWidget(canvas) + canvas.show_project(load_project(EXAMPLES / name)) + qtbot.waitUntil(lambda: canvas._ready, timeout=30000) + qtbot.wait(1500) # let the first Plotly.react settle + return canvas + + +def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def] + box: dict[str, object] = {} + canvas._web.page().runJavaScript(script, lambda value: box.update(value=value)) + qtbot.waitUntil(lambda: "value" in box, timeout=timeout) + return box["value"] + + +@pytest.mark.gui +def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def] + """The unfixed version restyled once per hover/unhover event.""" + canvas = _open_canvas(qtbot, "space_frame_3d.osmodel") + + restyles = _run_js(canvas, qtbot, _CHURN_PROBE) + + assert restyles == 0, f"mouse movement caused {restyles} plot restyles" + + +@pytest.mark.gui +def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def] + canvas = _open_canvas(qtbot, "basic_truss.osmodel") + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]" + + canvas.set_snap_preview_enabled(True) + qtbot.wait(100) + _run_js(canvas, qtbot, _EMIT_SNAP_HOVER) + qtbot.wait(250) + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown" + + _run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})") + qtbot.wait(250) + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared" diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py new file mode 100644 index 0000000..97ef3ee --- /dev/null +++ b/tests/gui/test_plotly_view_preservation.py @@ -0,0 +1,123 @@ +"""Regression tests for the Plotly canvas push contract. + +``Plotly.react`` resets any scene attribute the incoming layout omits, so a +data-only push must be flagged ``preserveView`` (the JS side then carries the +live camera and axis ranges forward). Only explicit re-frames may send the +computed framing. These tests capture the JavaScript the canvas emits instead +of driving the browser, so they stay fast and deterministic. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("plotly") + +from otko.services import load_project + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + + +def _canvas_with_captured_js(qtbot): # type: ignore[no-untyped-def] + from otko.views.canvas_plotly import PlotlyCanvas + + canvas = PlotlyCanvas() + qtbot.addWidget(canvas) + # Pretend the page finished loading, then capture instead of running JS. + canvas._ready = True + calls: list[str] = [] + canvas._eval = lambda js: calls.append(js) # type: ignore[method-assign] + return canvas, calls + + +def _parse_call(call: str) -> tuple[dict, bool]: + """Split a captured ``window.otkoUpdate(