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}", - "name": "nodes", - "showlegend": False, - } - ) - - def _build_frames( - self, - project: Project, - data: list[dict[str, Any]], - opts: SceneOptions, - points: np.ndarray, - node_row: dict[int, int], - ) -> None: - """One line trace whose per-point ``line.color`` carries the palette. - - plotly accepts an *array* for ``Scatter3d.line.color`` mapped through - a ``colorscale``, so a single trace can colour every element - individually — by family and by selection — instead of one trace per - colour. That also leaves room to colour by response value later. - Each element's two endpoints (and its ``None`` separator) share one - colour index, so each drawn segment is a solid colour. - """ - if not len(points): - return - scale_colors = family_palette(self._style) - selected_index = len(scale_colors) - 1 - - x: list[float | None] = [] - y: list[float | None] = [] - z: list[float | None] = [] - color_index: list[float] = [] - customdata: list[Any] = [] - for el in project.elements: - if not isinstance(el, _FRAME_CLASSES): - continue - i = node_row.get(el.nodes[0]) - j = node_row.get(el.nodes[1]) - if i is None or j is None: - continue - index = float( - selected_index if el.id in opts.selection_elements else element_family_index(el) - ) - x.extend([float(points[i][0]), float(points[j][0]), None]) - y.extend([float(points[i][1]), float(points[j][1]), None]) - z.extend([float(points[i][2]), float(points[j][2]), None]) - color_index.extend([index, index, index]) - customdata.extend([el.id, el.id, None]) - if not x: - return - - count = len(scale_colors) - data.append( - { - "type": "scatter3d", - "mode": "lines", - "x": x, - "y": y, - "z": z, - "line": { - "color": color_index, - "colorscale": [ - (index / (count - 1), color) for index, color in enumerate(scale_colors) - ], - "cmin": 0, - "cmax": count - 1, - "width": 4, - }, - "customdata": customdata, - "meta": {"kind": "element"}, - "hovertemplate": "Element #%{customdata}", - "name": "elements", - "showlegend": False, - } - ) - - def _build_supports( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not project.nodes: - return - dof_idx = _dof_indices(project.ndf) - groups: dict[str, list[Any]] = {} - for node in project.nodes: - if not any(node.restraint[i] for i in dof_idx): - continue - kind = _classify_support(node.restraint, dof_idx) - groups.setdefault(kind, []).append(node) - for kind, nodes in groups.items(): - data.append( - { - "type": "scatter3d", - "mode": "markers", - "x": [float(n.coords[0]) for n in nodes], - "y": [float(n.coords[1]) for n in nodes], - "z": [float(n.coords[2]) for n in nodes], - "marker": { - "color": self._style.support_color, - "size": _SUPPORT_MARKER_SIZE, - "symbol": _SUPPORT_SYMBOLS[kind], - "line": {"color": "#7f3f00", "width": 1}, - }, - "hoverinfo": "skip", - "name": f"support-{kind}", - "showlegend": False, - } - ) - - # ── loads / axes / extrusions ──────────────────────────────────── - def _build_loads( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not project.load_patterns or not project.nodes: - return - node_by_id = {n.id: n for n in project.nodes} - elem_by_id = {e.id: e for e in project.elements} - pts = np.array([n.coords for n in project.nodes], dtype=float) - scale = max(_diag_of_points(pts) * 0.05, 1e-6) - - nodal_x: list[float] = [] - nodal_y: list[float] = [] - nodal_z: list[float] = [] - nodal_u: list[float] = [] - nodal_v: list[float] = [] - nodal_w: list[float] = [] - dist_x: list[float] = [] - dist_y: list[float] = [] - dist_z: list[float] = [] - dist_u: list[float] = [] - dist_v: list[float] = [] - dist_w: list[float] = [] - - for pattern in project.load_patterns: - if not isinstance(pattern, PlainLoadPattern): - continue - for nload in pattern.nodal_loads: - if not isinstance(nload, NodalLoad): - continue - node = node_by_id.get(nload.node_id) - if node is None: - continue - f = np.asarray(nload.forces[:3], dtype=float) - mag = float(np.linalg.norm(f)) - if mag < 1e-12: - continue - direction = f / mag - tail = np.asarray(node.coords, dtype=float) - direction * scale - nodal_x.append(float(tail[0])) - nodal_y.append(float(tail[1])) - nodal_z.append(float(tail[2])) - nodal_u.append(float(direction[0])) - nodal_v.append(float(direction[1])) - nodal_w.append(float(direction[2])) - - for eload in pattern.element_loads: - if not isinstance(eload, UniformElementLoad): - continue - elem = elem_by_id.get(eload.element_id) - if elem is None: - continue - node_i = node_by_id.get(elem.nodes[0]) - node_j = node_by_id.get(elem.nodes[1]) - if node_i is None or node_j is None: - continue - pi = np.asarray(node_i.coords, dtype=float) - pj = np.asarray(node_j.coords, dtype=float) - axis = pj - pi - length = float(np.linalg.norm(axis)) - if length < 1e-9: - continue - x_local = axis / length - z_global = np.array([0.0, 0.0, 1.0]) - y_local = np.cross(z_global, x_local) - if float(np.linalg.norm(y_local)) < 1e-6: - y_local = np.cross(np.array([0.0, 1.0, 0.0]), x_local) - y_local /= float(np.linalg.norm(y_local)) - z_local = np.cross(x_local, y_local) - load_vec = eload.wx * x_local + eload.wy * y_local + eload.wz * z_local - mag = float(np.linalg.norm(load_vec)) - if mag < 1e-12: - continue - direction = load_vec / mag - n_arrows = 5 - for k in range(n_arrows): - t = (k + 0.5) / n_arrows - tail = pi + t * axis - direction * (0.4 * scale) - dist_x.append(float(tail[0])) - dist_y.append(float(tail[1])) - dist_z.append(float(tail[2])) - dist_u.append(float(direction[0])) - dist_v.append(float(direction[1])) - dist_w.append(float(direction[2])) - - if nodal_x: - data.append( - _cone_trace( - nodal_x, - nodal_y, - nodal_z, - nodal_u, - nodal_v, - nodal_w, - color=self._style.nodal_load_color, - name="nodal-loads", - size=scale, - ) - ) - if dist_x: - data.append( - _cone_trace( - dist_x, - dist_y, - dist_z, - dist_u, - dist_v, - dist_w, - color=self._style.element_load_color, - name="element-loads", - size=0.6 * scale, - ) - ) - - def _build_local_axes( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not opts.show_local_axes or not project.nodes: - return - node_by_id = {n.id: n for n in project.nodes} - pts = np.array([n.coords for n in project.nodes], dtype=float) - cap = max(_diag_of_points(pts) * 0.08, 1e-6) - axes: dict[str, dict[str, list[float]]] = { - "x": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, - "y": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, - "z": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, - } - for el in project.elements: - if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement): - continue - if not isinstance( - el, - ElasticBeamColumn - | DispBeamColumn - | ForceBeamColumn - | BeamWithHingesElement - | TrussElement - | CorotTrussElement, - ): - continue - node_i = node_by_id.get(el.nodes[0]) - node_j = node_by_id.get(el.nodes[1]) - if node_i is None or node_j is None: - continue - pi = np.asarray(node_i.coords, dtype=float) - pj = np.asarray(node_j.coords, dtype=float) - axis = pj - pi - length = float(np.linalg.norm(axis)) - if length < 1e-9: - continue - x_local = axis / length - y_local, z_local = _frame_basis(el, x_local) - mid = (pi + pj) / 2.0 - for key, direction in (("x", x_local), ("y", y_local), ("z", z_local)): - bucket = axes[key] - bucket["x"].append(float(mid[0])) - bucket["y"].append(float(mid[1])) - bucket["z"].append(float(mid[2])) - bucket["u"].append(float(direction[0])) - bucket["v"].append(float(direction[1])) - bucket["w"].append(float(direction[2])) - for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")): - bucket = axes[key] - if bucket["x"]: - data.append( - _cone_trace( - bucket["x"], - bucket["y"], - bucket["z"], - bucket["u"], - bucket["v"], - bucket["w"], - color=color, - name=f"local-{key}", - size=cap, - ) - ) - - def _build_extrusions( - self, project: Project, data: list[dict[str, Any]], opts: SceneOptions - ) -> None: - if not opts.show_extrusions: - return - from otko.services.section_bbox import bbox_for_section - - node_by_id = {n.id: n for n in project.nodes} - section_by_id = {s.id: s for s in project.sections} - mesh = _Mesh() - for el in project.elements: - if not isinstance(el, _FRAME_CLASSES): - continue - if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement): - continue - section_id = getattr(el, "section_id", None) - if section_id is None: - continue - section = section_by_id.get(section_id) - if section is None: - continue - dims = bbox_for_section(section, project) - if dims is None: - continue - w_y, h_z = dims - if w_y <= 0 or h_z <= 0: - continue - node_i = node_by_id.get(el.nodes[0]) - node_j = node_by_id.get(el.nodes[1]) - if node_i is None or node_j is None: - continue - pi = np.asarray(node_i.coords, dtype=float) - pj = np.asarray(node_j.coords, dtype=float) - axis = pj - pi - length = float(np.linalg.norm(axis)) - if length < 1e-9: - continue - x_local = axis / length - y_local, z_local = _frame_basis(el, x_local) - mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z)) - if not mesh.is_empty: - data.append( - mesh.as_trace( - color=self._style.extrusion_color, - opacity=self._style.extrusion_opacity, - name="extrusions", - ) - ) - - @staticmethod - def _box_corners( - pi: np.ndarray, - x_local: np.ndarray, - y_local: np.ndarray, - z_local: np.ndarray, - length: float, - w_y: float, - h_z: float, - ) -> np.ndarray: - hy, hz = w_y / 2.0, h_z / 2.0 - offsets = np.array( - [ - [0.0, -hy, -hz], - [length, -hy, -hz], - [length, +hy, -hz], - [0.0, +hy, -hz], - [0.0, -hy, +hz], - [length, -hy, +hz], - [length, +hy, +hz], - [0.0, +hy, +hz], - ] - ) - basis = np.column_stack([x_local, y_local, z_local]) - return pi + offsets @ basis.T - - # ── labels / hover marker ──────────────────────────────────────── - def _build_labels( - self, - project: Project, - data: list[dict[str, Any]], - opts: SceneOptions, - points: np.ndarray, - node_row: dict[int, int], - ) -> None: - if opts.show_node_labels and len(points): - labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes] - data.append( - _text_trace( - points, labels, name="node-labels", font_size=self._style.label_font_size - ) - ) - if opts.show_element_labels and len(points): - centers: list[np.ndarray] = [] - labels: list[str] = [] - for el in project.elements: - if len(el.nodes) != 2: - continue - i = node_row.get(el.nodes[0]) - j = node_row.get(el.nodes[1]) - if i is None or j is None: - continue - centers.append((points[i] + points[j]) / 2.0) - labels.append(el.name.strip() if el.name.strip() else f"E{el.id}") - if centers: - data.append( - _text_trace( - np.asarray(centers), - labels, - name="element-labels", - font_size=self._style.label_font_size, - ) - ) - - @staticmethod - def _build_hover_marker(data: list[dict[str, Any]]) -> int: - data.append( - { - "type": "scatter3d", - "mode": "markers", - "x": [], - "y": [], - "z": [], - "marker": { - "color": "#ffd900", - "size": 13, - "line": {"color": "#8a6d00", "width": 1}, - }, - "hoverinfo": "skip", - "name": "snap-hover", - "showlegend": False, - "meta": {"kind": "hover"}, - } - ) - return len(data) - 1 - - -def _cone_trace( - x: list[float], - y: list[float], - z: list[float], - u: list[float], - v: list[float], - w: list[float], - *, - color: str, - name: str, - size: float, -) -> dict[str, Any]: - return { - "type": "cone", - "x": x, - "y": y, - "z": z, - "u": u, - "v": v, - "w": w, - "anchor": "tail", - "sizemode": "absolute", - "sizeref": float(size), - "colorscale": [[0, color], [1, color]], - "showscale": False, - "hoverinfo": "skip", - "name": name, - "showlegend": False, - } - - -def _text_trace( - points: np.ndarray, labels: list[str], *, name: str, font_size: int -) -> dict[str, Any]: - return { - "type": "scatter3d", - "mode": "text", - "x": [float(p[0]) for p in points], - "y": [float(p[1]) for p in points], - "z": [float(p[2]) for p in points], - "text": labels, - "textposition": "top center", - "textfont": {"size": font_size, "color": "#111111"}, - "hoverinfo": "skip", - "name": name, - "showlegend": False, - } - - -def _rgb_to_hex(rgb: tuple[float, float, float]) -> str: - r, g, b = (int(round(v * 255)) for v in rgb) - return f"#{r:02x}{g:02x}{b:02x}" diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py index e4dff02..d2a3b45 100644 --- a/src/otko/views/dialogs/__init__.py +++ b/src/otko/views/dialogs/__init__.py @@ -34,7 +34,6 @@ from otko.views.dialogs.move import MoveDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.pattern_loads import PatternLoadsDialog from otko.views.dialogs.plain_pattern import PlainPatternDialog -from otko.views.dialogs.plot_properties import PlotPropertiesDialog from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog from otko.views.dialogs.quick_guide import QuickGuideDialog from otko.views.dialogs.replicate import ReplicateDialog @@ -70,7 +69,6 @@ __all__ = [ "PathTimeSeriesDialog", "PatternLoadsDialog", "PlainPatternDialog", - "PlotPropertiesDialog", "QuickGridLinesDialog", "QuickGuideDialog", "ReplicateDialog", diff --git a/src/otko/views/dialogs/assign_load.py b/src/otko/views/dialogs/assign_load.py index 534ba69..9ab8c3c 100644 --- a/src/otko/views/dialogs/assign_load.py +++ b/src/otko/views/dialogs/assign_load.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import ( QDialogButtonBox, QDoubleSpinBox, QFormLayout, + QInputDialog, QLabel, QLineEdit, QVBoxLayout, diff --git a/src/otko/views/dialogs/assign_masses.py b/src/otko/views/dialogs/assign_masses.py index f87a710..6592e44 100644 --- a/src/otko/views/dialogs/assign_masses.py +++ b/src/otko/views/dialogs/assign_masses.py @@ -8,8 +8,6 @@ it maps cleanly onto our existing :class:`SetMassCommand`. from __future__ import annotations -import contextlib - from PySide6.QtWidgets import ( QCheckBox, QDialog, @@ -105,8 +103,10 @@ class AssignMassesDialog(QDialog): self._my.setValue(self._mx.value()) self._mx.valueChanged.connect(self._my.setValue) else: - with contextlib.suppress(RuntimeError, TypeError): + try: self._mx.valueChanged.disconnect(self._my.setValue) + except (RuntimeError, TypeError): + pass def mass_vector(self) -> tuple[float, float, float, float, float, float]: """Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz).""" diff --git a/src/otko/views/dialogs/assign_support.py b/src/otko/views/dialogs/assign_support.py index 8b3a5bc..b742ebe 100644 --- a/src/otko/views/dialogs/assign_support.py +++ b/src/otko/views/dialogs/assign_support.py @@ -8,6 +8,7 @@ model's actual ``ndf`` at translation time. from __future__ import annotations +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QButtonGroup, QCheckBox, @@ -21,6 +22,7 @@ from PySide6.QtWidgets import ( QWidget, ) + # Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz) PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = { "Free": (False, False, False, False, False, False), diff --git a/src/otko/views/dialogs/case_forms.py b/src/otko/views/dialogs/case_forms.py index d608992..b8f7b24 100644 --- a/src/otko/views/dialogs/case_forms.py +++ b/src/otko/views/dialogs/case_forms.py @@ -21,6 +21,7 @@ from PySide6.QtWidgets import ( QListWidget, QListWidgetItem, QSpinBox, + QVBoxLayout, QWidget, ) diff --git a/src/otko/views/dialogs/case_manager.py b/src/otko/views/dialogs/case_manager.py index 3ba5f29..000a90c 100644 --- a/src/otko/views/dialogs/case_manager.py +++ b/src/otko/views/dialogs/case_manager.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from pydantic import ValidationError from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, @@ -20,6 +19,7 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) +from pydantic import ValidationError from otko.commands import ( AddAnalysisCasesCommand, @@ -34,7 +34,7 @@ from otko.core import ( TransientCase, ) from otko.viewmodels import ProjectViewModel -from otko.views.dialogs.case_forms import form_for +from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for if TYPE_CHECKING: from otko.core import Project diff --git a/src/otko/views/dialogs/define_grid_data.py b/src/otko/views/dialogs/define_grid_data.py index 7440b04..8230650 100644 --- a/src/otko/views/dialogs/define_grid_data.py +++ b/src/otko/views/dialogs/define_grid_data.py @@ -18,8 +18,6 @@ Top-level controls: from __future__ import annotations -from typing import ClassVar - from PySide6.QtCore import Qt from PySide6.QtGui import QColor from PySide6.QtWidgets import ( @@ -29,6 +27,7 @@ from PySide6.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, @@ -53,6 +52,7 @@ from otko.core.geometry import ( GridSystem, ) + _LINE_TYPE_CHOICES = ["Primary", "Secondary"] _BUBBLE_LOC_CHOICES = ["Start", "End"] @@ -60,14 +60,7 @@ _BUBBLE_LOC_CHOICES = ["Start", "End"] class _AxisGridTable(QWidget): """A single X/Y/Z spreadsheet — one row per GridLine.""" - COLUMNS: ClassVar[list[str]] = [ - "Grid ID", - "Ordinate", - "Line Type", - "Visibility", - "Bubble Loc", - "Color", - ] + COLUMNS = ["Grid ID", "Ordinate", "Line Type", "Visibility", "Bubble Loc", "Color"] def __init__(self, axis: str, parent: QWidget | None = None) -> None: super().__init__(parent) diff --git a/src/otko/views/dialogs/grid_system.py b/src/otko/views/dialogs/grid_system.py index c1ac05c..3f39cf8 100644 --- a/src/otko/views/dialogs/grid_system.py +++ b/src/otko/views/dialogs/grid_system.py @@ -17,8 +17,8 @@ from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QFormLayout, - QLabel, QLineEdit, + QLabel, QVBoxLayout, QWidget, ) diff --git a/src/otko/views/dialogs/locate_origin.py b/src/otko/views/dialogs/locate_origin.py index 28a8205..21708e7 100644 --- a/src/otko/views/dialogs/locate_origin.py +++ b/src/otko/views/dialogs/locate_origin.py @@ -13,6 +13,7 @@ from PySide6.QtWidgets import ( QDoubleSpinBox, QFormLayout, QGroupBox, + QHBoxLayout, QLabel, QVBoxLayout, QWidget, diff --git a/src/otko/views/dialogs/material_library.py b/src/otko/views/dialogs/material_library.py index 371a507..ace776e 100644 --- a/src/otko/views/dialogs/material_library.py +++ b/src/otko/views/dialogs/material_library.py @@ -7,9 +7,9 @@ committed. Each Add/Edit/Delete is its own undoable step. from __future__ import annotations -from pydantic import ValidationError from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, @@ -23,6 +23,7 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) +from pydantic import ValidationError from otko.commands import ( AddMaterialsCommand, diff --git a/src/otko/views/dialogs/path_time_series.py b/src/otko/views/dialogs/path_time_series.py index 66a6b34..476b5d4 100644 --- a/src/otko/views/dialogs/path_time_series.py +++ b/src/otko/views/dialogs/path_time_series.py @@ -120,7 +120,7 @@ class PathTimeSeriesDialog(QDialog): return try: dt, npts, vals = parse_peer_record(fname) - except Exception as exc: + except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, "PEER import failed", str(exc)) return self._values = vals @@ -142,7 +142,7 @@ class PathTimeSeriesDialog(QDialog): return try: vals = parse_plain_values(fname) - except Exception as exc: + except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, "Import failed", str(exc)) return self._values = vals diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py deleted file mode 100644 index ae249ef..0000000 --- a/src/otko/views/dialogs/plot_properties.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`. - -The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``), -so adding an editable property is a one-line change there plus a default on -the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed` -so the canvas can preview them live; ``style()`` returns the composed result. -""" - -from __future__ import annotations - -from PySide6.QtCore import Signal -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QCheckBox, - QColorDialog, - QDialog, - QDialogButtonBox, - QDoubleSpinBox, - QFormLayout, - QLabel, - QPushButton, - QSpinBox, - QVBoxLayout, - QWidget, -) - -from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle - - -class _ColorButton(QPushButton): - """A swatch that opens :class:`QColorDialog` and reports the hex value.""" - - colorChanged = Signal(str) - - def __init__(self, color: str, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._color = color - self.setFixedWidth(96) - self._refresh() - self.clicked.connect(self._pick) - - def color(self) -> str: - """Current colour as a ``#rrggbb`` string.""" - return self._color - - def set_color(self, color: str) -> None: - """Set the swatch colour (does not emit :attr:`colorChanged`).""" - self._color = color - self._refresh() - - def _refresh(self) -> None: - self.setText(self._color.upper()) - self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};") - - def _text_color(self) -> str: - color = QColor(self._color) - luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue() - return "#000000" if luminance > 140 else "#ffffff" - - def _pick(self) -> None: - chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour") - if chosen.isValid(): - self.set_color(chosen.name()) - self.colorChanged.emit(self._color) - - -class PlotPropertiesDialog(QDialog): - """Modeless-free editor for the persisted plot style.""" - - #: Emitted with the composed :class:`RenderStyle` on every edit. - changed = Signal(object) - - def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setWindowTitle("Plot Properties") - self._base = style - self._colors: dict[str, _ColorButton] = {} - self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {} - self._checks: dict[str, QCheckBox] = {} - - layout = QVBoxLayout(self) - form = QFormLayout() - for name, label, kind in STYLE_FIELDS: - widget = self._build_widget(name, getattr(style, name), kind) - form.addRow(QLabel(f"{label}:"), widget) - layout.addLayout(form) - - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - ) - reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole) - assert reset is not None - reset.clicked.connect(self._reset) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - # ── public ────────────────────────────────────────────────────── - def plot_style(self) -> RenderStyle: - """The composed style for the current widget values. - - Named ``plot_style`` (not ``style``) because :meth:`QWidget.style` - already exists and must keep its Qt meaning. - """ - updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()} - updates.update({name: w.value() for name, w in self._numbers.items()}) - updates.update({name: w.isChecked() for name, w in self._checks.items()}) - return self._base.with_updates(**updates) - - # ── internals ─────────────────────────────────────────────────── - def _build_widget(self, name: str, value: object, kind: str) -> QWidget: - if kind == "color": - button = _ColorButton(str(value)) - button.colorChanged.connect(self._emit_changed) - self._colors[name] = button - return button - if kind == "float": - spin = QDoubleSpinBox() - spin.setRange(0.0, 1.0) - spin.setSingleStep(0.05) - spin.setDecimals(2) - spin.setValue(float(value)) # type: ignore[arg-type] - spin.valueChanged.connect(self._emit_changed) - self._numbers[name] = spin - return spin - if kind == "bool": - check = QCheckBox() - check.setChecked(bool(value)) - check.toggled.connect(self._emit_changed) - self._checks[name] = check - return check - int_spin = QSpinBox() - int_spin.setRange(6, 32) - int_spin.setValue(int(value)) # type: ignore[call-overload] - int_spin.valueChanged.connect(self._emit_changed) - self._numbers[name] = int_spin - return int_spin - - def _emit_changed(self, *_args: object) -> None: - self.changed.emit(self.plot_style()) - - def _reset(self) -> None: - defaults = RenderStyle() - for name, button in self._colors.items(): - button.set_color(getattr(defaults, name)) - for name, spin in self._numbers.items(): - spin.setValue(getattr(defaults, name)) - for name, check in self._checks.items(): - check.setChecked(getattr(defaults, name)) - self._emit_changed() diff --git a/src/otko/views/dialogs/quick_grid_lines.py b/src/otko/views/dialogs/quick_grid_lines.py index 7928d60..23d3b61 100644 --- a/src/otko/views/dialogs/quick_grid_lines.py +++ b/src/otko/views/dialogs/quick_grid_lines.py @@ -11,6 +11,7 @@ from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QDoubleSpinBox, + QFormLayout, QGroupBox, QHBoxLayout, QLabel, diff --git a/src/otko/views/dialogs/quick_guide.py b/src/otko/views/dialogs/quick_guide.py index 9b0c424..85daf78 100644 --- a/src/otko/views/dialogs/quick_guide.py +++ b/src/otko/views/dialogs/quick_guide.py @@ -29,7 +29,7 @@ with Define → Add Plain Load Pattern… or Define → Add Uniform Excitation… for seismic input.

4 — Draw the model

-

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(, )`` call.""" - body = call[len("window.otkoUpdate(") : -1] - literal, _sep, flag = body.rpartition(", ") - return json.loads(json.loads(literal)), flag == "true" - - -@pytest.mark.gui -def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignore[no-untyped-def] - canvas, calls = _canvas_with_captured_js(qtbot) - - canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) - - assert calls, "show_project must push a figure" - payload, preserve = _parse_call(calls[-1]) - assert preserve is False, "initial push must be a re-frame" - # The framing push carries the camera and the padded ranges. - scene = payload["layout"]["scene"] - assert "camera" in scene - assert "range" in scene["xaxis"] - assert scene["xaxis"]["autorange"] is False - - -@pytest.mark.gui -def test_data_update_preserves_the_view(qtbot) -> None: # type: ignore[no-untyped-def] - canvas, calls = _canvas_with_captured_js(qtbot) - - canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) - calls.clear() - - canvas.selection.select_node(1) # data-only update - - assert calls, "selection change must push" - payload, preserve = _parse_call(calls[-1]) - assert preserve is True, "data update must preserve the view" - assert "camera" not in payload["layout"]["scene"], "must not re-send the camera" - - -@pytest.mark.gui -def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-untyped-def] - canvas, calls = _canvas_with_captured_js(qtbot) - - canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) - - for action in (canvas.view_xy, canvas.view_xz, canvas.view_yz, canvas.reset_camera): - calls.clear() - action() - assert calls[-1].endswith(", false)"), f"{action.__name__} must re-frame" - - -@pytest.mark.gui -def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # type: ignore[no-untyped-def] - """Re-colouring must not snap the camera back to the preset.""" - from otko.views.canvas3d.style import RenderStyle - - canvas, calls = _canvas_with_captured_js(qtbot) - canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) - calls.clear() - - canvas.set_style(RenderStyle(show_axis_outline=True)) - - assert calls, "a style change must push" - payload, preserve = _parse_call(calls[-1]) - assert preserve is True, "colour changes must not re-frame the view" - assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied - - -@pytest.mark.gui -def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def] - """A plotly failure inside the page must not be invisible from Python.""" - from PySide6.QtWebEngineCore import QWebEnginePage - - from otko.views.canvas_plotly.plotly_canvas import _LoggingPage - - messages: list[tuple[str, str]] = [] - page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg))) - - levels = QWebEnginePage.JavaScriptConsoleMessageLevel - page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html") - page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html") - page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "") - - assert messages == [ - ("error", "[web] plot.html:7 boom"), - ("warning", "[web] plot.html:8 careful"), - ("info", "[web] plotly:9 hello"), - ] diff --git a/tests/gui/test_property_commands.py b/tests/gui/test_property_commands.py index 8a49e26..a325b87 100644 --- a/tests/gui/test_property_commands.py +++ b/tests/gui/test_property_commands.py @@ -6,7 +6,7 @@ import pytest pytest.importorskip("PySide6") -from otko.commands import ( +from otko.commands import ( # noqa: E402 AddElementsCommand, AddMaterialsCommand, AddNodesCommand, @@ -16,7 +16,7 @@ from otko.commands import ( UpdateMaterialCommand, UpdateSectionCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, Node, @@ -24,7 +24,7 @@ from otko.core import ( Steel02, TrussElement, ) -from otko.viewmodels import ProjectViewModel +from otko.viewmodels import ProjectViewModel # noqa: E402 def _vm_with_steel() -> ProjectViewModel: diff --git a/tests/gui/test_property_forms.py b/tests/gui/test_property_forms.py index aed5ece..3943498 100644 --- a/tests/gui/test_property_forms.py +++ b/tests/gui/test_property_forms.py @@ -10,10 +10,13 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( +from otko.core import ( # noqa: E402 + Concrete01, Concrete02, ElasticIsotropic, + ElasticPP, ElasticSection, + ElasticUniaxial, Steel01, Steel02, ) @@ -97,14 +100,10 @@ def test_elastic_section_form_round_trip(qtbot) -> None: # type: ignore[no-unty def test_form_for_dispatches_by_type(qtbot) -> None: # type: ignore[no-untyped-def] from otko.views.dialogs.material_forms import ( Steel01Form, - ) - from otko.views.dialogs.material_forms import ( form_for as material_form_for, ) from otko.views.dialogs.section_forms import ( ElasticSectionForm, - ) - from otko.views.dialogs.section_forms import ( form_for as section_form_for, ) diff --git a/tests/gui/test_pushover_curve_units.py b/tests/gui/test_pushover_curve_units.py index 9aee331..03af4bb 100644 --- a/tests/gui/test_pushover_curve_units.py +++ b/tests/gui/test_pushover_curve_units.py @@ -7,9 +7,9 @@ import pytest pytest.importorskip("PySide6") -from otko.core import UnitSystem -from otko.services.results import PushoverResults -from otko.views.docks.pushover_curve import ( +from otko.core import UnitSystem # noqa: E402 +from otko.services.results import PushoverResults # noqa: E402 +from otko.views.docks.pushover_curve import ( # noqa: E402 PushoverCurveView, _is_rotation_dof, ) diff --git a/tests/gui/test_run_analysis.py b/tests/gui/test_run_analysis.py index 5281fd8..03319e5 100644 --- a/tests/gui/test_run_analysis.py +++ b/tests/gui/test_run_analysis.py @@ -8,11 +8,11 @@ import pytest pytest.importorskip("PySide6") -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import QObject, Signal # noqa: E402 -from otko.core import LinearTimeSeries, PlainLoadPattern, TransientCase -from otko.viewmodels import ProjectViewModel -from otko.views.dialogs.run_analysis import RunAnalysisDialog +from otko.core import LinearTimeSeries, PlainLoadPattern, Project, TransientCase # noqa: E402 +from otko.viewmodels import ProjectViewModel # noqa: E402 +from otko.views.dialogs.run_analysis import RunAnalysisDialog # noqa: E402 class _FakeRunner(QObject): diff --git a/tests/gui/test_section_extrusion.py b/tests/gui/test_section_extrusion.py index 3f66eb4..ad21da0 100644 --- a/tests/gui/test_section_extrusion.py +++ b/tests/gui/test_section_extrusion.py @@ -6,7 +6,7 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, Node, diff --git a/tests/gui/test_section_forms.py b/tests/gui/test_section_forms.py index 4747cec..3c52f4d 100644 --- a/tests/gui/test_section_forms.py +++ b/tests/gui/test_section_forms.py @@ -7,14 +7,14 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( +from otko.core import ( # noqa: E402 AggregatorDOF, ElasticSection, FiberSection, RectangularPatch, SectionAggregator, ) -from otko.views.dialogs.section_forms import ( +from otko.views.dialogs.section_forms import ( # noqa: E402 FORM_REGISTRY, form_for, ) diff --git a/tests/gui/test_selection.py b/tests/gui/test_selection.py index 365c03c..c795493 100644 --- a/tests/gui/test_selection.py +++ b/tests/gui/test_selection.py @@ -6,7 +6,7 @@ import pytest pytest.importorskip("PySide6") -from otko.views.canvas3d import SelectionState +from otko.views.canvas3d import SelectionState # noqa: E402 @pytest.mark.gui diff --git a/tests/gui/test_transforms.py b/tests/gui/test_transforms.py index fe13b70..4ad8716 100644 --- a/tests/gui/test_transforms.py +++ b/tests/gui/test_transforms.py @@ -6,15 +6,15 @@ import pytest pytest.importorskip("PySide6") -from otko.commands import ( +from otko.commands import ( # noqa: E402 AddElementsCommand, AddNodesCommand, MirrorCommand, MoveNodesCommand, ReplicateCommand, ) -from otko.core import Node, Steel01, TrussElement -from otko.viewmodels import ProjectViewModel +from otko.core import Node, Steel01, TrussElement # noqa: E402 +from otko.viewmodels import ProjectViewModel # noqa: E402 def _populated_vm() -> ProjectViewModel: diff --git a/tests/gui/test_tree_multi_select.py b/tests/gui/test_tree_multi_select.py index 235ee7e..9048600 100644 --- a/tests/gui/test_tree_multi_select.py +++ b/tests/gui/test_tree_multi_select.py @@ -6,9 +6,10 @@ import pytest pytest.importorskip("PySide6") -from PySide6.QtWidgets import QTreeWidget +from PySide6.QtCore import Qt # noqa: E402 +from PySide6.QtWidgets import QTreeWidget # noqa: E402 -from otko.core import Node +from otko.core import Node # noqa: E402 @pytest.mark.gui diff --git a/tests/gui/test_units_status_bar.py b/tests/gui/test_units_status_bar.py index 4eaaae1..b052ad4 100644 --- a/tests/gui/test_units_status_bar.py +++ b/tests/gui/test_units_status_bar.py @@ -6,7 +6,7 @@ import pytest pytest.importorskip("PySide6") -from otko.core import UnitSystem +from otko.core import UnitSystem # noqa: E402 @pytest.mark.gui diff --git a/tests/gui/test_update_element_fields.py b/tests/gui/test_update_element_fields.py index f83cd67..bdecc06 100644 --- a/tests/gui/test_update_element_fields.py +++ b/tests/gui/test_update_element_fields.py @@ -6,18 +6,18 @@ import pytest pytest.importorskip("PySide6") -from otko.commands import ( +from otko.commands import ( # noqa: E402 AddElementsCommand, AddMaterialsCommand, AddNodesCommand, UpdateElementFieldsCommand, ) -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticUniaxial, Node, TrussElement, ) -from otko.viewmodels import ProjectViewModel +from otko.viewmodels import ProjectViewModel # noqa: E402 def _vm_with_truss() -> ProjectViewModel: diff --git a/tests/gui/test_working_plane.py b/tests/gui/test_working_plane.py index 012c1c0..a0b59f0 100644 --- a/tests/gui/test_working_plane.py +++ b/tests/gui/test_working_plane.py @@ -7,7 +7,7 @@ import pytest pytest.importorskip("PySide6") -from otko.core import ( +from otko.core import ( # noqa: E402 CoordinateGridSystem, GridSystem, Project, diff --git a/tests/integration/test_basic_truss.py b/tests/integration/test_basic_truss.py index 20be03c..72ffaa3 100644 --- a/tests/integration/test_basic_truss.py +++ b/tests/integration/test_basic_truss.py @@ -11,7 +11,7 @@ import pytest pytest.importorskip("openseespy") -from otko.services.opensees_runner import OpenSeesRunner +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -25,7 +25,7 @@ def test_basic_truss_matches_opensees_tcl_reference() -> None: a linear rescale of the Tcl model — any deviation would flag a real solver/translation issue. """ - from examples.basic_truss import IN_TO_M, build_basic_truss + from examples.basic_truss import build_basic_truss, IN_TO_M proj = build_basic_truss() runner = OpenSeesRunner(proj) @@ -45,7 +45,6 @@ def test_basic_truss_matches_opensees_tcl_reference() -> None: def test_basic_truss_round_trips(tmp_path) -> None: # type: ignore[no-untyped-def] """The example project must survive save/load without any information loss.""" from examples.basic_truss import build_basic_truss - from otko.services import load_project, save_project p = build_basic_truss() diff --git a/tests/integration/test_beam_quad_2d.py b/tests/integration/test_beam_quad_2d.py index 4dc4a60..6c52b5b 100644 --- a/tests/integration/test_beam_quad_2d.py +++ b/tests/integration/test_beam_quad_2d.py @@ -9,8 +9,8 @@ import pytest pytest.importorskip("openseespy") -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_combinations.py b/tests/integration/test_combinations.py index 8ba5ca7..8a4dc21 100644 --- a/tests/integration/test_combinations.py +++ b/tests/integration/test_combinations.py @@ -8,7 +8,7 @@ import pytest ops = pytest.importorskip("openseespy.opensees") # skip if OpenSeesPy not installed -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, LinearTimeSeries, @@ -20,8 +20,8 @@ from otko.core import ( Project, StaticCase, ) -from otko.services import OpenSeesRunner, evaluate_combination -from otko.services.results import StaticResults +from otko.services import OpenSeesRunner, evaluate_combination # noqa: E402 +from otko.services.results import StaticResults # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_concrete04_runner.py b/tests/integration/test_concrete04_runner.py index 4293dea..054f462 100644 --- a/tests/integration/test_concrete04_runner.py +++ b/tests/integration/test_concrete04_runner.py @@ -18,7 +18,7 @@ import pytest pytest.importorskip("openseespy") -from otko.core import ( +from otko.core import ( # noqa: E402 Concrete04, FiberSection, ForceBeamColumn, @@ -32,7 +32,7 @@ from otko.core import ( StaticCase, UnitSystem, ) -from otko.services.opensees_runner import OpenSeesRunner +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_dof_coverage.py b/tests/integration/test_dof_coverage.py index 7e9e37e..cb20abb 100644 --- a/tests/integration/test_dof_coverage.py +++ b/tests/integration/test_dof_coverage.py @@ -11,7 +11,7 @@ import pytest pytest.importorskip("openseespy") -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticUniaxial, LinearTimeSeries, NodalLoad, @@ -21,7 +21,7 @@ from otko.core import ( StaticCase, TrussElement, ) -from otko.services.opensees_runner import OpenSeesRunner +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_eigen_two_storey_one_bay_frame.py b/tests/integration/test_eigen_two_storey_one_bay_frame.py index 83cd67e..49eb4cd 100644 --- a/tests/integration/test_eigen_two_storey_one_bay_frame.py +++ b/tests/integration/test_eigen_two_storey_one_bay_frame.py @@ -8,9 +8,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import ModalCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import ModalCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_eigen_two_storey_shear_frame.py b/tests/integration/test_eigen_two_storey_shear_frame.py index 793f98d..724ba40 100644 --- a/tests/integration/test_eigen_two_storey_shear_frame.py +++ b/tests/integration/test_eigen_two_storey_shear_frame.py @@ -8,9 +8,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import ModalCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import ModalCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_elastic_frame.py b/tests/integration/test_elastic_frame.py index 6f04163..03aa14c 100644 --- a/tests/integration/test_elastic_frame.py +++ b/tests/integration/test_elastic_frame.py @@ -8,9 +8,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import ModalCase, StaticCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import ModalCase, StaticCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -29,6 +29,7 @@ def _reload(proj, tmp_path): # type: ignore[no-untyped-def] def test_elastic_frame_gravity_reactions(tmp_path) -> None: # type: ignore[no-untyped-def] """ΣFy at base = total applied gravity (distributed w × beam × floors).""" from examples.elastic_frame import ( + BAY, LOAD_F1, LOAD_F2, LOAD_F3, @@ -69,6 +70,7 @@ def test_elastic_frame_gravity_reactions(tmp_path) -> None: # type: ignore[no-u def test_elastic_frame_gravity_plus_lateral_reactions(tmp_path) -> None: # type: ignore[no-untyped-def] """ΣFx at base must equal -(lateral applied) within PDelta tolerance.""" from examples.elastic_frame import ( + BAY, LOAD_F1, LOAD_F2, LOAD_F3, @@ -118,5 +120,5 @@ def test_elastic_frame_modal_periods(tmp_path) -> None: # type: ignore[no-untyp # 0.1930, 0.1562, 0.130 s. Our solve nails these within 1.5%. expected = [1.040, 0.3526, 0.1930, 0.1562, 0.130] periods = [2.0 * math.pi / math.sqrt(v) for v in r.eigenvalues] - for i, (T, T_ref) in enumerate(zip(periods, expected, strict=False), start=1): - assert pytest.approx(T_ref, rel=0.02) == T, f"T{i} = {T:.4f} s, reference {T_ref:.4f} s" + for i, (T, T_ref) in enumerate(zip(periods, expected), start=1): + assert T == pytest.approx(T_ref, rel=0.02), f"T{i} = {T:.4f} s, reference {T_ref:.4f} s" diff --git a/tests/integration/test_ex1a_canti2d.py b/tests/integration/test_ex1a_canti2d.py index a20d589..e0d0a1d 100644 --- a/tests/integration/test_ex1a_canti2d.py +++ b/tests/integration/test_ex1a_canti2d.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex1a_canti2d_eq.py b/tests/integration/test_ex1a_canti2d_eq.py index 264d30c..69ad31c 100644 --- a/tests/integration/test_ex1a_canti2d_eq.py +++ b/tests/integration/test_ex1a_canti2d_eq.py @@ -9,8 +9,8 @@ import pytest pytest.importorskip("openseespy") -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex1b_portal2d.py b/tests/integration/test_ex1b_portal2d.py index f47bdd2..324837e 100644 --- a/tests/integration/test_ex1b_portal2d.py +++ b/tests/integration/test_ex1b_portal2d.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex2a_canti2d_elastic_element.py b/tests/integration/test_ex2a_canti2d_elastic_element.py index 71bfb6d..3218683 100644 --- a/tests/integration/test_ex2a_canti2d_elastic_element.py +++ b/tests/integration/test_ex2a_canti2d_elastic_element.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex2b_canti2d_inelastic_section.py b/tests/integration/test_ex2b_canti2d_inelastic_section.py index 3766581..2909e21 100644 --- a/tests/integration/test_ex2b_canti2d_inelastic_section.py +++ b/tests/integration/test_ex2b_canti2d_inelastic_section.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py b/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py index 1cae060..86d6fa8 100644 --- a/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py +++ b/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex3_canti2d_variants.py b/tests/integration/test_ex3_canti2d_variants.py index 2486519..8007be3 100644 --- a/tests/integration/test_ex3_canti2d_variants.py +++ b/tests/integration/test_ex3_canti2d_variants.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_ex4_portal2d_variants.py b/tests/integration/test_ex4_portal2d_variants.py index d4943cb..e3abe5a 100644 --- a/tests/integration/test_ex4_portal2d_variants.py +++ b/tests/integration/test_ex4_portal2d_variants.py @@ -9,9 +9,9 @@ import pytest pytest.importorskip("openseespy") -from otko.core import PushoverCase, TransientCase -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.core import PushoverCase, TransientCase # noqa: E402 +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_moment_curvature.py b/tests/integration/test_moment_curvature.py index 12439d4..a73c4ec 100644 --- a/tests/integration/test_moment_curvature.py +++ b/tests/integration/test_moment_curvature.py @@ -8,11 +8,13 @@ behaviour is exercised by the Phase 9 pushover tests. from __future__ import annotations +import math + import pytest pytest.importorskip("openseespy") -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticUniaxial, FiberSection, LinearTimeSeries, @@ -24,7 +26,7 @@ from otko.core import ( StaticCase, ZeroLengthSectionElement, ) -from otko.services.opensees_runner import OpenSeesRunner +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -132,7 +134,7 @@ def test_pushover_drives_rotation_for_moment_curvature() -> None: # Every (κ, M) point must satisfy M = E·I·κ (1 % tolerance allows # for the ~20-fibre discretisation of the rectangular section). - for kappa, moment in zip(result.control_disp, result.base_shear, strict=False): + for kappa, moment in zip(result.control_disp, result.base_shear): if abs(kappa) < 1e-12: continue expected_M = E * I * kappa diff --git a/tests/integration/test_moment_curvature_example.py b/tests/integration/test_moment_curvature_example.py index 7648e51..f0bed1f 100644 --- a/tests/integration/test_moment_curvature_example.py +++ b/tests/integration/test_moment_curvature_example.py @@ -6,8 +6,8 @@ import pytest pytest.importorskip("openseespy") -from otko.services import load_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.services import load_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -15,13 +15,13 @@ pytestmark = pytest.mark.slow def test_moment_curvature_example_round_trips_and_converges(tmp_path) -> None: # type: ignore[no-untyped-def] """build_moment_curvature() → save → load → run → expected shape.""" from examples.moment_curvature import ( + build_moment_curvature, COL_DEPTH, COVER, E_STEEL, FY, MU, NUM_INCR, - build_moment_curvature, ) proj = build_moment_curvature() diff --git a/tests/integration/test_pattern_factors.py b/tests/integration/test_pattern_factors.py index 3c05c3e..58d58a1 100644 --- a/tests/integration/test_pattern_factors.py +++ b/tests/integration/test_pattern_factors.py @@ -8,7 +8,7 @@ import pytest ops = pytest.importorskip("openseespy.opensees") # skip if OpenSeesPy not installed -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, LinearTimeSeries, @@ -18,7 +18,7 @@ from otko.core import ( Project, StaticCase, ) -from otko.services import OpenSeesRunner +from otko.services import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_rc_frame_earthquake.py b/tests/integration/test_rc_frame_earthquake.py index 132535f..1d4d3dc 100644 --- a/tests/integration/test_rc_frame_earthquake.py +++ b/tests/integration/test_rc_frame_earthquake.py @@ -9,8 +9,8 @@ import pytest pytest.importorskip("openseespy") -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -18,8 +18,9 @@ pytestmark = pytest.mark.slow def test_rc_frame_earthquake_runs_and_has_oscillatory_response(tmp_path) -> None: # type: ignore[no-untyped-def] """Synthetic ground motion produces bounded, oscillatory response.""" from examples.rc_frame_earthquake import ( - N_PTS, build_rc_frame_earthquake, + DT, + N_PTS, ) proj = build_rc_frame_earthquake() diff --git a/tests/integration/test_rc_frame_gravity.py b/tests/integration/test_rc_frame_gravity.py index 0c91190..5250108 100644 --- a/tests/integration/test_rc_frame_gravity.py +++ b/tests/integration/test_rc_frame_gravity.py @@ -6,8 +6,8 @@ import pytest pytest.importorskip("openseespy") -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -22,7 +22,7 @@ def test_rc_frame_gravity_matches_opensees_reference(tmp_path) -> None: # type: The column axial force is 180 kip compression (from the 180 kip load stepped onto each top node). """ - from examples.rc_frame_gravity import P_LOAD, build_rc_frame_gravity + from examples.rc_frame_gravity import build_rc_frame_gravity, P_LOAD proj = build_rc_frame_gravity() proj.validate_references() diff --git a/tests/integration/test_rc_frame_pushover.py b/tests/integration/test_rc_frame_pushover.py index 5bd9ed0..89518c3 100644 --- a/tests/integration/test_rc_frame_pushover.py +++ b/tests/integration/test_rc_frame_pushover.py @@ -6,8 +6,8 @@ import pytest pytest.importorskip("openseespy") -from otko.services import load_project, save_project -from otko.services.opensees_runner import OpenSeesRunner +from otko.services import load_project, save_project # noqa: E402 +from otko.services.opensees_runner import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -19,11 +19,10 @@ def test_rc_frame_pushover_reaches_target_with_fallback(tmp_path) -> None: # ty AND shows expected nonlinear shape. """ from examples.rc_frame_pushover import ( + build_rc_frame_pushover, D_STEP, D_TARGET, - build_rc_frame_pushover, ) - from otko.core import PushoverCase proj = build_rc_frame_pushover() diff --git a/tests/integration/test_runner_imposed_motion.py b/tests/integration/test_runner_imposed_motion.py index 33c352b..54daa94 100644 --- a/tests/integration/test_runner_imposed_motion.py +++ b/tests/integration/test_runner_imposed_motion.py @@ -23,7 +23,7 @@ import pytest ops = pytest.importorskip("openseespy.opensees") h5py = pytest.importorskip("h5py") -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticUniaxial, ImposedSupportMotionPattern, Node, @@ -33,7 +33,7 @@ from otko.core import ( UniformExcitationPattern, ZeroLengthElement, ) -from otko.services import OpenSeesRunner +from otko.services import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_runner_modal.py b/tests/integration/test_runner_modal.py index bf2a843..670e682 100644 --- a/tests/integration/test_runner_modal.py +++ b/tests/integration/test_runner_modal.py @@ -15,16 +15,16 @@ import math import pytest -ops = pytest.importorskip("openseespy.opensees") +ops = pytest.importorskip("openseespy.opensees") # noqa: F401 -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, ModalCase, Node, Project, ) -from otko.services import OpenSeesRunner +from otko.services import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_runner_static.py b/tests/integration/test_runner_static.py index bd9cac1..5722c0b 100644 --- a/tests/integration/test_runner_static.py +++ b/tests/integration/test_runner_static.py @@ -12,7 +12,7 @@ import pytest ops = pytest.importorskip("openseespy.opensees") # skip if OpenSeesPy not installed -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, LinearTimeSeries, @@ -22,7 +22,7 @@ from otko.core import ( Project, StaticCase, ) -from otko.services import OpenSeesRunner +from otko.services import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow diff --git a/tests/integration/test_runner_transient.py b/tests/integration/test_runner_transient.py index a2be365..4715153 100644 --- a/tests/integration/test_runner_transient.py +++ b/tests/integration/test_runner_transient.py @@ -12,10 +12,10 @@ import math import numpy as np import pytest -ops = pytest.importorskip("openseespy.opensees") -h5py = pytest.importorskip("h5py") +ops = pytest.importorskip("openseespy.opensees") # noqa: F401 +h5py = pytest.importorskip("h5py") # noqa: F401 -from otko.core import ( +from otko.core import ( # noqa: E402 ConstantTimeSeries, ElasticBeamColumn, ElasticSection, @@ -27,7 +27,7 @@ from otko.core import ( StaticCase, TransientCase, ) -from otko.services import OpenSeesRunner +from otko.services import OpenSeesRunner # noqa: E402 pytestmark = pytest.mark.slow @@ -51,6 +51,7 @@ def test_sdof_free_vibration_matches_cosine(tmp_path) -> None: # type: ignore[n # (already done), then verify dt-step Newmark integration of free # vibration starting from a static IC. F0 = 100.0 + u0 = F0 / k project = Project( ndm=2, diff --git a/tests/services/test_element_forces.py b/tests/services/test_element_forces.py index 2f57387..ad03cda 100644 --- a/tests/services/test_element_forces.py +++ b/tests/services/test_element_forces.py @@ -19,6 +19,7 @@ from otko.services.element_forces import ( ) from otko.services.results import StaticResults + # ────────────────────────────────────────────────────────────────────── # Fixtures: a tiny 3D project with 2 elements + canned force results. # ────────────────────────────────────────────────────────────────────── diff --git a/tests/services/test_runner_translation.py b/tests/services/test_runner_translation.py index 1903183..1bbee24 100644 --- a/tests/services/test_runner_translation.py +++ b/tests/services/test_runner_translation.py @@ -23,6 +23,7 @@ from otko.core import ( Node, PlainLoadPattern, Project, + StaticCase, Steel01, TrussElement, ZeroLengthElement, diff --git a/tests/services/test_section_bbox.py b/tests/services/test_section_bbox.py index 25f6b24..43ab0c0 100644 --- a/tests/services/test_section_bbox.py +++ b/tests/services/test_section_bbox.py @@ -10,6 +10,7 @@ from otko.core import ( CircularPatch, ElasticSection, FiberSection, + Project, RectangularPatch, StraightLayer, ) diff --git a/tests/tools/test_parse_schemas.py b/tests/tools/test_parse_schemas.py index 506496c..e1159aa 100644 --- a/tests/tools/test_parse_schemas.py +++ b/tests/tools/test_parse_schemas.py @@ -13,7 +13,6 @@ import json from pathlib import Path import pytest - from tools.gidopensees_import.parse_schemas import ( ParseError, _parse_dependencies, diff --git a/tests/unit/core/materials/test_concrete04.py b/tests/unit/core/materials/test_concrete04.py index 5ea8311..52142a0 100644 --- a/tests/unit/core/materials/test_concrete04.py +++ b/tests/unit/core/materials/test_concrete04.py @@ -136,7 +136,6 @@ class TestConcrete04JsonRoundTrip: def test_old_osmodel_without_concrete04_loads_cleanly(self) -> None: """An osmodel payload that doesn't mention Concrete04 is unaffected.""" from pathlib import Path - from otko.services import load_project osmodel = Path(__file__).parents[4] / "examples" / "cantilever.osmodel" diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py index 7b3f95c..9281e67 100644 --- a/tests/unit/test_architecture.py +++ b/tests/unit/test_architecture.py @@ -2,12 +2,11 @@ The documented dependency stack is ``views -> viewmodels -> services -> core``; each layer may only import from itself or a lower layer, ``core`` stays free of -Qt/solver/plotting libraries, and the rendering backends are confined to the -canvas packages (``views/canvas3d``, ``views/canvas_plotly``). These tests never -import the application modules; they read the source tree with ``ast`` so the -import discipline can be checked without executing Qt or the solver. -Directories that do not exist yet simply have nothing to walk, which trivially -satisfies a "forbidden import" rule. +Qt/solver/plotting libraries, and the rendering backend is confined to +``views/canvas3d``. These tests never import the application modules; they read +the source tree with ``ast`` so the import discipline can be checked without +executing Qt or the solver. Directories that do not exist yet simply have +nothing to walk, which trivially satisfies a "forbidden import" rule. """ from __future__ import annotations @@ -17,10 +16,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SRC = ROOT / "src" / "otko" -CANVAS_DIRS = ( - SRC / "views" / "canvas3d", - SRC / "views" / "canvas_plotly", -) +CANVAS = SRC / "views" / "canvas3d" # Qt binding roots, in every spelling the project might use. QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"} @@ -36,7 +32,7 @@ CORE_FORBIDDEN = QT_ROOTS | { "matplotlib", } -# Only the canvas packages may touch a 3D or plotting backend. +# Only views/canvas3d/ may touch a 3D or plotting backend. VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"} @@ -79,14 +75,14 @@ def _violations( forbidden: set[str], *, skip: tuple[Path, ...] = (), - exclude_dirs: tuple[Path, ...] = (), + exclude_dir: Path | None = None, ) -> list[str]: """Return ``": "`` entries for every forbidden import found.""" found: list[str] = [] for path in _iter_python_files(root): if path in skip: continue - if any(_is_within(path, directory) for directory in exclude_dirs): + if exclude_dir is not None and _is_within(path, exclude_dir): continue for module in sorted(_root_imports(path) & forbidden): found.append(f"{path.relative_to(ROOT)}: {module}") @@ -116,9 +112,9 @@ def test_architecture_views_no_solver() -> None: def test_no_vtk_import_outside_canvas() -> None: - """3D/plot backends stay under the canvas packages.""" - violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS) - assert violations == [], f"backend imports outside the canvas packages: {violations}" + """3D/plot backends stay under views/canvas3d/.""" + violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS) + assert violations == [], f"backend imports outside views/canvas3d/: {violations}" # The documented dependency stack; ``commands`` sits beside viewmodels (a VM diff --git a/tests/unit/test_diagram_renderer.py b/tests/unit/test_diagram_renderer.py index 425606b..cf6ae38 100644 --- a/tests/unit/test_diagram_renderer.py +++ b/tests/unit/test_diagram_renderer.py @@ -15,21 +15,21 @@ import pytest # Force pyvista off-screen before any pyvista import in this module's chain. os.environ.setdefault("PYVISTA_OFF_SCREEN", "true") -import pyvista as pv +import pyvista as pv # noqa: E402 -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, Node, Project, ) -from otko.services.element_forces import ( +from otko.services.element_forces import ( # noqa: E402 DiagramData, ForceComponent, extract_diagram_data, ) -from otko.services.results import StaticResults -from otko.views.canvas3d.diagram_renderer import DiagramRenderer +from otko.services.results import StaticResults # noqa: E402 +from otko.views.canvas3d.diagram_renderer import DiagramRenderer # noqa: E402 pv.OFF_SCREEN = True diff --git a/tests/unit/test_elements.py b/tests/unit/test_elements.py index e3c971a..11987c6 100644 --- a/tests/unit/test_elements.py +++ b/tests/unit/test_elements.py @@ -6,8 +6,8 @@ import pytest from pydantic import TypeAdapter, ValidationError from otko.core import ( - ElasticBeamColumn, Element, + ElasticBeamColumn, ForceBeamColumn, TrussElement, ZeroLengthElement, diff --git a/tests/unit/test_grid_system.py b/tests/unit/test_grid_system.py index 6688dce..adcbb55 100644 --- a/tests/unit/test_grid_system.py +++ b/tests/unit/test_grid_system.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from pathlib import Path import pytest @@ -173,7 +174,7 @@ def test_coord_system_round_trip_world_local() -> None: p_local = (1.5, -0.5, 2.0) p_world = cs.local_to_world(p_local) p_back = cs.world_to_local(p_world) - for a, b in zip(p_local, p_back, strict=False): + for a, b in zip(p_local, p_back): assert a == pytest.approx(b, abs=1e-9) diff --git a/tests/unit/test_phase8a.py b/tests/unit/test_phase8a.py index c9a3842..0e67780 100644 --- a/tests/unit/test_phase8a.py +++ b/tests/unit/test_phase8a.py @@ -8,6 +8,7 @@ from __future__ import annotations +import numpy as np import pytest from otko.commands.nodes import SetMassCommand diff --git a/tests/unit/test_phase9.py b/tests/unit/test_phase9.py index 24b38b9..04af175 100644 --- a/tests/unit/test_phase9.py +++ b/tests/unit/test_phase9.py @@ -10,7 +10,9 @@ import pytest from otko.core import ( AggregatorDOF, CircularPatch, + ElasticSection, FiberSection, + Fibre, Project, RectangularPatch, SectionAggregator, @@ -153,7 +155,7 @@ def test_Iz_of_rect_matches_analytical() -> None: # ── Round-trip persistence ─────────────────────────────────────────── def test_fiber_section_with_patches_round_trips(tmp_path) -> None: # type: ignore[no-untyped-def] - from otko.core import ElasticBeamColumn, Node + from otko.core import Node, ElasticBeamColumn p = Project( nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))], diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py deleted file mode 100644 index 45b2b21..0000000 --- a/tests/unit/test_plotly_trace_builder.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Unit tests for the pure Plotly trace builder (no Qt, no plotly import).""" - -from __future__ import annotations - -import json -from pathlib import Path - -import numpy as np -import pytest - -from otko.services import load_project -from otko.views.canvas3d.style import RenderStyle -from otko.views.canvas_plotly.trace_builder import ( - PlotlyTraceBuilder, - SceneOptions, -) - -EXAMPLES = Path(__file__).resolve().parents[2] / "examples" - - -def _load(name: str): # type: ignore[no-untyped-def] - return load_project(EXAMPLES / f"{name}.osmodel") - - -def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def] - return [trace for trace in scene.data if trace.get("name") == name] - - -def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def] - return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data] - - -def test_builds_grid_nodes_and_frames() -> None: - scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) - names = {trace.get("name") for trace in scene.data} - assert "grid-active" in names - assert "nodes" in names - assert "elements" in names - assert scene.diagonal > 0 - # The hover-snap marker is always present so JS can restyle it. - assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover" - - -def test_nodes_carry_ids_as_customdata() -> None: - project = _load("basic_truss") - scene = PlotlyTraceBuilder().build(project, SceneOptions()) - (nodes,) = _traces(scene, "nodes") - assert nodes["meta"]["kind"] == "node" - assert nodes["customdata"] == [node.id for node in project.nodes] - assert len(nodes["marker"]["color"]) == len(project.nodes) - - -def _color_for_index(trace: dict, index: float) -> str: - """Map a ``line.color`` scalar index to its colour via the colorscale.""" - scale = sorted(trace["line"]["colorscale"], key=lambda item: item[0]) - return scale[int(index)][1] - - -def test_frames_are_one_trace_with_per_element_colours() -> None: - """A single line trace carries the palette through its ``line.color`` array.""" - project = _load("cantilever") - style = RenderStyle() - scene = PlotlyTraceBuilder(style).build( - project, SceneOptions(selection_elements=frozenset({2})) - ) - frames = _traces(scene, "elements") - assert len(frames) == 1, "selection must not split the frame trace" - line = frames[0]["line"] - assert isinstance(line["color"], list) - assert len(line["color"]) == len(frames[0]["x"]) - assert line["colorscale"] - - colors = line["color"] - customdata = frames[0]["customdata"] - - def index_of(element_id: int) -> set[float]: - return {colors[k] for k, value in enumerate(customdata) if value == element_id} - - selected = index_of(2) - unselected = index_of(1) - assert len(selected) == 1 and len(unselected) == 1 - assert selected != unselected - assert _color_for_index(frames[0], next(iter(selected))) == style.selected_color - assert _color_for_index(frames[0], next(iter(unselected))) == style.element_beam_color - - -def test_element_palette_distinguishes_beam_from_truss() -> None: - style = RenderStyle() - builder = PlotlyTraceBuilder(style) - beam = _traces(builder.build(_load("cantilever"), SceneOptions()), "elements")[0] - truss = _traces(builder.build(_load("basic_truss"), SceneOptions()), "elements")[0] - assert _color_for_index(beam, beam["line"]["color"][0]) == style.element_beam_color - assert _color_for_index(truss, truss["line"]["color"][0]) == style.element_truss_color - - -def test_selected_node_is_highlighted_by_colour() -> None: - project = _load("basic_truss") - target = project.nodes[1].id - scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_nodes=frozenset({target}))) - (nodes,) = _traces(scene, "nodes") - ids = nodes["customdata"] - assert nodes["marker"]["color"][ids.index(target)] != nodes["marker"]["color"][0] - - -def test_working_plane_filters_grid_segments() -> None: - project = _load("basic_truss") - builder = PlotlyTraceBuilder() - iso = builder.build(project, SceneOptions()) - plane = builder.build(project, SceneOptions(working_plane=("XY", 0.0))) - (iso_grid,) = _traces(iso, "grid-active") - (plane_grid,) = _traces(plane, "grid-active") - assert len(plane_grid["x"]) <= len(iso_grid["x"]) - - -def test_snap_targets_carry_world_coordinates() -> None: - scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) - (snap,) = _traces(scene, "snap") - assert snap["meta"]["kind"] == "snap" - assert snap["customdata"], "expected at least one snappable intersection" - assert all(len(point) == 3 for point in snap["customdata"]) - assert all(isinstance(coord, float) for point in snap["customdata"] for coord in point) - - -def test_extrusions_add_a_mesh3d_trace() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions(show_extrusions=True)) - (mesh,) = _traces(scene, "extrusions") - assert mesh["type"] == "mesh3d" - assert mesh["i"] and mesh["j"] and mesh["k"] - assert max(mesh["i"]) < len(mesh["x"]) - - -def test_local_axes_add_coloured_cones() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions(show_local_axes=True)) - names = {trace.get("name") for trace in scene.data} - assert {"local-x", "local-y", "local-z"} <= names - for trace in scene.data: - if trace.get("name", "").startswith("local-"): - assert trace["type"] == "cone" - assert len(trace["u"]) == len(trace["x"]) > 0 - - -def test_labels_traces_are_text_only() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build( - project, SceneOptions(show_node_labels=True, show_element_labels=True) - ) - (node_labels,) = _traces(scene, "node-labels") - (element_labels,) = _traces(scene, "element-labels") - assert node_labels["mode"] == "text" - assert len(node_labels["text"]) == len(project.nodes) - assert element_labels["mode"] == "text" - - -def test_deformation_shifts_node_coordinates() -> None: - project = _load("cantilever") - - class _Shift: - def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray: - out = points.copy() - out[:, 2] += 1.0 - return out - - base = PlotlyTraceBuilder().build(project, SceneOptions()) - moved = PlotlyTraceBuilder().build(project, SceneOptions(deformation=_Shift())) - (base_nodes,) = _traces(base, "nodes") - (moved_nodes,) = _traces(moved, "nodes") - assert moved_nodes["z"] == pytest.approx([z + 1.0 for z in base_nodes["z"]]) - - -def test_payload_is_json_serialisable() -> None: - scene = PlotlyTraceBuilder().build( - _load("cantilever"), - SceneOptions( - selection_nodes=frozenset({1}), - selection_elements=frozenset({1}), - working_plane=("XY", 0.0), - show_extrusions=True, - show_local_axes=True, - show_node_labels=True, - show_element_labels=True, - ), - ) - payload = json.dumps(scene.to_payload()) - assert '"data"' in payload and '"layout"' in payload - assert scene.layout["scene"]["aspectmode"] == "data" - - -def test_empty_project_yields_only_the_hover_marker() -> None: - scene = PlotlyTraceBuilder().build(None, SceneOptions()) - assert scene.data == [] - assert scene.hover_trace == -1 - - from otko.core import Project - - empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions()) - assert _kinds(empty) == ["hover"] - - -def test_frame_trace_meta_marks_elements_pickable() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions()) - (frames,) = _traces(scene, "elements") - assert frames["meta"]["kind"] == "element" - # None separators break the line into per-element segments. - assert None in frames["x"] - assert len(frames["customdata"]) == len(frames["x"]) - - -# ── scene recipe (opstool-derived framing) ─────────────────────────────── -def test_axis_bounds_are_padded_around_the_model() -> None: - project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions()) - points = np.array([node.coords for node in project.nodes], dtype=float) - for index, axis in enumerate(("x", "y", "z")): - low, high = scene.axis_bounds[axis] - assert low < points[:, index].min() or low <= points[:, index].min() - assert high > points[:, index].max() or high >= points[:, index].max() - assert scene.diagonal > 0 - - -def test_planar_model_gets_unit_slack_on_the_flat_axis() -> None: - project = _load("basic_truss") - scene = PlotlyTraceBuilder().build(project, SceneOptions()) - low, high = scene.axis_bounds["z"] - assert high - low > 0 # a degenerate axis must not collapse the view - - -def test_axis_overrides_pin_ranges_with_autorange_off() -> None: - scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions()) - overrides = scene.axis_overrides() - assert set(overrides) == {"xaxis", "yaxis", "zaxis"} - for axis, override in overrides.items(): - assert override["autorange"] is False - assert len(override["range"]) == 2 - assert override["range"] == list(scene.axis_bounds[axis[0]]) - - -def test_hover_templates_identify_entities() -> None: - scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) - (nodes,) = _traces(scene, "nodes") - (frames,) = _traces(scene, "elements") - assert nodes["hovertemplate"] == "Node #%{customdata}" - assert frames["hovertemplate"] == "Element #%{customdata}" - # Hover must not fall back to the raw-id "skip" mode. - assert "hoverinfo" not in nodes - - -def test_axis_outline_flag_toggles_grid_and_ticks() -> None: - plain = RenderStyle() - outlined = RenderStyle(show_axis_outline=True) - project = _load("cantilever") - scene_plain = PlotlyTraceBuilder(plain).build(project, SceneOptions()) - scene_outlined = PlotlyTraceBuilder(outlined).build(project, SceneOptions()) - - off_axis = scene_plain.layout["scene"]["xaxis"] - on_axis = scene_outlined.layout["scene"]["xaxis"] - assert off_axis["showgrid"] is False and off_axis["showticklabels"] is False - assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True - # The coloured axis lines stay visible either way (orientation cue). - assert off_axis["visible"] is True and on_axis["visible"] is True diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py deleted file mode 100644 index c179eb7..0000000 --- a/tests/unit/test_render_style.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Unit tests for the shared render style and its editable subset.""" - -from __future__ import annotations - -import dataclasses - -import pytest - -from otko.core import ElasticBeamColumn, Node, Project, TrussElement, ZeroLengthElement -from otko.views.canvas3d.style import ( - EDITABLE_FIELDS, - SELECTED_STATE, - STYLE_FIELDS, - RenderStyle, - element_family_index, - family_palette, -) - - -def test_defaults_are_the_documented_palette() -> None: - style = RenderStyle() - assert style.node_color == "#d9d9d9" - assert style.selected_color == "#00ffff" - # opstool-derived element palette. - assert style.element_beam_color == "#0652ff" - assert style.element_truss_color == "#FF8C00" - assert style.element_link_color == "#39FF14" - assert len(style.response_scale_colors) == 10 - assert style.response_scale_colors[0] == "#313695" # cold end - assert style.response_scale_colors[-1] == "#a50026" # warm end - - -def test_with_updates_is_immutable() -> None: - original = RenderStyle() - updated = original.with_updates(node_color="#123456", label_font_size=20) - assert updated is not original - assert updated.node_color == "#123456" - assert updated.label_font_size == 20 - # The source instance is untouched (frozen dataclass contract). - assert original.node_color == "#d9d9d9" - assert original.label_font_size == 12 - - -def test_editable_subset_matches_the_field_table() -> None: - style = RenderStyle() - assert RenderStyle.EDITABLE == EDITABLE_FIELDS - assert set(style.editable_values()) == set(EDITABLE_FIELDS) - # Every advertised field must exist on the dataclass with the right kind. - kinds = {name: kind for name, _label, kind in STYLE_FIELDS} - for name in EDITABLE_FIELDS: - assert hasattr(style, name), name - assert kinds[name] in {"color", "float", "int", "bool"} - - -def test_response_colorscale_is_an_evenly_spaced_mapping() -> None: - style = RenderStyle() - scale = style.response_colorscale() - assert len(scale) == len(style.response_scale_colors) - assert scale[0] == (0.0, style.response_scale_colors[0]) - assert scale[-1] == (1.0, style.response_scale_colors[-1]) - positions = [position for position, _color in scale] - assert positions == sorted(positions) - - -@pytest.mark.parametrize( - ("element", "expected"), - [ - (ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 0), - (TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), 1), - (ZeroLengthElement(id=3, nodes=(1, 1), material_ids=(1,), dofs=(1,)), 2), - ], -) -def test_element_family_index(element: object, expected: int) -> None: - assert element_family_index(element) == expected - - -def test_family_palette_order_and_selected_slot() -> None: - style = RenderStyle(node_color="#010101") - palette = family_palette(style) - assert palette == [ - style.element_beam_color, - style.element_truss_color, - style.element_link_color, - style.selected_color, - ] - assert len(palette) - 1 == SELECTED_STATE - - -def test_editable_values_round_trip_through_replace() -> None: - style = RenderStyle() - values = style.editable_values() - assert RenderStyle(**values) == style - # And the persisted shape is JSON-friendly (str/int/float only). - import json - - assert json.loads(json.dumps(values)) == values - - -def test_style_is_a_frozen_slots_dataclass() -> None: - assert dataclasses.is_dataclass(RenderStyle) - with pytest.raises(dataclasses.FrozenInstanceError): - RenderStyle().node_color = "#000000" # type: ignore[misc] - - -def test_project_builder_helper_is_unused_but_valid() -> None: - """A style constructed from a project's elements classifies every family.""" - project = Project( - ndm=2, - ndf=3, - nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))], - elements=[ - ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), - TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), - ], - ) - indices = [element_family_index(el) for el in project.elements] - assert indices == [0, 1] diff --git a/tests/unit/test_renderer.py b/tests/unit/test_renderer.py index 04529d6..e1c8fb0 100644 --- a/tests/unit/test_renderer.py +++ b/tests/unit/test_renderer.py @@ -5,9 +5,9 @@ from __future__ import annotations import pytest pv = pytest.importorskip("pyvista") -import numpy as np +import numpy as np # noqa: E402 -from otko.core import ( +from otko.core import ( # noqa: E402 ElasticBeamColumn, ElasticSection, LinearTimeSeries, @@ -18,13 +18,12 @@ from otko.core import ( Steel01, TrussElement, ) -from otko.services.deformation import DeformationSource -from otko.views.canvas3d.model_renderer import ( +from otko.services.deformation import DeformationSource # noqa: E402 +from otko.views.canvas3d.model_renderer import ( # noqa: E402 ModelRenderer, RendererMode, _classify_support, ) -from otko.views.canvas3d.style import SELECTED_STATE # ──────────────────────────── support classification ──────────────────────────── @@ -126,13 +125,9 @@ def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist() # Nodes 1 and 3 selected → row 0 and row 2 assert node_states == [1, 0, 1] - # Frame cells carry the palette slot: the element family normally, and - # SELECTED_STATE when selected. + # Element 2 selected → it's the second frame (index 1 in frame_ids_ordered) selected_frame_idx = r._frame_id_to_row[2] - assert frame_states[selected_frame_idx] == SELECTED_STATE - for index, family in enumerate(r._frame_family): - expected = SELECTED_STATE if index == selected_frame_idx else family - assert frame_states[index] == expected + assert frame_states[selected_frame_idx] == 1 def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def] diff --git a/tests/unit/test_unit_labels.py b/tests/unit/test_unit_labels.py index 999f0e4..1fe1e3e 100644 --- a/tests/unit/test_unit_labels.py +++ b/tests/unit/test_unit_labels.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from otko.core import UnitSystem, labels_for diff --git a/tests/unit/test_zerolength_section.py b/tests/unit/test_zerolength_section.py index 69ef334..696b83e 100644 --- a/tests/unit/test_zerolength_section.py +++ b/tests/unit/test_zerolength_section.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest -from pydantic import ValidationError from otko.core import ( ElasticSection, @@ -22,7 +21,7 @@ def test_zero_length_section_schema_defaults() -> None: def test_zero_length_section_rejects_extra_fields() -> None: - with pytest.raises(ValidationError): + with pytest.raises(Exception): ZeroLengthSectionElement( id=1, nodes=(1, 2),