diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c834e6..56068ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, develop] pull_request: - branches: [main, develop] jobs: lint: @@ -13,9 +12,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.11" - # Pinned to the pre-commit revisions so CI and local hooks agree. - - run: pip install ruff==0.4.4 + python-version: "3.12" + - run: pip install ruff - run: ruff check src tests - run: ruff format --check src tests @@ -25,14 +23,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - 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 + python-version: "3.12" + - run: pip install -e ".[gui,dev]" + - run: mypy src/otko/core src/otko/services src/otko/viewmodels test-headless: - needs: [lint, type] strategy: fail-fast: false matrix: @@ -48,14 +43,19 @@ jobs: - run: pytest tests/unit tests/services -m "not slow" test-gui: - needs: [lint, type] - runs-on: ubuntu-latest + 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: "3.11" + python-version: ${{ matrix.python-version }} - 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,19 +63,24 @@ jobs: libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \ libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb - run: pip install -e ".[gui,dev]" - - run: xvfb-run -a pytest tests/gui -m "not slow" + - name: Run GUI tests (Linux, headless) + if: runner.os == 'Linux' + run: xvfb-run -a pytest tests/gui + - name: Run GUI tests (Win/Mac) + if: runner.os != 'Linux' + run: pytest tests/gui test-integration: - 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: "3.11" - - run: pip install -e ".[dev]" - - run: pytest tests/integration -m "not slow" \ No newline at end of file + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[gui,dev]" + - run: pytest tests/integration -m slow diff --git a/.gitignore b/.gitignore index eac3bc1..c82640e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,3 @@ 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 deleted file mode 100644 index 32dbf4f..0000000 --- a/.ignore +++ /dev/null @@ -1,9 +0,0 @@ -# BEGIN oh-my-opencode-slim clonedeps -!.slim/ -!.slim/clonedeps.json -!.slim/clonedeps/ -!.slim/clonedeps/repos/ -!.slim/clonedeps/repos/** -.slim/clonedeps/repos/**/.git/ -.slim/clonedeps/repos/**/.git/** -# END oh-my-opencode-slim clonedeps diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json deleted file mode 100644 index ddddcab..0000000 --- a/.slim/clonedeps.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "1.0.0", - "updatedAt": "2026-09-16T17:30:00.000Z", - "dependencies": [ - { - "name": "opstool", - "resolvedVersion": "1.0.26", - "repoUrl": "https://github.com/yexiang92/opstool.git", - "ref": "v1.0.26", - "path": ".slim/clonedeps/repos/yexiang92__opstool", - "packagePath": "opstool/vis", - "reason": "Read-only source for evaluating whether to port opstool's PyVista/Plotly visualization settings into otko's canvas backends, or to depend on opstool.vis directly (GPL-3.0, compatible with otko's AGPL-3.0 via GPLv3 section 13)." - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index babac38..bdc5f2e 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 + plotly.js backend + dev tools +pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends) python -m otko # launch GUI (src/otko/app.py:run) ``` @@ -19,8 +19,6 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`. - `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.** - `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). @@ -58,17 +56,3 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git - `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip). - 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 24b2dd1..10a928c 100644 --- a/NOTICE +++ b/NOTICE @@ -45,21 +45,6 @@ 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 d67c45f..5e1e4bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ gui = [ "vtk>=9.3", "pyqtgraph>=0.13", "imageio[ffmpeg]>=2.34", - "plotly>=5.18", ] dev = [ "pytest>=7.4", @@ -90,25 +89,7 @@ select = [ "SIM", # simplify "RUF", # ruff-specific ] -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"] +ignore = ["E501"] # line length handled by formatter [tool.ruff.format] quote-style = "double" diff --git a/src/otko/commands/__init__.py b/src/otko/commands/__init__.py index eddf86f..82ccef5 100644 --- a/src/otko/commands/__init__.py +++ b/src/otko/commands/__init__.py @@ -29,6 +29,14 @@ 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, @@ -55,14 +63,6 @@ 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 444f34c..5950b40 100644 --- a/src/otko/commands/analysis.py +++ b/src/otko/commands/analysis.py @@ -13,7 +13,9 @@ 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) @@ -34,7 +36,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]] = [] @@ -56,7 +58,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 0e8b3dd..15f616f 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 23f1aa8..0af9a08 100644 --- a/src/otko/commands/combinations.py +++ b/src/otko/commands/combinations.py @@ -13,7 +13,9 @@ 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) @@ -34,7 +36,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]] = [] @@ -58,7 +60,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 3f94205..e3df65a 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 f1967ba..6487a9d 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 e271746..894c56b 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 789b6c8..d24fabf 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 9729ced..836ade9 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 e24840c..b737265 100644 --- a/src/otko/commands/nodes.py +++ b/src/otko/commands/nodes.py @@ -14,7 +14,9 @@ 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) @@ -39,7 +41,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) @@ -93,7 +95,7 @@ class SetRestraintCommand(ProjectCommand): def __init__( self, - vm: ProjectViewModel, + vm: "ProjectViewModel", node_ids: set[int], restraint: tuple[bool, bool, bool, bool, bool, bool], ) -> None: @@ -128,7 +130,7 @@ class UpdateNodeFieldsCommand(ProjectCommand): def __init__( self, - vm: ProjectViewModel, + vm: "ProjectViewModel", node_id: int, fields: dict[str, Any], ) -> None: @@ -166,7 +168,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 3fd0065..362e2a6 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 94142a6..e75a54f 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 1d03ab5..1d1062c 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 07c335d..3e48448 100644 --- a/src/otko/commands/transforms.py +++ b/src/otko/commands/transforms.py @@ -15,9 +15,10 @@ All copies inherit the source node's restraint, mass, and name. from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal from otko.commands.base import ProjectCommand +from otko.core import Node if TYPE_CHECKING: from otko.viewmodels import ProjectViewModel @@ -29,7 +30,7 @@ class MoveNodesCommand(ProjectCommand): def __init__( self, - vm: ProjectViewModel, + vm: "ProjectViewModel", node_ids: set[int], offset: tuple[float, float, float], ) -> None: @@ -62,7 +63,7 @@ class ReplicateCommand(ProjectCommand): def __init__( self, - vm: ProjectViewModel, + vm: "ProjectViewModel", node_ids: set[int], element_ids: set[int], offset: tuple[float, float, float], @@ -146,7 +147,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 e8835b7..b809bad 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 +from typing import Annotated, Literal, Union from pydantic import Field, PositiveFloat, PositiveInt, model_validator @@ -269,6 +269,6 @@ class ResponseSpectrumCase(Entity): AnalysisCase = Annotated[ - StaticCase | ModalCase | TransientCase | PushoverCase | ResponseSpectrumCase, + Union[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 35a2026..f79ae39 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 +from typing import Annotated, Literal, Union from pydantic import Field, PositiveFloat, PositiveInt @@ -198,14 +198,16 @@ class QuadElement(Entity): Element = Annotated[ - TrussElement - | CorotTrussElement - | ElasticBeamColumn - | ForceBeamColumn - | DispBeamColumn - | ZeroLengthElement - | ZeroLengthSectionElement - | BeamWithHingesElement - | QuadElement, + Union[ + 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 f7d61ea..cf6ecc7 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 68a1557..283dfad 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 +from typing import Annotated, Literal, Union from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator @@ -110,7 +110,7 @@ class ResponseSpectrum(Entity): TimeSeries = Annotated[ - LinearTimeSeries | ConstantTimeSeries | PathTimeSeries, + Union[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[ - PlainLoadPattern | UniformExcitationPattern | ImposedSupportMotionPattern, + Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern], Field(discriminator="type"), ] diff --git a/src/otko/core/materials/__init__.py b/src/otko/core/materials/__init__.py index daa84f6..be71418 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 +from typing import Annotated, Literal, Union 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,16 +229,18 @@ class HystereticSM(Entity): # ──────────────────────────── Discriminated union ──────────────────────────── Material = Annotated[ - ElasticIsotropic - | ElasticUniaxial - | Steel01 - | Steel02 - | Concrete01 - | Concrete02 - | Concrete04 - | ElasticPP - | HystereticMaterial - | HystereticSM, + Union[ + 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 f2d030a..175f7a4 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 530e0ca..595b09b 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 +from typing import Annotated, Literal, Union from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt @@ -61,7 +61,7 @@ class RectShape(BaseModel): SectionShape = Annotated[ - PipeShape | AngleShape | RectShape, + Union[PipeShape, AngleShape, RectShape], Field(discriminator="kind"), ] @@ -165,12 +165,12 @@ class StraightLayer(BaseModel): Patch = Annotated[ - RectangularPatch | CircularPatch, + Union[RectangularPatch, CircularPatch], Field(discriminator="kind"), ] Layer = Annotated[ - StraightLayer, + Union[StraightLayer], Field(discriminator="kind"), ] @@ -225,6 +225,6 @@ class SectionAggregator(Entity): Section = Annotated[ - ElasticSection | FiberSection | SectionAggregator, + Union[ElasticSection, FiberSection, SectionAggregator], Field(discriminator="type"), ] diff --git a/src/otko/services/animation_export.py b/src/otko/services/animation_export.py index fa80790..20c3f53 100644 --- a/src/otko/services/animation_export.py +++ b/src/otko/services/animation_export.py @@ -12,9 +12,8 @@ the main thread. from __future__ import annotations import math -from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, Callable import numpy as np diff --git a/src/otko/services/export.py b/src/otko/services/export.py index 5886360..0f3a22c 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 ce99a54..b6f6e6c 100644 --- a/src/otko/services/peer_record.py +++ b/src/otko/services/peer_record.py @@ -11,7 +11,6 @@ building the PathTimeSeries. from __future__ import annotations -import contextlib import re from pathlib import Path @@ -65,9 +64,10 @@ 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(): - # Skip stray non-numeric tokens. - with contextlib.suppress(ValueError): + try: 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,8 +83,10 @@ def parse_plain_values(path: str | Path) -> list[float]: vals: list[float] = [] for line in text.splitlines(): for tok in line.split(): - with contextlib.suppress(ValueError): + try: 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 18f5a20..b3438b8 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 a637428..a38d4e4 100644 --- a/src/otko/services/section_properties.py +++ b/src/otko/services/section_properties.py @@ -21,6 +21,7 @@ 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 9ea06d1..6ed5dbd 100644 --- a/src/otko/viewmodels/project_vm.py +++ b/src/otko/viewmodels/project_vm.py @@ -11,7 +11,6 @@ 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 @@ -41,8 +40,10 @@ class ProjectViewModel(QObject): @Slot(bool) def _on_stack_clean_changed(self, clean: bool) -> None: # Guard against the late-fire that Qt sends during destruction. - with contextlib.suppress(RuntimeError): + try: 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 4352b00..bcd9305 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 57c0a70..28b6f82 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -22,7 +22,6 @@ Convention: from __future__ import annotations -import contextlib import logging from typing import Any @@ -31,7 +30,6 @@ 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") @@ -63,9 +61,8 @@ class DiagramRenderer: - ``clear()`` removes the overlay. """ - def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: + def __init__(self, plotter: Any) -> None: self._plotter = plotter - self._style = style or RenderStyle() self._actor: Any = None self._label_actor: Any = None @@ -160,9 +157,7 @@ class DiagramRenderer: self._actor = self._plotter.add_mesh( mesh, scalars="value", - # Shared response palette (opstool's default diverging scale), so - # the PyVista and Plotly backends stay visually consistent. - cmap=list(self._style.response_scale_colors), + cmap="coolwarm", clim=(-vmax, vmax), show_scalar_bar=True, scalar_bar_args={"title": comp_label, "n_labels": 5}, @@ -259,8 +254,10 @@ class DiagramRenderer: pass self._actor = None if self._label_actor is not None: - with contextlib.suppress(Exception): + try: 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 3e00425..b1b3417 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -24,7 +24,6 @@ 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 @@ -32,9 +31,6 @@ 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) @@ -342,17 +338,6 @@ 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 12c88cb..568c079 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -10,7 +10,6 @@ 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 @@ -25,20 +24,15 @@ from otko.core import ( ElasticBeamColumn, ForceBeamColumn, NodalLoad, + QuadElement, + UniformElementLoad, PlainLoadPattern, Project, - QuadElement, TrussElement, - UniformElementLoad, ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.views.canvas3d.style import ( - SELECTED_STATE, - RenderStyle, - element_family_index, - family_palette, -) +from otko.views.canvas3d.style import RenderStyle class RendererMode(enum.Enum): @@ -190,6 +184,9 @@ 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() @@ -208,8 +205,6 @@ 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 @@ -224,8 +219,10 @@ 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 - with contextlib.suppress(Exception): + try: self._plotter.enable_anti_aliasing("ssaa") + except Exception: + pass # ── public API ─────────────────────────────────────────────────── def render(self, project: Project | None) -> None: @@ -282,16 +279,6 @@ 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. @@ -320,12 +307,11 @@ class ModelRenderer: self._reglyph_nodes() if self._frame_pd is not None and self._frame_ids_ordered: - # Restore each cell to its family slot, then flag the selected ones. - states = np.array(self._frame_family, dtype=np.int8) + states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8) for eid in element_ids: row = self._frame_id_to_row.get(eid) if row is not None: - states[row] = SELECTED_STATE + states[row] = 1 self._frame_pd.cell_data["_oss_state"] = states self._frame_pd.Modified() @@ -343,8 +329,10 @@ class ModelRenderer: """ # Remove any previous marker. if self._hover_actor is not None: - with contextlib.suppress(Exception): + try: self._plotter.remove_actor(self._hover_actor, render=False) + except Exception: + pass self._hover_actor = None if world_point is None: @@ -388,13 +376,15 @@ 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: - with contextlib.suppress(Exception): + try: 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._style.node_color, self._style.node_selected_color], + cmap=self._NODE_LUT, clim=[0, 1], show_scalar_bar=False, pickable=True, @@ -407,7 +397,6 @@ 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]] @@ -416,7 +405,6 @@ 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() @@ -424,18 +412,16 @@ 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) - # Cell scalar carries the palette slot (family, or SELECTED_STATE). - pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8) + pd.cell_data["_oss_state"] = np.zeros(len(ids), 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=family_palette(self._style), - clim=[0, SELECTED_STATE], + cmap=self._FRAME_LUT, + clim=[0, 1], show_scalar_bar=False, line_width=3.0, pickable=True, @@ -498,17 +484,10 @@ class ModelRenderer: plane_axis = axis_idx plane_offset_local = plane_off - cs_shift - # 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: + def _on_active_plane(local_pt: tuple[float, float, float]) -> bool: + if plane_axis is None: return True - return abs(local_pt[_axis] - _offset) < 1e-6 + return abs(local_pt[plane_axis] - plane_offset_local) < 1e-6 # type: ignore[operator] # Collect active + dim segments separately so they get their # own polydata + actor (different opacity / color). @@ -517,21 +496,13 @@ 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], - _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: + def add_seg(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> 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] @@ -643,7 +614,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]) @@ -694,16 +665,18 @@ 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]) @@ -780,8 +753,8 @@ class ModelRenderer: """Add one semi-transparent steel-blue non-pickable aux actor.""" actor = self._plotter.add_mesh( mesh, - color=self._style.extrusion_color, - opacity=self._style.extrusion_opacity, + color=(0.35, 0.60, 0.85), # cool steel-blue + opacity=0.22, show_edges=True, edge_color=(0.15, 0.25, 0.45), line_width=1.0, @@ -1001,7 +974,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 = self._style.support_color + support_color = (1.0, 0.5, 0.1) for node in project.nodes: if not any(node.restraint[i] for i in dof_idx): continue @@ -1015,7 +988,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 = self._style.nodal_load_color + load_color = (0.2, 0.85, 0.2) 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: @@ -1046,7 +1019,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 = self._style.element_load_color # distributed loads + elem_load_color = (1.0, 0.55, 0.2) # orange n_arrows_per_elem = 5 for eload in pattern.element_loads: if not isinstance(eload, UniformElementLoad): @@ -1121,11 +1094,15 @@ class ModelRenderer: self._clear_label_actors() for a in (self._node_actor, self._frame_actor): if a is not None: - with contextlib.suppress(Exception): + try: self._plotter.remove_actor(a, render=False) + except Exception: + pass for a in self._aux_actors: - with contextlib.suppress(Exception): + try: self._plotter.remove_actor(a, render=False) + except Exception: + pass self._node_actor = None self._frame_actor = None self._aux_actors.clear() @@ -1135,7 +1112,6 @@ 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 @@ -1144,8 +1120,10 @@ class ModelRenderer: def _clear_label_actors(self) -> None: for actor in (self._node_label_actor, self._element_label_actor): if actor is not None: - with contextlib.suppress(Exception): + try: self._plotter.remove_actor(actor, render=False) + except Exception: + pass self._node_label_actor = None self._element_label_actor = None @@ -1168,7 +1146,7 @@ class ModelRenderer: return self._plotter.add_point_labels( pts, labels, - font_size=self._style.label_font_size, + font_size=12, shape_opacity=0.15, text_color="black", point_color="white", @@ -1198,7 +1176,7 @@ class ModelRenderer: return self._plotter.add_point_labels( np.asarray(centers), labels, - font_size=self._style.label_font_size, + font_size=12, 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 a55d43b..f25d674 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -4,64 +4,11 @@ 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, 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) +from dataclasses import dataclass @dataclass(frozen=True, slots=True) @@ -72,58 +19,20 @@ class RenderStyle: background_top: str = "#dbe2ef" background_bottom: str = "#f5f7fb" - node_color: str = "#d9d9d9" # normal node sphere / marker - node_selected_color: str = "#00ffff" # selected node + node_color: str = "#f0a500" # warm gold + node_selected_color: str = "#00d4ff" # bright cyan frame_color: str = "#1f1f1f" - truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad + truss_color: str = "#2e5cb8" zerolength_color: str = "#a020f0" - 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", - ) + selected_color: str = "#00d4ff" fix_color: str = "#c0392b" # firebrick pin_color: str = "#c0392b" roller_color: str = "#e67e22" custom_support_color: str = "#7f8c8d" - load_color: str = "#27ae60" # Y-axis colour of the view triad + load_color: str = "#27ae60" mass_color: str = "#9b59b6" # ── sizes (relative to bbox diagonal unless absolute) ──────────── @@ -140,24 +49,3 @@ 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 deleted file mode 100644 index 0352961..0000000 --- a/src/otko/views/canvas_base.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Backend-agnostic contract for the central 3D canvas. - -Two backends implement it: - -- :class:`otko.views.canvas3d.ModelCanvas` — PyVista/VTK (native OpenGL, default) -- :class:`otko.views.canvas_plotly.PlotlyCanvas` — plotly.js in a QWebEngineView - -The ``MainWindow`` swaps between them at runtime, so both must expose the same -public surface (signals, selection, working plane, view presets, display -toggles) and declare what optional overlay layers they support via -:class:`CanvasCapabilities`. -""" - -from __future__ import annotations - -import importlib.util -from dataclasses import dataclass -from typing import Any, Protocol - - -def plotly_available() -> bool: - """True when the optional ``plotly`` package can be imported. - - The Plotly backend needs plotly.js (shipped inside the ``plotly`` wheel) - to render; without it the backend menu entry is disabled. - """ - return importlib.util.find_spec("plotly") is not None - - -@dataclass(frozen=True) -class CanvasCapabilities: - """Optional overlay layers a backend may or may not implement. - - Consumers query these (see ``RenderControls._refresh_action_enablement``) - instead of assuming a feature works on every backend. - """ - - #: Force-diagram ribbon overlay (N/V/M diagrams). - diagrams: bool = True - #: SAP2000-style section extrusion overlay. - extrusions: bool = True - #: Per-element local-axis triad overlay. - local_axes: bool = True - #: Node / element text labels. - labels: bool = True - #: Off-screen frame capture (mode-shape / time-history video export). - animation_export: bool = True - - -class CanvasBackend(Protocol): - """Structural contract implemented by every canvas backend. - - This mirrors :class:`ModelCanvas`; it exists so the swap path and the - consumers have one written-down interface to code against. - """ - - capabilities: CanvasCapabilities - selection: Any - - def show_project(self, project: Any) -> None: ... - - def clear_model(self) -> None: ... - - def render(self) -> None: ... - - def reset_camera(self) -> None: ... - - def view_isometric(self) -> None: ... - - def view_xy(self) -> None: ... - - def view_xz(self) -> None: ... - - def view_yz(self) -> None: ... - - def set_working_plane(self, plane: str, offset: float) -> None: ... - - def clear_working_plane(self) -> None: ... - - def working_plane_type(self) -> str | None: ... - - def working_plane_offset(self) -> float | None: ... - - def set_snap_preview_enabled(self, enabled: bool) -> None: ... - - def set_show_section_extrusions(self, enabled: bool) -> None: ... - - def set_show_local_axes(self, enabled: bool) -> None: ... - - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ... - - def set_default_selection_enabled(self, enabled: bool) -> None: ... diff --git a/src/otko/views/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py deleted file mode 100644 index 7fabb6d..0000000 --- a/src/otko/views/canvas_plotly/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Plotly-backed canvas package (optional GUI backend). - -``PlotlyCanvas`` is exported lazily (PEP 562): the pure -:mod:`~otko.views.canvas_plotly.trace_builder` must stay importable in the -headless test job, which has no Qt at all. -""" - -from __future__ import annotations - -from typing import Any - -from otko.views.canvas_plotly.trace_builder import ( - PlotlyTraceBuilder, - Scene, - SceneOptions, -) - -__all__ = ["PlotlyCanvas", "PlotlyTraceBuilder", "Scene", "SceneOptions"] - - -def __getattr__(name: str) -> Any: - if name == "PlotlyCanvas": - from otko.views.canvas_plotly.plotly_canvas import PlotlyCanvas - - return PlotlyCanvas - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/otko/views/canvas_plotly/bridge.py b/src/otko/views/canvas_plotly/bridge.py deleted file mode 100644 index 9dafbce..0000000 --- a/src/otko/views/canvas_plotly/bridge.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Qt ↔ plotly.js bridge exposed through ``QWebChannel``. - -The page calls :meth:`_Bridge.picked` / :meth:`_Bridge.snapClicked`; every -call is re-emitted here and wired to the canvas' own Qt signals. -""" - -from __future__ import annotations - -from PySide6.QtCore import QObject, Signal, Slot - - -class _Bridge(QObject): - """Slot surface the JavaScript side addresses as ``otkoBridge``.""" - - #: (kind, entity id, additive modifier held) where kind is node|element. - picked = Signal(str, int, bool) - #: World-space coordinates of a clicked grid intersection. - snapClicked = Signal(float, float, float) - - @Slot(str, int, bool) - def onPicked(self, kind: str, entity_id: int, additive: bool) -> None: - """JS entry point: a model entity (or snap target) was clicked.""" - self.picked.emit(kind, int(entity_id), bool(additive)) - - @Slot(float, float, float) - def onSnapClicked(self, x: float, y: float, z: float) -> None: - """JS entry point: a grid-intersection target was clicked.""" - self.snapClicked.emit(float(x), float(y), float(z)) diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py deleted file mode 100644 index 46e145e..0000000 --- a/src/otko/views/canvas_plotly/html.py +++ /dev/null @@ -1,276 +0,0 @@ -"""HTML/JS runtime for the Plotly canvas. - -The page is materialised once per process into a temp directory and loaded -from ``file://``: the plotly.js bundle is ~5 MB, which is past -``QWebEngineView.setHtml``'s data-URL limit, and writing it to disk also lets -the browser cache it across figure updates. - -The JS side exposes three entry points to Python (called via -``QWebEnginePage.runJavaScript``): - -- ``otkoUpdate(payloadJson)`` — replace data + layout with ``Plotly.react``, - which diffs client-side and leaves the interactive camera untouched. -- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets, - parallel-projection toggle). -- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview. - -Clicks travel the other way through the ``otkoBridge`` QWebChannel object: -entity picks carry the trace ``meta.kind`` and the point ``customdata``. -""" - -from __future__ import annotations - -import atexit -import shutil -import tempfile -from pathlib import Path - -#: Materialised runtime directory (plotly.min.js + index.html + qwebchannel.js). -_ASSETS_DIR: Path | None = None - -# ``qrc:///qtwebchannel/qwebchannel.js`` is served by QtWebEngine's internal -# resource scheme; it is not reachable through QFile. -_PAGE = """ - -
- - - - - - - - - - -""" - - -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 deleted file mode 100644 index ddecb15..0000000 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Plotly-backed 3D canvas. - -A ``QWebEngineView`` hosting plotly.js, driven over ``QWebChannel``. It -implements the same public surface as :class:`otko.views.canvas3d.ModelCanvas` -(signals, selection, working plane, view presets, display toggles) so -``MainWindow`` can swap the two at runtime. - -Update strategy: figures are pushed with ``Plotly.react``. Because react -resets any scene attribute the incoming layout omits, a non-framing push is -flagged ``preserveView`` and the JS side carries the live camera and axis -ranges forward — so re-rendering on a model edit or selection change never -yanks the camera the user is orbiting. Only an explicit re-frame (new -project, view preset, ``reset_camera``, style change) sends the computed -camera plus opstool-style padded axis ranges. -""" - -from __future__ import annotations - -import json -import math -from collections.abc import Callable -from dataclasses import replace -from typing import Any - -from PySide6.QtCore import QObject, QUrl, Signal -from PySide6.QtWebChannel import QWebChannel -from PySide6.QtWebEngineCore import QWebEnginePage -from PySide6.QtWebEngineWidgets import QWebEngineView -from PySide6.QtWidgets import QVBoxLayout, QWidget - -from otko.views.canvas3d.model_renderer import RendererMode -from otko.views.canvas3d.selection import SelectionState -from otko.views.canvas3d.style import RenderStyle -from otko.views.canvas_base import CanvasCapabilities -from otko.views.canvas_plotly import html as _html -from otko.views.canvas_plotly.bridge import _Bridge -from otko.views.canvas_plotly.trace_builder import ( - PlotlyTraceBuilder, - Scene, - SceneOptions, -) - -#: View preset directions (unit-ish vectors from the scene centre to the eye). -_VIEW_DIRECTIONS = { - "iso": (1.0, 1.0, 0.8), - "xy": (0.0, 0.0, 1.0), - "xz": (0.0, -1.0, 0.0), - "yz": (1.0, 0.0, 0.0), -} - - -class _LoggingPage(QWebEnginePage): - """A web page that forwards the JS console to the app's console dock. - - A plotly failure inside the page is otherwise invisible from Python (the - ``react`` promise just rejects), which makes a frozen canvas very hard to - diagnose. - """ - - def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None: - super().__init__(parent) - self._log = log - - def javaScriptConsoleMessage( - self, - level: QWebEnginePage.JavaScriptConsoleMessageLevel, - message: str, - line_number: int, - source_id: str, - ) -> None: - names = { - QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error", - QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning", - } - severity = names.get(level, "info") - source = source_id.rsplit("/", 1)[-1] or "plotly" - self._log(f"[web] {source}:{line_number} {message}", severity) - - -class _CameraShim: - """Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere.""" - - def __init__(self, canvas: PlotlyCanvas) -> None: - self._canvas = canvas - - @property - def parallel_projection(self) -> bool: - return self._canvas._parallel - - @parallel_projection.setter - def parallel_projection(self, value: bool) -> None: - self._canvas.set_parallel_projection(bool(value)) - - -class _PlotlyRendererFacade: - """Stand-in for ``ModelRenderer`` covering the calls made on ``_renderer``. - - ``RenderControls`` and ``DockManager`` reach into ``canvas._renderer`` for - ``render`` / ``set_mode`` / ``_mode``; this keeps those code paths - backend-agnostic. - """ - - def __init__(self, canvas: PlotlyCanvas) -> None: - self._canvas = canvas - - @property - def _mode(self) -> RendererMode: - return self._canvas._mode - - @property - def _project(self) -> Any: - return self._canvas._project - - @property - def _working_plane(self) -> tuple[str, float] | None: - return self._canvas._working_plane - - def render(self, project: Any) -> None: - self._canvas.set_project(project) - - def set_mode(self, mode: RendererMode, deformation: Any = None) -> None: - self._canvas.set_mode(mode, deformation) - - def set_working_plane(self, plane: tuple[str, float] | None) -> None: - if plane is None: - self._canvas.set_working_plane(None, 0.0) - else: - self._canvas.set_working_plane(plane[0], plane[1]) - - def set_show_section_extrusions(self, on: bool) -> None: - self._canvas.set_show_section_extrusions(on) - - def set_show_local_axes(self, on: bool) -> None: - self._canvas.set_show_local_axes(on) - - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: - self._canvas.set_display_options( - show_node_labels=show_node_labels, - show_element_labels=show_element_labels, - ) - - -class PlotlyCanvas(QWidget): - """The central 3D viewport, rendered by plotly.js in a web view.""" - - nodePicked = Signal(int) - elementPicked = Signal(int) - emptyClicked = Signal(float, float, float) - - #: Force diagrams and off-screen video capture are not implemented on - #: this backend yet (both are PyVista-specific today). - capabilities = CanvasCapabilities(diagrams=False, animation_export=False) - - def __init__( - self, - parent: QWidget | None = None, - style: RenderStyle | None = None, - selection: SelectionState | None = None, - log: Callable[[str, str], None] | None = None, - ) -> None: - super().__init__(parent) - self._log = log - self._style = style or RenderStyle() - self.selection = selection or SelectionState(self) - self._builder = PlotlyTraceBuilder(self._style) - self._project: Any = None - self._scene = Scene(data=[], layout={}) - self._options = SceneOptions() - self._mode = RendererMode.MODEL - self._parallel = False - self._view_preset = "iso" - self._snap_enabled = False - self._default_selection_enabled = True - self._working_plane: tuple[str, float] | None = None - self._camera = _CameraShim(self) - self._framing_dirty = True - self._renderer = _PlotlyRendererFacade(self) - self._ready = False - - self._build_ui() - self.selection.selectionChanged.connect(self._on_selection_changed) - - # ── construction ───────────────────────────────────────────────── - def _build_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self._web = QWebEngineView(self) - if self._log is not None: - self._web.setPage(_LoggingPage(self._log, self._web)) - self._channel = QWebChannel(self._web.page()) - self._bridge = _Bridge(self) - self._channel.registerObject("otkoBridge", self._bridge) - self._web.page().setWebChannel(self._channel) - self._web.loadFinished.connect(self._on_loaded) - self._bridge.picked.connect(self._on_picked) - self._bridge.snapClicked.connect(self._on_snap_clicked) - layout.addWidget(self._web) - self._web.load(QUrl.fromLocalFile(_html.runtime_url())) - - def _on_loaded(self, ok: bool) -> None: - if not ok: - return - self._ready = True - self._push_scene() - self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})") - - # ── public API (mirrors ModelCanvas) ───────────────────────────── - def show_project(self, project: Any) -> None: - """Render (or clear) a project; frame the camera when nodes exist.""" - self.set_project(project) - if project is not None and project.nodes: - self._view_preset = "iso" - self._framing_dirty = True - self.render() - - def clear_model(self) -> None: - """Remove the model but keep the view/selection machinery.""" - self.selection.clear() - self.set_project(None) - self.render() - - def render(self, *args: Any, **kwargs: Any) -> None: - """Rebuild the figure and hand it to plotly.js. - - ``QWidget.render`` is overloaded for painting into a target; those - calls are forwarded untouched so the widget stays well-behaved, and - only the no-argument canvas idiom (shared with ``ModelCanvas``) - triggers a figure push. - """ - if args or kwargs: - super().render(*args, **kwargs) - return - self._push_scene() - - def set_project(self, project: Any) -> None: - """Bind a project without rendering (used by the ``_renderer`` facade).""" - self._project = project - - def set_mode(self, mode: RendererMode, deformation: Any = None) -> None: - """Set MODEL / DEFORMED / MODAL plus the displacement source to apply.""" - self._mode = mode - self._options = replace(self._options, deformation=deformation) - - def set_parallel_projection(self, on: bool) -> None: - self._parallel = bool(on) - self._framing_dirty = True - - def reset_camera(self) -> None: - self._view_preset = "iso" - self._framing_dirty = True - self.render() - - def view_isometric(self) -> None: - self._view_preset = "iso" - self._framing_dirty = True - self.render() - - def view_xy(self) -> None: - """Top view: the eye sits on +Z looking down.""" - self._view_preset = "xy" - self._framing_dirty = True - self.render() - - def view_xz(self) -> None: - """Front view: the eye sits on -Y.""" - self._view_preset = "xz" - self._framing_dirty = True - self.render() - - def view_yz(self) -> None: - """Right view: the eye sits on +X.""" - self._view_preset = "yz" - self._framing_dirty = True - self.render() - - # ── working plane ──────────────────────────────────────────────── - def set_working_plane(self, plane: str | None, offset: float) -> None: - """Filter the grid overlay to the active plan / elevation level.""" - if plane is None: - self._working_plane = None - else: - if plane not in ("XY", "XZ", "YZ"): - raise ValueError(f"Unsupported working plane: {plane!r}") - self._working_plane = (plane, float(offset)) - self._options = replace(self._options, working_plane=self._working_plane) - self.render() - - def clear_working_plane(self) -> None: - self.set_working_plane(None, 0.0) - - def working_plane_type(self) -> str | None: - return self._working_plane[0] if self._working_plane is not None else None - - def working_plane_offset(self) -> float | None: - return self._working_plane[1] if self._working_plane is not None else None - - # ── display toggles ────────────────────────────────────────────── - def set_snap_preview_enabled(self, enabled: bool) -> None: - """Toggle the hover snap-target preview (draw tools turn it on).""" - self._snap_enabled = bool(enabled) - self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})") - - def set_show_section_extrusions(self, enabled: bool) -> None: - self._options = replace(self._options, show_extrusions=bool(enabled)) - self.render() - - def set_show_local_axes(self, enabled: bool) -> None: - self._options = replace(self._options, show_local_axes=bool(enabled)) - self.render() - - def set_style(self, style: RenderStyle) -> None: - """Swap the visual style and repaint the figure. - - Deliberately *not* a re-frame: colour/opacity are trace props and the - background/outline ride the layout, so a style change (including the - live preview while dragging a colour) updates without snapping the - camera the user has moved. - """ - self._style = style - self._builder.set_style(style) - self.render() - - def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: - self._options = replace( - self._options, - show_node_labels=bool(show_node_labels), - show_element_labels=bool(show_element_labels), - ) - self.render() - - def set_default_selection_enabled(self, enabled: bool) -> None: - """When False, picks fire signals but do not touch :attr:`selection`.""" - self._default_selection_enabled = bool(enabled) - - # ── internals ─────────────────────────────────────────────────── - def _push_scene(self) -> None: - """Send the figure to plotly.js. - - ``Plotly.react`` resets any scene attribute the incoming layout omits, - which used to snap the camera *and* the padded ranges back on every - selection change. So a non-framing push marks itself - ``preserveView`` and the JS side carries the live camera/ranges - forward; only an explicit re-frame (new project, view preset, - ``reset_camera``) sends the computed framing. Style changes are - non-framing: they re-colour in place. - """ - self._scene = self._builder.build(self._project, self._options) - if not self._ready: - # Nothing to push yet; crucially this must come *before* the - # framing flag is consumed, or the framing scheduled before - # loadFinished would be dropped on the floor. - return - layout = dict(self._scene.layout) - re_framed = self._framing_dirty - if re_framed: - scene_layout = dict(layout["scene"]) - scene_layout["camera"] = self._camera_dict(self._scene) - for axis, override in self._scene.axis_overrides().items(): - scene_layout[axis] = {**scene_layout.get(axis, {}), **override} - layout["scene"] = scene_layout - self._framing_dirty = False - payload = json.dumps({"data": self._scene.data, "layout": layout}) - self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})") - - def _camera_dict(self, scene: Scene) -> dict[str, Any]: - cx, cy, cz = scene.center - distance = max(scene.diagonal, 1e-6) * 1.6 - direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"]) - norm = math.sqrt(sum(component * component for component in direction)) or 1.0 - eye = ( - cx + direction[0] / norm * distance, - cy + direction[1] / norm * distance, - cz + direction[2] / norm * distance, - ) - # Looking straight down the Z axis needs a non-degenerate up vector. - up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0) - return { - "eye": {"x": eye[0], "y": eye[1], "z": eye[2]}, - "center": {"x": cx, "y": cy, "z": cz}, - "up": {"x": up[0], "y": up[1], "z": up[2]}, - "projection": {"type": "orthographic" if self._parallel else "perspective"}, - } - - def _eval(self, js: str) -> None: - if self._ready: - self._web.page().runJavaScript(js) - - def _on_selection_changed(self, nodes: frozenset[int], elements: frozenset[int]) -> None: - self._options = replace( - self._options, - selection_nodes=frozenset(nodes), - selection_elements=frozenset(elements), - ) - self.render() - - def _on_picked(self, kind: str, entity_id: int, additive: bool) -> None: - if kind == "node": - if self._default_selection_enabled: - if additive: - self.selection.toggle_node(entity_id) - else: - self.selection.select_node(entity_id) - self.nodePicked.emit(entity_id) - elif kind == "element": - if self._default_selection_enabled: - if additive: - self.selection.toggle_element(entity_id) - else: - self.selection.select_element(entity_id) - self.elementPicked.emit(entity_id) - - def _on_snap_clicked(self, x: float, y: float, z: float) -> None: - self.emptyClicked.emit(float(x), float(y), float(z)) - - -def _js_bool(value: bool) -> str: - return "true" if value else "false" diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py deleted file mode 100644 index fa5fc94..0000000 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ /dev/null @@ -1,1028 +0,0 @@ -"""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 (top): Select, Draw Node (F1), +
Tools toolbar (left): 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 b1beb12..f31ec6f 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: + except Exception as exc: # noqa: BLE001 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: + except Exception as exc: # noqa: BLE001 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 fdb9031..4bb185f 100644 --- a/src/otko/views/dialogs/section_editor.py +++ b/src/otko/views/dialogs/section_editor.py @@ -12,6 +12,7 @@ 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 ( @@ -323,6 +324,7 @@ 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 ec386e4..c824db7 100644 --- a/src/otko/views/dialogs/section_forms.py +++ b/src/otko/views/dialogs/section_forms.py @@ -19,11 +19,8 @@ from PySide6.QtWidgets import ( ) from otko.core import ( - AngleShape, ElasticSection, FiberSection, - PipeShape, - RectShape, SectionAggregator, ) @@ -97,13 +94,14 @@ class SectionFormBase(QWidget): class ElasticSectionForm(SectionFormBase): type_label = "Elastic Section" - #: Shape kind → visible display-only dimensions (see the core - #: ``SectionShape`` union). ``None`` hides them all. + #: Shape → visible display-only dimensions. ``None`` hides them all. _SHAPE_DIMS: ClassVar[dict[str | None, tuple[str, ...]]] = { None: (), - "rect": ("b", "d"), - "angle": ("b", "d", "t"), - "pipe": ("od", "t"), + "rect": ("b", "h"), + "I": ("b", "d", "tw", "tf"), + "H": ("b", "d", "tw", "tf"), + "pipe": ("diameter",), + "circle": ("diameter",), } def __init__(self, parent: QWidget | None = None) -> None: @@ -129,14 +127,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", "angle", "pipe"): + for key in ("rect", "I", "H", "pipe", "circle"): 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", "d", "t", "od"): + for key in ("b", "h", "d", "tw", "tf", "diameter"): spin = _opt_spin() lbl = QLabel(f"{key}:") self._dim_spins[key] = spin @@ -162,15 +160,20 @@ class ElasticSectionForm(SectionFormBase): self._g.setValue(s.G) if s.J is not None: self._j.setValue(s.J) - kind = getattr(s.shape, "kind", None) if s.shape is not None else None - idx = self._shape.findData(kind) + idx = self._shape.findData(s.shape) self._shape.setCurrentIndex(idx if idx >= 0 else 0) for key, spin in self._dim_spins.items(): - v = getattr(s.shape, key, None) if s.shape is not None else None + v = getattr(s, key, None) spin.setValue(v if v is not None else _UNSET_SENTINEL) 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(), @@ -180,38 +183,10 @@ class ElasticSectionForm(SectionFormBase): Iy=self._iy.value(), G=self._g.value(), J=self._j.value(), - shape=self._read_shape(), + shape=shape, + **dims, # type: ignore[arg-type] ) - def _read_shape(self) -> PipeShape | AngleShape | RectShape | None: - """Build the display-only shape hint from the form fields. - - Dimensions are optional in the UI, so an incomplete shape degrades - to ``None`` instead of raising — the hint is never emitted to - OpenSees and cannot change an analysis result. - """ - - def dim(key: str) -> float | None: - return _opt_value(self._dim_spins[key]) - - kind = self._shape.currentData() - if kind == "rect": - b, d = dim("b"), dim("d") - if b is None or d is None: - return None - return RectShape(b=b, d=d) - if kind == "angle": - b, d, t = dim("b"), dim("d"), dim("t") - if b is None or d is None or t is None: - return None - return AngleShape(b=b, d=d, t=t) - if kind == "pipe": - od, t = dim("od"), dim("t") - if od is None or t is None: - return None - return PipeShape(od=od, t=t) - return None - class FiberSectionSummaryForm(SectionFormBase): """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 198c821..c525a50 100644 --- a/src/otko/views/dialogs/section_library.py +++ b/src/otko/views/dialogs/section_library.py @@ -2,7 +2,6 @@ from __future__ import annotations -from pydantic import ValidationError from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, @@ -18,6 +17,7 @@ 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 b7cc471..6bdc94b 100644 --- a/src/otko/views/dialogs/uniform_excitation.py +++ b/src/otko/views/dialogs/uniform_excitation.py @@ -23,6 +23,7 @@ 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 cd34449..df4b70e 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -210,14 +210,6 @@ 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( @@ -268,14 +260,6 @@ 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 @@ -380,14 +364,6 @@ 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 de89664..497dc85 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 +from typing import TYPE_CHECKING, Callable 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 a19e99d..2580e36 100644 --- a/src/otko/views/docks/force_diagram.py +++ b/src/otko/views/docks/force_diagram.py @@ -10,6 +10,8 @@ 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 c8f0a79..cc56c3e 100644 --- a/src/otko/views/docks/hysteresis.py +++ b/src/otko/views/docks/hysteresis.py @@ -15,7 +15,6 @@ For 2D it's [N1, Vy1, Mz1, N2, Vy2, Mz2]. from __future__ import annotations -import contextlib from typing import Any import pyqtgraph as pg @@ -338,6 +337,8 @@ class HysteresisView(QWidget): def _clear_curve(self) -> None: if self._curve is not None: - with contextlib.suppress(Exception): + try: 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 b6c5ed0..10cb7f7 100644 --- a/src/otko/views/docks/property_editor.py +++ b/src/otko/views/docks/property_editor.py @@ -8,8 +8,9 @@ dumb: no direct Project mutation, no Qt ↔ OpenSees coupling. from __future__ import annotations -from collections.abc import Callable +from typing import Callable +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QDoubleSpinBox, @@ -25,6 +26,7 @@ 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 86beff2..3dc0e11 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 UnitSystem, labels_for +from otko.core import UnitLabels, 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 7664171..42cb523 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 Qt, Signal +from PySide6.QtCore import Signal from PySide6.QtWidgets import ( QHBoxLayout, QHeaderView, @@ -20,6 +20,7 @@ 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 3af8383..c8d3342 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}") + parts.append(f"{label}×{float(factor):g}") # noqa: RUF001 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 560dba0..ba285fe 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,7 +208,9 @@ class TimeHistoryView(QWidget): def _clear_traces(self) -> None: for _, _, item in self._traces: - with contextlib.suppress(Exception): + try: 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 5241869..ec9f487 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -13,21 +13,16 @@ 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, QStackedWidget +from PySide6.QtWidgets import QMainWindow, QMessageBox 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 @@ -39,9 +34,6 @@ from otko.views.tools import ( ToolController, ) -#: Canvas backends, in menu order. -CANVAS_BACKENDS = ("pyvista", "plotly") - class MainWindow( QMainWindow, @@ -52,7 +44,7 @@ class MainWindow( ): """Top-level application shell.""" - def __init__(self, settings: QSettings | None = None) -> None: + def __init__(self) -> None: super().__init__() self.setWindowTitle("OTKO") self.setWindowIcon( @@ -68,22 +60,12 @@ class MainWindow( self._show_node_labels = False self._show_element_labels = False - # 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_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 self._build_docks() self._build_actions() @@ -95,164 +77,19 @@ class MainWindow( self._refresh_action_enablement() self.restore_layout() - # ── 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() + # ── 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) # ── layout persistence ─────────────────────────────────────────── def save_layout(self) -> None: """Persist window geometry and dock/toolbar state for next launch.""" - self._settings.setValue("geometry", self.saveGeometry()) - self._settings.setValue("windowState", self.saveState()) + settings = QSettings("OTKO", "OTKO") + settings.setValue("geometry", self.saveGeometry()) + settings.setValue("windowState", self.saveState()) def restore_layout(self) -> None: """Restore a previously saved geometry/dock layout, if any. @@ -260,14 +97,15 @@ class MainWindow( On first run no values are stored, so the defaults from ``__init__`` (``resize`` + dock construction) stay in effect. """ - geometry = self._settings.value("geometry") + settings = QSettings("OTKO", "OTKO") + geometry = settings.value("geometry") if geometry is not None: self.restoreGeometry(geometry) - state = self._settings.value("windowState") + state = settings.value("windowState") if state is not None: self.restoreState(state) - def closeEvent(self, event: QCloseEvent) -> None: # (Qt override) + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt override) """Prompt to save unsaved work, then persist the window layout. The prompt is shown only for a visible window: Qt also closes hidden @@ -309,7 +147,6 @@ class MainWindow( self._act_about.triggered.connect(self._on_about) self._act_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) @@ -324,12 +161,9 @@ 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) - # 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")) + 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) # 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 5e7a271..83f5433 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -192,46 +192,9 @@ 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. @@ -516,11 +479,6 @@ 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) @@ -531,7 +489,7 @@ class MenuBuilder: tb.setMovable(True) tb.setIconSize(QSize(24, 24)) tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - # Top edge; Tools shares the top area (built next). + # Top edge; Tools stays in the left area independently (built next). self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb) self._view_toolbar = tb tb.addAction(self._act_zoom_extents) @@ -577,8 +535,9 @@ class MenuBuilder: tb.setMovable(True) tb.setIconSize(QSize(24, 24)) tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - # Top edge alongside the View toolbar (built just before). - self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb) + # View lives on the top edge, so Tools stays left independently + # in its own left-area column. + self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, 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 06db479..1d574c1 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -50,23 +50,6 @@ 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 @@ -476,7 +459,6 @@ 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) @@ -500,11 +482,9 @@ 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) - # 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_force_diagram.setEnabled(has_static) self._act_show_time_history.setEnabled(has_transient) - self._act_export_th_animation.setEnabled(has_transient and caps.animation_export) + self._act_export_th_animation.setEnabled(has_transient) self._act_show_hysteresis.setEnabled(has_transient) self._act_show_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 bfb6072..04abc8e 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 980716a..958abe3 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 0f286be..5e7ee0f 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 4ee8254..1eeb69d 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 c91c064..82dd86a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ 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 @@ -20,27 +19,6 @@ 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 fed67e3..9750537 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 ( +from otko.commands import ( # noqa: E402 AddAnalysisCasesCommand, DeleteAnalysisCasesCommand, UpdateAnalysisCaseCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 LinearTimeSeries, ModalCase, NodalLoad, @@ -20,7 +20,7 @@ from otko.core import ( StaticCase, TransientCase, ) -from otko.viewmodels import ProjectViewModel +from otko.viewmodels import ProjectViewModel # noqa: E402 def _vm_with_pattern() -> ProjectViewModel: diff --git a/tests/gui/test_analysis_runner.py b/tests/gui/test_analysis_runner.py index 14c100a..2d19389 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 ( +from otko.commands import ( # noqa: E402 AddAnalysisCasesCommand, AddElementsCommand, AddNodalLoadsCommand, AddNodesCommand, AddSectionsCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, Node, StaticCase, ) -from otko.services.results import StaticResults -from otko.viewmodels import AnalysisRunner, ProjectViewModel +from otko.services.results import StaticResults # noqa: E402 +from otko.viewmodels import AnalysisRunner, ProjectViewModel # noqa: E402 @pytest.fixture diff --git a/tests/gui/test_assign_load_pattern_name.py b/tests/gui/test_assign_load_pattern_name.py index b0b4fba..0f95da2 100644 --- a/tests/gui/test_assign_load_pattern_name.py +++ b/tests/gui/test_assign_load_pattern_name.py @@ -6,14 +6,15 @@ import pytest pytest.importorskip("PySide6") -from otko.commands import AddNodalLoadsCommand -from otko.core import ( +from otko.commands import AddNodalLoadsCommand # noqa: E402 +from otko.core import ( # noqa: E402 LinearTimeSeries, Node, PlainLoadPattern, + Project, ) -from otko.viewmodels import ProjectViewModel -from otko.views.dialogs.assign_load import AssignLoadDialog +from otko.viewmodels import ProjectViewModel # noqa: E402 +from otko.views.dialogs.assign_load import AssignLoadDialog # noqa: E402 @pytest.mark.gui diff --git a/tests/gui/test_assign_masses.py b/tests/gui/test_assign_masses.py index 3ba0dda..87a3a7a 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 +from otko.views.dialogs.assign_masses import AssignMassesDialog # noqa: E402 @pytest.mark.gui diff --git a/tests/gui/test_assign_zls.py b/tests/gui/test_assign_zls.py index 9a8cb66..850e0a8 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 ( +from otko.core import ( # noqa: E402 ElasticSection, Node, Project, ) -from otko.views.dialogs.assign_zls import ( +from otko.views.dialogs.assign_zls import ( # noqa: E402 AssignZeroLengthSectionDialog, ) diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py deleted file mode 100644 index ed77be1..0000000 --- a/tests/gui/test_canvas_backend_switch.py +++ /dev/null @@ -1,122 +0,0 @@ -"""GUI tests for the live canvas-backend switch (PyVista ⇄ Plotly). - -QSettings are injected per-test so a swap never leaks into the real user -configuration or the next test. -""" - -from __future__ import annotations - -import pytest - -pytest.importorskip("PySide6") -pytest.importorskip("pyvistaqt") -pytest.importorskip("plotly") - -from PySide6.QtCore import QSettings - - -def _make_window(tmp_path, qtbot): # type: ignore[no-untyped-def] - from otko.views.main_window import MainWindow - - settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) - window = MainWindow(settings=settings) - qtbot.addWidget(window) - return window - - -@pytest.mark.gui -def test_default_backend_is_pyvista(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - from otko.views.canvas3d import ModelCanvas - - window = _make_window(tmp_path, qtbot) - assert window.canvas_backend() == "pyvista" - assert isinstance(window._canvas, ModelCanvas) - assert window._diagram_renderer is not None - - -@pytest.mark.gui -def test_switch_to_plotly_preserves_selection(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - from otko.views.canvas_plotly import PlotlyCanvas - - window = _make_window(tmp_path, qtbot) - window._vm.new_project() - window._canvas.selection.select_node(2) - - window.swap_canvas_backend("plotly") - - assert window.canvas_backend() == "plotly" - assert isinstance(window._canvas, PlotlyCanvas) - # One shared SelectionState: the tree/table/properties stay bound. - assert window._canvas.selection is window._selection - assert window._canvas.selection.nodes == frozenset({2}) - # Both canvases live in the stack; the PyVista one was not destroyed. - assert window._canvas_stack.count() == 2 - - -@pytest.mark.gui -def test_plotly_gates_pyvista_only_features(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _make_window(tmp_path, qtbot) - assert window._canvas.capabilities.diagrams is True - - window.swap_canvas_backend("plotly") - caps = window._canvas.capabilities - assert caps.diagrams is False - assert caps.animation_export is False - assert caps.extrusions is True and caps.labels is True - - window._refresh_action_enablement() - assert window._act_show_force_diagram.isEnabled() is False - assert window._act_export_th_animation.isEnabled() is False - - -@pytest.mark.gui -def test_swapping_back_reuses_the_same_canvases(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _make_window(tmp_path, qtbot) - pyvista_canvas = window._canvas - - window.swap_canvas_backend("plotly") - plotly_canvas = window._canvas - window.swap_canvas_backend("pyvista") - - assert window._canvas is pyvista_canvas - assert window._diagram_renderer is not None - # No duplicate widgets accumulate across repeated swaps. - window.swap_canvas_backend("plotly") - assert window._canvas is plotly_canvas - assert window._diagram_renderer is None # diagrams are PyVista-only - assert window._canvas_stack.count() == 2 - - -@pytest.mark.gui -def test_backend_choice_is_persisted(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _make_window(tmp_path, qtbot) - window.swap_canvas_backend("plotly") - assert window._settings.value("canvas/backend") == "plotly" - - # A fresh window with the same settings boots straight into Plotly. - from otko.views.canvas_plotly import PlotlyCanvas - from otko.views.main_window import MainWindow - - settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) - second = MainWindow(settings=settings) - qtbot.addWidget(second) - assert second.canvas_backend() == "plotly" - assert isinstance(second._canvas, PlotlyCanvas) - - -@pytest.mark.gui -def test_working_plane_survives_the_swap(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _make_window(tmp_path, qtbot) - window._vm.open(_example("basic_truss")) - window._canvas.set_working_plane("XY", 0.0) - - window.swap_canvas_backend("plotly") - - assert window._canvas.working_plane_type() == "XY" - assert window._canvas.working_plane_offset() == 0.0 - - -def _example(name: str) -> str: - from pathlib import Path - - return str(Path(__file__).resolve().parents[2] / "examples" / f"{name}.osmodel") diff --git a/tests/gui/test_canvas_grid_snap.py b/tests/gui/test_canvas_grid_snap.py index 02ddb4b..8410edf 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 ( +from otko.core import ( # noqa: E402 CoordinateGridSystem, CoordinateSystem, GridSystem, diff --git a/tests/gui/test_case_forms.py b/tests/gui/test_case_forms.py index 6672806..c911141 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 ( +from otko.core import ( # noqa: E402 NodalLoad, PlainLoadPattern, StaticCase, TransientCase, ) -from otko.views.dialogs.case_forms import TransientCaseForm +from otko.views.dialogs.case_forms import TransientCaseForm # noqa: E402 def _patterns(): # type: ignore[no-untyped-def] diff --git a/tests/gui/test_commands.py b/tests/gui/test_commands.py index 0457390..4584ed9 100644 --- a/tests/gui/test_commands.py +++ b/tests/gui/test_commands.py @@ -11,14 +11,15 @@ import pytest pytest.importorskip("PySide6") -from otko.commands import ( +from otko.commands import ( # noqa: E402 AddElementsCommand, AddNodalLoadsCommand, AddNodesCommand, + DeleteElementsCommand, DeleteNodesCommand, SetRestraintCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 LinearTimeSeries, NodalLoad, Node, @@ -26,7 +27,7 @@ from otko.core import ( Steel01, TrussElement, ) -from otko.viewmodels import ProjectViewModel +from otko.viewmodels import ProjectViewModel # noqa: E402 # ─────────────────────────── helpers ──────────────────────────────── diff --git a/tests/gui/test_convert_element_type.py b/tests/gui/test_convert_element_type.py index 426c23b..4fa11ff 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 ( +from otko.commands import ( # noqa: E402 AddElementsCommand, AddMaterialsCommand, AddNodesCommand, AddSectionsCommand, ConvertElementTypeCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, ElasticUniaxial, Node, TrussElement, ) -from otko.viewmodels import ProjectViewModel +from otko.viewmodels import ProjectViewModel # noqa: E402 def _vm_setup() -> ProjectViewModel: diff --git a/tests/gui/test_define_dialogs.py b/tests/gui/test_define_dialogs.py index 4b6f9b3..57c1bfc 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 -from otko.views.dialogs.linear_time_series import LinearTimeSeriesDialog -from otko.views.dialogs.plain_pattern import PlainPatternDialog +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 @pytest.mark.gui diff --git a/tests/gui/test_draw_frame_tool.py b/tests/gui/test_draw_frame_tool.py index ff6370c..6161eb2 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 -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 +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 class _CanvasStub: diff --git a/tests/gui/test_draw_node_tool.py b/tests/gui/test_draw_node_tool.py index aab389d..d7d0023 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 -from otko.viewmodels import ProjectViewModel -from otko.views.canvas3d.selection import SelectionState -from otko.views.tools.draw_node import ( +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 DrawNodeTool, _snap_to_grid, ) diff --git a/tests/gui/test_draw_truss_tool.py b/tests/gui/test_draw_truss_tool.py index bcc2879..4c67164 100644 --- a/tests/gui/test_draw_truss_tool.py +++ b/tests/gui/test_draw_truss_tool.py @@ -6,17 +6,18 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( +from otko.core import ( # noqa: E402 CoordinateGridSystem, + CoordinateSystem, ElasticUniaxial, GridSystem, Node, TrussElement, make_grid_lines, ) -from otko.viewmodels import ProjectViewModel -from otko.views.canvas3d.selection import SelectionState -from otko.views.tools.draw_truss import DrawTrussTool +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 class _CanvasStub: diff --git a/tests/gui/test_grid_dialog.py b/tests/gui/test_grid_dialog.py index 0013ad4..c73e852 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 ( +from otko.core import ( # noqa: E402 CoordinateGridSystem, CoordinateSystem, GridLine, diff --git a/tests/gui/test_material_dialog_concrete04.py b/tests/gui/test_material_dialog_concrete04.py index 23523bc..4dd34d5 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 -from otko.views.dialogs.material_forms import ( - FORM_REGISTRY, +from otko.core import Concrete04 # noqa: E402 +from otko.views.dialogs.material_forms import ( # noqa: E402 Concrete04Form, + FORM_REGISTRY, ) diff --git a/tests/gui/test_material_form_ranges.py b/tests/gui/test_material_form_ranges.py index 54c1899..cb47096 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 ( +from otko.views.dialogs.material_forms import ( # noqa: E402 Concrete01Form, Concrete02Form, Steel01Form, diff --git a/tests/gui/test_path_ts_dialogs.py b/tests/gui/test_path_ts_dialogs.py index 5f1e132..7245068 100644 --- a/tests/gui/test_path_ts_dialogs.py +++ b/tests/gui/test_path_ts_dialogs.py @@ -8,16 +8,17 @@ import pytest pytest.importorskip("PySide6") -from otko.commands import ( +from otko.commands import ( # noqa: E402 AddLoadPatternCommand, AddTimeSeriesCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 + Node, PathTimeSeries, Project, UniformExcitationPattern, ) -from otko.viewmodels import ProjectViewModel +from otko.viewmodels import ProjectViewModel # noqa: E402 # ─────────────── AddTimeSeriesCommand / AddLoadPatternCommand ────────────── diff --git a/tests/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py deleted file mode 100644 index 3ef3601..0000000 --- a/tests/gui/test_plot_properties.py +++ /dev/null @@ -1,131 +0,0 @@ -"""GUI tests for Options → Plot Properties. - -Covers the dialog itself plus MainWindow's live-apply / persist / revert -wiring. QSettings are injected so nothing touches real user state. -""" - -from __future__ import annotations - -import pytest - -pytest.importorskip("PySide6") -pytest.importorskip("pyvistaqt") - -from PySide6.QtCore import QObject, QSettings, Signal -from PySide6.QtWidgets import QDialog - - -def _settings(tmp_path): # type: ignore[no-untyped-def] - return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) - - -def _window(tmp_path, qtbot): # type: ignore[no-untyped-def] - from otko.views.main_window import MainWindow - - window = MainWindow(settings=_settings(tmp_path)) - qtbot.addWidget(window) - return window - - -def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - from otko.views.canvas3d.style import RenderStyle - - window = _window(tmp_path, qtbot) - assert window._style == RenderStyle() - - -def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - window.swap_canvas_backend("plotly") # build the second canvas too - - window.set_plot_style( - window._style.with_updates(element_beam_color="#ff0000", label_font_size=20) - ) - - assert set(window._canvases) == {"pyvista", "plotly"} - for canvas in window._canvases.values(): - assert canvas._style.element_beam_color == "#ff0000" - assert canvas._style.label_font_size == 20 - - # A fresh window sharing the settings file reloads the customisation. - from otko.views.main_window import MainWindow - - fresh = MainWindow(settings=_settings(tmp_path)) - qtbot.addWidget(fresh) - assert fresh._style.element_beam_color == "#ff0000" - assert fresh._style.label_font_size == 20 - - -def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False) - - assert window._canvas._style.node_color == "#010203" - assert window._settings.value("plot/props") is None - - -def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - original = window._style - preview = original.with_updates(node_color="#010203") - - class _RejectingDialog(QObject): - changed = Signal(object) - - def __init__(self, style: object, parent: QObject | None = None) -> None: - super().__init__(parent) - self._style = preview - - def exec(self) -> QDialog.DialogCode: - self.changed.emit(preview) # live preview happens... - return QDialog.DialogCode.Rejected # ...but the user cancels - - def plot_style(self): # type: ignore[no-untyped-def] - return self._style - - monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog) - window._on_plot_properties() - - assert window._style == original - assert window._canvas._style.node_color == original.node_color - assert window._settings.value("plot/props") is None - - -def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] - window = _window(tmp_path, qtbot) - preview = window._style.with_updates(node_color="#010203") - - class _AcceptingDialog(QObject): - changed = Signal(object) - - def __init__(self, style: object, parent: QObject | None = None) -> None: - super().__init__(parent) - self._style = preview - - def exec(self) -> QDialog.DialogCode: - return QDialog.DialogCode.Accepted - - def plot_style(self): # type: ignore[no-untyped-def] - return self._style - - monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog) - window._on_plot_properties() - - assert window._style.node_color == "#010203" - assert window._canvas._style.node_color == "#010203" - assert "#010203" in str(window._settings.value("plot/props")) - - -def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] - from otko.views.canvas3d.style import RenderStyle - from otko.views.dialogs import PlotPropertiesDialog - - dialog = PlotPropertiesDialog(RenderStyle()) - qtbot.addWidget(dialog) - assert dialog.plot_style() == RenderStyle() - - dialog._colors["node_color"].set_color("#abcdef") - assert dialog.plot_style().node_color == "#abcdef" - - dialog._reset() - assert dialog.plot_style() == RenderStyle() diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py deleted file mode 100644 index 78d5160..0000000 --- a/tests/gui/test_plotly_hover.py +++ /dev/null @@ -1,93 +0,0 @@ -"""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 deleted file mode 100644 index 97ef3ee..0000000 --- a/tests/gui/test_plotly_view_preservation.py +++ /dev/null @@ -1,123 +0,0 @@ -"""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(