Compare commits

..
188 changed files with 2051 additions and 5969 deletions

View file

@ -14,25 +14,12 @@ jobs:
- 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
- run: pip install ruff mypy
- run: ruff check src tests
- run: ruff format --check src tests
type:
runs-on: ubuntu-latest
steps:
- 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
test-headless:
needs: [lint, type]
test:
needs: lint
strategy:
fail-fast: false
matrix:
@ -44,18 +31,8 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: pytest tests/unit tests/services -m "not slow"
test-gui:
needs: [lint, type]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Linux Qt deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 \
@ -63,19 +40,9 @@ jobs:
libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \
libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb
- run: pip install -e ".[dev]"
- run: xvfb-run -a pytest tests/gui -m "not slow"
test-integration:
needs: [lint, type]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
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"
- name: Run tests (Linux, headless)
if: runner.os == 'Linux'
run: xvfb-run -a pytest --cov --cov-report=xml -m "not slow"
- name: Run tests (Win/Mac)
if: runner.os != 'Linux'
run: pytest --cov --cov-report=xml -m "not slow"

View file

@ -1,97 +1,37 @@
# Contributing
Thanks for helping with OTKO — a SAP2000-style desktop GUI for
OpenSeesPy. Early-stage project: the bar is architecture cleanliness, not
feature count. If your change breaks a layering rule below, it won't
merge — no matter how useful the feature.
Early-stage project. The bar is architecture cleanliness, not feature
count. If your change breaks a layering rule below, it won't merge —
no matter how useful the feature.
## Dev setup
```bash
python -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows
pip install -e ".[gui,dev]"
source .venv/bin/activate
pip install -e ".[dev]"
pre-commit install
```
`pip install -e ".[gui,dev]"` pulls the Qt/PyVista desktop stack plus the
dev tools. For a headless checkout (core + services only, no Qt) use
`pip install -e .` instead. Python 3.10+; on Windows use 3.12+.
Launch the GUI with:
```bash
python -m otko
```
`pre-commit install` wires ruff + mypy into your local git hooks so
obvious issues are caught before a commit. Run it once per clone.
## Before opening a PR
Run the verify commands in this order and make sure they are all clean:
```bash
ruff check src tests
ruff format src tests # line-length 100, E501 ignored
ruff format src tests
mypy src/otko/core src/otko/services
pytest -m "not slow" # CI gate: lint → this, on 3.10/3.11/3.12 × ubuntu/windows/macos
pytest -m "not slow"
```
Focused runs are faster while iterating: `pytest tests/unit` (pure logic,
milliseconds), `pytest tests/gui -k <name>` (pytest-qt, needs a display;
Linux GUI tests want `xvfb-run -a pytest ...`), and
`pytest tests/integration -k <name>` (real OpenSeesPy runs). Single test:
`pytest tests/unit/test_project.py::test_name -q`. Markers: `gui`, `slow`.
## Architectural rules (enforced in review)
OTKO uses a strict one-way **MVVM + service** architecture. Dependencies
flow outward-in only:
```
views → viewmodels → services → core
```
`commands` sits alongside the bridge and owns every model mutation.
1. `core/` is pure Python — stdlib + numpy + pydantic. It may **not**
import Qt or `openseespy`. Period.
2. `services/` may use `core` + `h5py` + `openseespy`, but may **not**
import Qt.
3. `views/` (PySide6/pyvistaqt) may **not** import `openseespy` directly —
go through a service. No business logic in `views`.
4. `viewmodels/` bridges `core` ↔ Qt (signals, `QUndoStack`).
5. `commands/` holds the `QUndoCommand` subclasses; all model mutations
go through `commands`, not ad-hoc edits in `views`.
6. Public functions and methods need type hints and a docstring.
7. New domain entities go through Pydantic validation.
8. Long-running operations (>50 ms) run off the GUI thread (`AnalysisWorker`
in a `QThread`, cancelled via `isInterruptionRequested()`; results cross
threads as a lightweight `ResultsHandle` written to HDF5).
The full package map and the fixed OpenSeesPy command order the runner
emits live in [`docs/architecture.md`](docs/architecture.md). Never
reorder the runner's commands.
## Branch model
`main` is the default and integration branch. Work on short-lived topic
branches cut from `main` — `feat/<topic>`, `fix/<topic>`, or
`docs/<topic>` — and open a pull request against `main`. Do not commit
directly to `main` from an agent session. There is no `develop` branch.
1. `core/` may not import Qt or `openseespy`. Period.
2. `services/` may not import Qt.
3. `views/` may not import `openseespy` directly — go through a service.
4. Public functions and methods need type hints and a docstring.
5. New domain entities go through Pydantic validation.
6. Long-running operations (>50 ms) run off the GUI thread.
## Commit style
Conventional Commits — `feat:`, `fix:`, `refactor:`, `docs:`, `test:`,
`chore:`, `ci:`. Keep each commit focused; a `feat:` commit should add a
feature, not mix one in with unrelated refactors.
## Documentation
If your change is user-visible, update
[`docs/QUICK_GUIDE.md`](docs/QUICK_GUIDE.md) and the relevant roadmap or
ADR entry. Project model files are `.osmodel` (Pydantic-validated JSON);
regenerate the checked-in `examples/*.osmodel` from their scripts with
`python examples/<name>.py` rather than hand-editing them.
`chore:`, `ci:`.

70
NOTICE
View file

@ -1,70 +0,0 @@
# NOTICE
OTKO
Copyright © 2026 Ozan and contributors.
## OTKO license
OTKO's own source code is licensed under the **GNU Affero General Public
License v3.0** (AGPL-3.0). The full text is in [`LICENSE`](LICENSE). This
`NOTICE` file does not replace or modify that license; where the two
disagree, `LICENSE` governs.
OTKO is **not** an MIT-licensed project. Portions of the codebase were
ported or adapted from an earlier, MIT-licensed prototype called
`otko-development`, so the original MIT notice is reproduced below as
required by that license.
## Ported / adapted code: `otko-development`
Portions of OTKO were ported or adapted from the `otko-development`
project, which is distributed under the MIT License. The MIT
copyright and permission notice is reproduced verbatim below:
```
MIT License
Copyright (c) 2026 OTKO contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Runtime dependencies
OTKO depends on third-party software that is not covered by OTKO's
AGPL-3.0 license. Each component remains under its own license, and its
license text ships with the corresponding package. The notes below are a
summary for attribution, not a substitute for those license texts.
- **OpenSees / OpenSeesPy** — the finite-element solver invoked by
`otko.services.opensees_runner`. OpenSees is copyright The Regents of
the University of California and is distributed under a BSD-style
license; the notice is shipped inside the `openseespy` package.
- **PySide6** — the Qt 6 bindings used by the desktop GUI. PySide6 is
available under the GNU Lesser General Public License v3 (LGPLv3) or a
commercial license. OTKO links against it dynamically, which keeps the
LGPL relinking obligation satisfiable for redistributors.
- **numpy**, **pydantic**, **h5py**, **pyvista**, **VTK**,
**pyqtgraph**, and **imageio** — each is distributed under its own
license (for example BSD-3-Clause, MIT, and similar permissive terms).
See the license file bundled with each installed package for the exact
terms.
When redistributing OTKO, keep `LICENSE`, this `NOTICE`, and the license
notices of the dependencies above.

View file

@ -82,13 +82,6 @@ views (Qt) → viewmodels → services (OpenSeesRunner, Persistence) → c
Long version in [`docs/architecture.md`](docs/architecture.md),
including the OpenSeesPy command order the runner emits.
## Documentation
Practical, task-first walkthroughs live in
[`docs/QUICK_GUIDE.md`](docs/QUICK_GUIDE.md) — a cantilever build,
modal analysis, report/script export, display units, and undo/redo.
The full index is [`docs/README.md`](docs/README.md).
## Install (development)
**Desktop GUI** (Qt, PyVista, pyqtgraph, imageio):

View file

@ -1,132 +0,0 @@
# OTKO Quick Guide
A practical, task-first guide to the OTKO desktop GUI. It assumes you
have already installed the desktop extras and can launch the app:
```bash
pip install -e ".[gui,dev]"
python -m otko
```
Project files use the `.osmodel` extension — a single, Pydantic-validated
JSON document that diffs cleanly in Git. Analysis output is written
separately to `*.osresults.h5`.
For the layer map and the OpenSeesPy command order, see
[`architecture.md`](architecture.md). For the feature-by-feature plan, see
[`roadmap.md`](roadmap.md).
## 1. A cantilever walkthrough
This follows the bundled `examples/cantilever.osmodel` model: a 5 m
horizontal beam, fixed at the left end, with a tip load. If you would
rather build it by hand, the steps are below.
1. **Start a project.** **File → New (3D Frame)**. Pick display units in
the bottom-right **Units** combo before typing any values.
2. **Lay out a grid.** **Define → Coordinate System/Grids…** (Ctrl+G).
Define X lines at 0…5 m (say, every 1 m), Y = 0, Z = 0, and set the
grid as the active coordinate system. The 3D canvas will draw it as
reference geometry.
3. **Add nodes.** **Define → Add Node…** (Ctrl+N), or use the **Draw
Node** tool and click on grid intersections at (0,0,0) … (5,0,0).
4. **Define material and section.** **Define → Material Library…**
(Ctrl+Shift+M) then **Define → Section Library…** (Ctrl+Shift+S). The
example uses a steel `ElasticSection` named `W12x40`.
5. **Draw the element.** **Assign/Define → Draw Frame** (F2), then click
from the first node to the last. Assign the section with
**Assign → Frame → Section…**.
6. **Add the support.** Select the node at x = 0 and use
**Assign → Joint → Restraints…** (Ctrl+R); restrain all six DOF. The
support icon confirms the fixed end.
7. **Add the load.** Select the tip node and use **Assign → Joint →
Point Loads…** (Ctrl+L). The example applies -10 kN in Y. Alternatively
build the distributed case with **Assign → Frame → Distributed
Load…**.
8. **Set up and run the case.** **Analyze → Cases…** (Ctrl+Shift+A) to
create or review a Static case, then **Analyze → Run…** (F5). The
bundled file already contains `Tip-Load`, `Uniform-Load`, and a modal
`Modal-3` case.
### Smoke check
Open `examples/cantilever.osmodel`, run the `Tip-Load` static case, then
**Display → Show Force Diagram… → M3**. The moment diagram is linear and
peaks at **50 kN·m at the fixed end**. V2 is a constant -10 kN along the
span. If you see that, the model, runner, and post-processor are wired up
correctly.
## 2. Running a modal analysis
1. Open a model that has mass assigned (the bundled cantilever lumps mass
at every free node so modal works out of the box).
2. **Analyze → Cases…**, add or select a **Modal** case, and set the
number of modes `n_modes` (the example uses 3).
3. **Analyze → Run…** (F5). Results appear in the results/report panel:
periods, frequencies, and participation factors per mode.
4. **Display → Animate Mode Shape** to view each mode. Use the mode
selector and the animation controls, and **Export…** if you want a
video of the mode shape.
5. Modal results also feed the response-spectrum case: define a response
spectrum, then run the SRSS or CQC combination and open
**Display → Show Response Spectrum**.
## 3. Reviewing results and exporting a report or script
After a run, the results/report panel shows a summary for the active case
(static reactions and forces, modal periods, and so on). Use the display
actions to inspect the model visually:
- **Display → Show Deformed Shape** — with a scale slider.
- **Display → Show Force Diagram…** — axial (P), shear (V2/V3), moment
(M2/M3) diagrams.
- **Display → Show Pushover Curve**, **Show Time-History**, **Show
Hysteresis** as applicable.
To hand the analysis to someone else, or to archive exactly what was run,
export a script:
- **File → Export OpenSeesPy (.py)…** — writes the full model, and
optionally a selected analysis case, as a runnable Python script.
- **File → Export Tcl (.tcl)…** — the same model as classic OpenSees Tcl.
The export dialog lets you choose "Model only (no analysis case)" or one
of the configured cases. The generated script follows the runner's fixed
command order (`wipe → model → node → fix → … → analyze`), so it
reproduces the analysis outside the GUI.
## 4. Changing display units
Use either control, they are the same setting:
- The **Units** combo in the bottom-right of the status bar, or
- **Options → Set Display Units…**
Changing units updates how lengths, forces, and moments are formatted in
the UI and plots. It does **not** rescale the underlying model numbers —
pick the right unit system before you type values, and convert
deliberately if you switch later. A set of unit labels is available in the
unit-label tests under `tests/unit/test_unit_labels.py`.
## 5. Undo and redo
Every model mutation goes through the undo stack, so most edits are
reversible:
- **Edit → Undo** (Ctrl+Z)
- **Edit → Redo** (Ctrl+Y / Ctrl+Shift+Z)
Menu text is dynamic — it names the operation, for example "Undo Add 4
Nodes". Compound operations such as drawing a frame (node + element) are
wrapped in a single macro, so one undo removes the whole step. File
loads, analysis runs, and display-only changes are not model mutations and
are not undoable.
## Where to go next
- [`architecture.md`](architecture.md) — MVVM layering and command order.
- [`roadmap.md`](roadmap.md) — what is done and what is planned.
- `examples/` — 20+ verified models, each generated from a checked-in
Python script.
- [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — setup, rules, and verify
commands.

View file

@ -1,26 +0,0 @@
# OTKO Documentation
Index of the project documentation. Start with the quick guide if you
just want to build and run a model; read the architecture page if you are
changing code.
| Document | What it covers |
| --- | --- |
| [QUICK_GUIDE.md](QUICK_GUIDE.md) | Task-first walkthrough: cantilever model, modal analysis, report and script export, display units, undo/redo. |
| [architecture.md](architecture.md) | MVVM layering, package responsibilities, threading, persistence, and the fixed OpenSeesPy command order. |
| [roadmap.md](roadmap.md) | Phase-by-phase feature status, from scaffolding through the earthquake-engineering primitives. |
| [adr/](adr/) | Architecture Decision Records — the "why" behind individual design choices. |
| [screenshots/](screenshots/) | Screenshots referenced by the docs and README. |
## Architecture Decision Records
- [ADR-0001 — GiD/OpenSees schema import](adr/ADR-0001-gidopensees-schema-import.md)
- [ADR-0002 — Headless / GUI dependency split](adr/ADR-0002-headless-gui-dep-split.md)
## Related documentation
- [`../README.md`](../README.md) — project overview, install, and quick start.
- [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — dev setup, layering rules, commit style, and verify commands.
- [`../AGENTS.md`](../AGENTS.md) — condensed context for automated agents.
- [`gap-analysis-gidopensees.md`](gap-analysis-gidopensees.md) — gap analysis against the GiD/OpenSees reference.
- [`../examples/README.md`](../examples/README.md) — the bundled example models.

View file

@ -89,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"

View file

@ -1,383 +0,0 @@
# 15 — Rebuild Adoption Plan
> **Status:** proposed. **Source of truth for intent:** the greenfield
> rebuild specs at `~/Sync/otko-development/specifications` (`00`–`14`).
> This document plans how to bring that rebuild's good ideas into **this**
> repo without breaking the working app.
>
> Requirement IDs are shown without brackets for brevity (e.g. `PKG-032` =
> `[PKG-032]` in the rebuild set) so they stay grep-able.
---
## 1. Context
| | This repo (`~/Sync/otko`) | Rebuild (`~/Sync/otko-development`) |
|---|---|---|
| Role | **Working app** — the primary deliverable | Greenfield spec-conformant attempt |
| VCS | Not a git repo in this checkout | `rebuild` branch, commit `725040e` |
| License | AGPL-3.0 | MIT (+ `NOTICE`) |
| Python | 3.10–3.12 | 3.12 only |
| Project file | `.osmodel` | `.otko` |
| Scope | Superset: static, modal, **transient, pushover, response-spectrum**, quad/shell-adjacent elements, HDF5 | Narrow v1: static + modal; reports, combinations, self-weight, units display |
| src LOC | ~37k | ~25k |
| Gate | deps not installed here; **57 collection errors** locally | `ruff` passes; `mypy`/`pytest` deps missing locally |
The rebuild is *ahead* on: **reporting/Typst, load combinations, self-weight,
display-unit conversion, closed-form diagrams, modal mass participation,
progress/cancel, local-axis editing, viewmodel separation, and quality
gates**. It is *behind* on everything this repo already ships. Therefore the
plan is a **selective harvest**, not a merge.
## 2. Goal and non-goals
**Goal.** Adopt the rebuild's good capabilities into this repo, incrementally,
each phase independently shippable, with the existing app and tests staying
green.
**Non-goals.**
- Do **not** rewrite this repo to the rebuild's architecture (`ProjectStore`,
frozen `Project`, `CommandFactory`, `services/scene.py`,
`services/diagram_data.py` substrates).
- Do **not** replace the runner/export/results surface — this repo's is a
strict superset.
- Do **not** change `.osmodel`, the AGPL license, or the Python 3.10–3.12
support range as part of this plan.
## 3. Principles
1. **Additive, adapt, don't copy.** Port algorithms/patterns; re-type to this
repo's APIs (`StaticResults`/`ModalResults`, mutable `Project`,
4-value `UnitSystem`, `element_forces.DiagramData`).
2. **Protect the working app.** Every phase keeps `pytest -m "not slow"` and
the GUI smoke tests green. New optional deps stay **lazy** so headless
installs are unaffected.
3. **One phase = one branch = one shippable PR**, with tests and a gate.
4. **Trace every item to a rebuild spec ID** so "is it done?" stays mechanical.
5. **License hygiene.** MIT→AGPL-3.0 is one-way compatible: preserve the MIT
copyright/permission notice for ported files (`NOTICE` + per-file header).
Never copy this repo's AGPL code back into the MIT rebuild.
6. **Establish version control first.** This checkout has **no `.git`**. Before
any work, create a repo/branch per `AGENTS.md` (`origin` is the self-hosted
Forgejo; work on `feat/<topic>`, PR against `main`).
## 4. Adoption matrix
Verdicts: **PORT** (near-verbatim), **ADAPT** (re-type/re-wire), **REIMPL**
(reimplement against this repo's APIs), **SKIP** (intentional divergence).
Size: **S** ≈ hours · **M** ≈ a few days · **L** ≈ 1–2+ weeks.
### 4.1 Quality / foundations
| # | Capability | Rebuild source | Verdict | Phase | Size | Spec IDs |
|---|---|---|---|---|---|---|
| Q1 | Single-source version + solver pin via `_const.py` | `src/otko/_const.py`, `pyproject.toml` `[tool.hatch.version]` | ADAPT | 0 | S | PKG-032/033, RUN-085 |
| Q2 | Layering/import-discipline AST tests | `tests/unit/test_architecture.py` (246) | ADAPT (paths) | 0 | M | ARC-001..003/006/011/060..064 |
| Q3 | CI split into 5 jobs incl. missing **`type` (mypy)** job; mark integration `slow` | `.github/workflows/ci.yml`, `tests/unit/test_ci_config.py`, `test_meta_gates.py` | ADAPT | 0 | M | TST-004/031/040/041 |
| Q4 | conftest hardening (offscreen default; `ops.wipe()` in teardown) | `tests/conftest.py` | ADAPT | 0 | S | TST §7 |
| Q5 | `tests/services/` split from `tests/unit/` | tests layout | ADAPT | 0 | M | TST-002 |
| Q6 | Standalone docs + docs test | `docs/QUICK_GUIDE.md`, `docs/README.md`, `tests/unit/test_docs.py` | ADAPT | 0 | M | — |
| Q7 | `NOTICE` + attribution for ported MIT code | `NOTICE` | REIMPL (AGPL wording) | 0 | S | PKG-042 |
| Q8 | Drop phantom `scipy`/`pandas` from `[gui]` | `pyproject.toml`, `tests/unit/test_packaging.py` | ADAPT | 0 | S | PKG-011 |
| Q9 | Scoped mypy overrides + coverage `fail_under` | `pyproject.toml` | ADAPT | 0 | S | TST-031/032 |
| Q10 | Packaging config tests subset (entrypoint, line length, single-source) | `tests/unit/test_packaging.py` (117) | ADAPT | 0 | S | PKG-030..033 |
| Q11 | Spec-ID traceability harness | `tests/unit/test_spec_traceability.py` (343) | REIMPL | 8 | L | TST-013/024/025 |
| Q12 | Performance probes | `tests/performance/*` | ADAPT | 8 | L | OVR-010..014 |
### 4.2 Services / engine
| # | Capability | Rebuild source | Verdict | Phase | Size | Spec IDs |
|---|---|---|---|---|---|---|
| S1 | `SolverSession` Protocol + `Real`/`Recording` sessions | `services/solver_session.py` (154) | PORT | 1 | S | RUN-003/010/011/012 |
| S2 | Persistence refuses to save an invalid model + resolved path | `services/persistence.py` (63) | ADAPT | 1 | S | PER-021/022 |
| S3 | Result fields: `StaticResults.time`, modal `ModeParticipation` record | `services/results.py` (138) | ADAPT | 1 | S | RES-001/003/005, ANL-012 |
| S4 | Component-less element forces = absent, not zeros | `runner/run.py` | ADAPT | 1 | S | RUN-053 |
| S5 | `progress(int)` + `cancelled` + `isInterruptionRequested()` + `cancel()`; real Cancel UX | `services/qt_workers.py`, `viewmodels/analysis_vm.py`, `views/dialogs/run.py` | ADAPT | 2 | M | ANL-031..036, ARC-032/033, UX-081 |
| S6 | Modal mass participation unified with response-spectrum path | `runner/modal.py` (466), `services/results.py` | ADAPT | 2 | M | RUN-062/070..072, ANL-013 |
| S7 | Display-unit layer + `GRAVITY` + `ProjectMeta.display` (adapter over 4-system enum) | `core/units.py` (298) | ADAPT | 3 | M | UNT-003/004/010/020..024/030..032 |
| S8 | Closed-form diagram math (`N/V/M/T` shapes + extrema) | `core/diagrams.py` (436) | PORT | 4 | S | RES-010/011/012 |
| S9 | Backend-neutral `diagram_data` service + `render_matplotlib` | `services/diagram_data.py` (400) | REIMPL | 4 | M | RES-020..024/030..032, CAN-084 |
| S10 | **Report pipeline** CSV / PNG(≥300 dpi) / SVG / Typst (+optional PDF) + case-report action | `services/report/*` (1141) | PORT + ADAPT deps | 5 | M | RES-040..047/050/051/061 |
| S11 | Local axis: `LocalAxis`, element field, geomTransf dedup by `(type,vecxz,roll)`, editor + triad | `core/geometry/local_axis.py`, `runner/emit.py` | ADAPT | 6 | M | GEO-060..064, DOM-040 |
| S12 | Load combinations entity + commands + runner materialisation | `core/combinations.py` (44), `commands/combinations.py` (146) | PORT + ADAPT | 7 | M | LOD-050..058, ANL-001, RUN-050 |
| S13 | Self-weight service + regenerate command | `services/self_weight.py` (364) | REIMPL | 7 | M | UNT-040..045, LOD-011/040..043 |
| S14 | Material `rho` coverage for self-weight | `core/materials` | ADAPT | 7 | S | UNT-041 |
### 4.3 UI / UX
| # | Capability | Rebuild source | Verdict | Phase | Size | Spec IDs |
|---|---|---|---|---|---|---|
| U1 | Pure formatting helpers (`DOF/MASS/RESTRAINT_LABELS`, number/float parse) | `views/formatting.py` (84) | PORT | 0 | S | UX-033/062 |
| U2 | Timestamped, severity-tagged console | `views/docks/console.py` (103) | PORT | 0 | S | UX-042 |
| U3 | `ValidatedDialog` base (help line, inline errors, OK-gating, unit suffix) | `views/dialogs/base.py` (191) | PORT + staged | 8 | M | UX-060..064 |
| U4 | `ResultsVM` per-case handle cache; `CanvasVM` selection ownership | `viewmodels/results_vm.py`, `canvas_vm.py` | REIMPL | 8 | M | RES-005, CAN-042/041 |
| U5 | Overlay helpers: ghost undeformed, unit-labelled diagram extremes, zero hint, pattern-filtered loads | `views/canvas/overlays.py` (684) | REIMPL | 8 | M | CAN-052/062/082/083, LOD-063 |
| U6 | `QSettings` layout persistence + `closeEvent` save prompt | `views/main_window.py` | PORT | 0 | S | UX-013, PER-032/033, ARC-050 |
| U7 | `ThemeManager` + dark icon set | `views/resources/theme.py`, `icons.py` | ADAPT (verify icon provenance) | 8 | S | UX-070/071/072 |
| U8 | Thicken `ProjectViewModel` (move `action_handlers` logic into VM methods) | `viewmodels/project_vm.py` (713) | REIMPL | 8 | L | UX-001/002/005, ARC-005/020..022 |
| U9 | Analysis VM status + Simple/Advanced control fields | `viewmodels/analysis_vm.py` | ADAPT | 8 | M | ANL-021/050..054, UX-037 |
### 4.4 Explicitly deferred / skipped
| Item | Verdict | Why |
|---|---|---|
| Polygon-first sections + `services/sections_mesh.py` (opstool GPLv3) | DEFER (Phase 9, conditional) | Schema-breaking redesign; adds GPLv3/runtime deps; not report-critical |
| Frozen `Project`/entities + `ProjectStore`/`CommandFactory` rewrite | DEFER (not recommended now) | Large coordinated rewrite of 43 commands + all call sites; low user-visible value vs risk |
| Canvas backend Protocol + `plotly_backend.py` + `services/scene.py` | DEFER (Phase 9) | Requires core + views surgery; plotly pulls QtWebEngine (known teardown SIGSEGV) |
| `.otko` file suffix / greenfield `Project` schema (PER-001/006) | SKIP | Breaks `.osmodel` corpus and compatibility; do only as an explicit user-approved migration |
| Rebuild `runner/export.py` | SKIP | This repo's `export.py` is a superset (5 case types, `.py`+`.tcl`) |
| Rebuild static/modal HDF5 | SKIP | Rebuild has none; this repo's transient HDF5 is already better |
| Rebuild slim `materials`/`analysis`/`loads` unions | SKIP | Dropping `HystereticSM`, Transient/Pushover/ResponseSpectrum, Path/Imposed patterns would regress shipped features |
| MIT license / py3.12-only / single-OS CI assertions | SKIP | Intentional differences (AGPL, 3.10–3.12, 3-OS matrix) |
## 5. Phases
### Phase 0 — Foundations, safety net, quick wins
**Why first:** makes later phases verifiable and cheap; all items are
additive and low risk. Also unblocks the mpy gate that currently does nothing.
- **Prep:** initialise git and a `feat/rebuild-adoption` branch (§3.6).
- **Q1** Create `src/otko/_const.py` (`__version__`, `OPENSEESPY_VERSION`),
switch `pyproject.toml` to `dynamic = ["version"]` + `[tool.hatch.version]`,
and import the pin in `services/export.py` (currently duplicated at
`export.py:52`). Spec: PKG-032/033, RUN-085.
- **Q2** Port `tests/unit/test_architecture.py`; adapt `CANVAS = views/canvas3d`
path and `services/{_emit,_run}.py` paths. Expect it to flag three existing
offenders — decide per item: `OSS_PICK_DEBUG` env var in
`views/canvas3d/model_canvas.py:29` (ARC-052), `QUndoStack` outside VMs, and
the canvas path. Either fix or add a documented, narrow allowlist.
- **Q3** Split CI: `lint` (+`ruff format --check`), **`type` (mypy scopes)**,
`test-headless` (`pytest tests/unit tests/services -m "not slow"`),
`test-gui` (`xvfb-run pytest tests/gui`), `test-integration`
(`pytest tests/integration -m slow`). Add `pytestmark = pytest.mark.slow` to
integration modules. Keep the existing 3-OS × 3.10–3.12 matrix and the long
Linux Qt apt list.
- **Q4** `tests/conftest.py`: `QT_QPA_PLATFORM=offscreen` default; move
`ops.wipe()` to teardown (this repo currently wipes before each test).
- **Q5** Move service-level tests (`runner_translation`, `persistence`,
`results`, `export`, …) into `tests/services/`; update CI and `AGENTS.md`.
- **Q6** Add `docs/QUICK_GUIDE.md` + `docs/README.md` (rewrite `.otko` →
`.osmodel`, rebuild-only APIs → this repo's), update `CONTRIBUTING.md` with
the 5-layer table + install/run/test commands; port `test_docs.py`.
- **Q7** Add `NOTICE` crediting MIT `otko-development` for ported files;
add a per-file header to ported files (`# Ported from otko-development
(MIT), (c) 2026 OTKO contributors`).
- **Q8/Q9/Q10** `pyproject.toml`: drop `scipy`/`pandas` from `[gui]`; convert
mypy to scoped `strict` overrides (core/services/viewmodels); add
`[tool.coverage.run] fail_under`; port the `test_packaging.py` subset that
applies (entrypoint, version single-source, line length).
- **U1/U2/U6** Port `views/formatting.py`; timestamp the console
(`views/docks/console.py` into the existing dock); add `save_layout`/
`restore_layout` (`QSettings`) and a `closeEvent` unsaved-changes prompt.
**Gate:** `ruff check src tests`, `ruff format --check src tests`,
`mypy src/otko/core src/otko/services src/otko/viewmodels`,
`pytest -m "not slow"`, arch test, and the new packaging/docs tests pass.
### Phase 1 — Cheap, safe services wins
- **S1** Port `services/solver_session.py` (`SolverSession`, `RealSolverSession`,
`RecordingSolverSession`). Inject into `OpenSeesRunner` while keeping the
existing `ops_module=` shim; consolidate the two wipe paths
(`_emit.py:105` → `session.reset()`). Tests:
`tests/services/test_solver_session.py`.
- **S2** In `services/persistence.py::save_project`, call
`validate_references()` first and refuse to write an invalid model,
reporting the problem list (PER-022); return the resolved path (PER-021).
- **S3** Extend `StaticResults` with `time` and add a `ModeParticipation`
record to `ModalResults` (keep the existing dataclass names — consumers in
`views/docks/results_panel.py` dispatch on them).
- **S4** `RUN-053`: return an absent (not zero-filled) force entry when a
component is missing; guard the static `eleForce` fallback.
**Gate:** new service tests + existing runner/persistence tests green; runner
integration suite unchanged.
### Phase 2 — Progress & cancellation
- **S5** Add to `services/qt_workers.py`: `progress(int)`, `cancelled`,
an `_interruption_requested()` check between steps, and a bounded teardown
wait. Add `AnalysisRunner.cancel()` (calls `QThread.requestInterruption()`).
Wire progress callbacks through static (`RUN-054`) and modal (`RUN-063`)
runs. Replace the indeterminate bar in `views/dialogs/run_analysis.py` with
a real progress bar + working Cancel (UX-081). Add per-case status to the
case manager (ANL-050).
- **S6** Unify modal mass participation: put the rebuild's participation
math into one service and have **both** modal results and the existing
response-spectrum path (`services/spectrum.py`) consume it. Add the ANL-013
no-mass warning.
**Gate:** cancel leaves no partial handle; progress reaches 100; participation
sums to 1.0 against a hand-checked example.
### Phase 3 — Units display layer + GRAVITY
- **S7** Port the display machinery from `core/units.py` **as an adapter**:
keep this repo's 4-value `UnitSystem` and map families
(`SI_M_N|SI_MM_N → METRIC`, `US_FT_KIP|US_IN_KIP → IMPERIAL`); add
`GRAVITY`, `display(value, system, quantity, pref)`, `DisplayPrefs`; add
`ProjectMeta.display` (persisted). Retarget the status-bar/menu unit picker
so it writes **display prefs**, not the stored `meta.units` (fixes UNT-031).
- Watch-outs: rebuild uses `StrEnum` (3.11+) — do **not** adopt it (this repo
targets 3.10). `export.py` currently embeds `meta.units.value`; keep that
but source labels via the new API.
**Gate:** switching display units changes every label and leaves
`project.model_dump()` byte-identical; old 4-system `.osmodel` files still load.
### Phase 4 — Closed-form diagrams
- **S8** Port `core/diagrams.py` near-verbatim (pure numpy, self-contained).
- **S9** Build a new `services/diagram_data.py` (REIMPL) on top of this repo's
`StaticResults` + load patterns, reusing `core/diagrams` for interior
shapes/extrema. Run it **alongside** `services/element_forces.py`, migrate
`views/canvas3d/diagram_renderer.py` and `views/dock_manager.py` behind a
flag, then retire the old extractor once the on-screen output matches.
**Gate:** port `test_diagram_data.py` (end-values, Vz/My plane, axial,
all-six components, shared data, auto-scale); on-screen diagrams unchanged.
### Phase 5 — Reporting pipeline ★ (the headline v1 capability)
- **S10** Port `services/report/{__init__,csv,figures,typst,pdf}.py`. Add a
`[reports]` extra (`matplotlib`, `imageio-ffmpeg`; Typst CLI optional, probed
via `shutil.which`, graceful degradation per RES-046). Expose a "case report"
action that produces the full set: CSV tables, PNG (≥300 dpi), SVG, a Typst
document (PDF optional), conditional static/modal sections.
- Dependencies: **Phase 3** (units) and **Phase 4** (diagrams) must land first;
reuse **Phase 1** result fields.
**Gate:** the reference test — cantilever moment **50 kN·m at the fixed end**
(RES-061) — plus report generation off the GUI thread and preservation of the
original model file. Port `test_report_{csv,figures,typst,modal}.py` and the
`test_full_report.py` / `test_report_reference.py` integration tests.
### Phase 6 — Local axis (user-editable)
- **S11** Port `core/geometry/local_axis.py`; add
`local_axis: LocalAxis` to the frame elements (`ElasticBeamColumn`,
`ForceBeamColumn`, `DispBeamColumn`, `BeamWithHinges`). Change
`services/_emit.py` geomTransf to read the explicit axis and dedup by
`(type, vecxz, roll_deg)` instead of auto-deriving. Update the renderer
triad (its current `getattr(el, "vecxz")` at `model_renderer.py:727` is dead
code) and add an editor (property dock / dialog).
- **Critical migration risk:** a blind default `vecxz=(0,0,1)` is parallel to
a vertical member's axis and would break existing vertical models. Use a
**safe default rule** (e.g. derive the default from geometry exactly as the
runner does today, then only override when the user sets it) so existing
`.osmodel` files keep loading and producing identical results.
**Gate:** existing frame/eigen integration tests unchanged; new
`test_local_axis_parallel_rejected` + `test_geom_transf_dedup_by_combination`.
### Phase 7 — Load combinations + self-weight
- **S12** Port `core/combinations.py` and `commands/combinations.py`; add
`Project.combinations`; extend `validate_references`; add
`StaticCase.combination_id` with the XOR invariant (combination **or**
patterns+factors, ANL-001); materialise scaled loads in the runner
(LOD-054) without regressing the existing per-pattern factor path.
- **S13** Reimplement `services/self_weight.py` against this repo's types:
`w = ρ·A·g` distributed local for frames, equivalent nodal loads for trusses
(UNT-042/043), a `ConstantTimeSeries` + `PlainLoadPattern`, regeneration that
replaces the prior pattern (LOD-041), no-density/unresolved reporting
(LOD-042), and the gravity constant recorded in the description (UNT-045).
Add a `RegenerateSelfWeightCommand` (undoable).
- **S14** `ElasticIsotropic` currently is the only material with `rho`
(`materials/__init__.py:28`); add `rho` to the other self-weight-capable
materials, or report them as missing density.
- Depends on **Phase 3** (`GRAVITY`) and **Phase 6** (correct local projection).
**Gate:** port `test_loads_combinations.py` and `test_self_weight.py` (minus
the combination assertion until S12 lands); verify superposition matches a
hand calculation.
### Phase 8 — UI/UX adoption (larger, optional but valuable)
- **U3** `ValidatedDialog` base + migrate dialogs incrementally (unit suffix
via the Phase 3 API).
- **U4** Extract `ResultsVM` (per-case handle cache) and `CanvasVM` (selection
ownership) as supersets of the current state holders so existing call sites
keep working.
- **U5** Reimplement overlay **pure helpers** against this repo's
`deformation`/`element_forces`: ghost undeformed (CAN-062), unit-labelled
diagram extremes (CAN-082), zero-component hint (CAN-083), and
pattern-filtered load display (CAN-052/LOD-063). Unit-test them display-free.
- **U7** Adopt `ThemeManager` + a dark icon set — **verify SVG provenance and
license first** before copying any icon assets.
- **U8/U9** Thicken `ProjectViewModel` (move `action_handlers.py` logic into VM
methods, one command family at a time) and add analysis-case status /
Simple-vs-Advanced controls.
- **Q11/Q12** Spec-ID traceability harness and performance probes (only if the
team commits to the `[AREA-NNN]` convention and a perf budget).
**Gate:** GUI tests under `xvfb-run`; VM logic unit-tested without a display.
### Phase 9 — Deferred, only on explicit decision
- Polygon-first sections + `sections_mesh` (opstool **GPLv3**, lazy import,
optional `[sections]` extra; ensure AGPL compatibility and attribution).
- Frozen `Project`/`ProjectStore`/`CommandFactory` migration.
- Canvas backend Protocol + plotly backend + `services/scene.py`.
Revisit only after Phases 0–8 are stable and if the value justifies the blast
radius documented in the dossiers.
## 6. Dependency graph
```
Phase 0 (foundations)
│
├─► Phase 1 (sessions, persistence, result fields)
│ │
│ ├─► Phase 2 (progress/cancel, mass participation)
│ │
│ └─► Phase 3 (units display + GRAVITY) ──┐
│ │
│ Phase 4 (diagrams) ────────────────┤
│ ▼
│ Phase 5 (REPORTING) ★
│
├─► Phase 6 (local axis) ──┐
│ ▼
└─► Phase 7 (combinations + self-weight)
│
▼
Phase 8 (UI/UX) ──► Phase 9 (deferred)
```
**Critical path to the v1 promise (a printable case report):**
`Phase 3 → Phase 4 → Phase 5`, with `Phase 1` as a cheap prerequisite and
`Phase 2` interleavable.
## 7. Licensing & attribution
- This repo is **AGPL-3.0**; the rebuild is **MIT**. MIT code may be
incorporated into an AGPL work — **one-way**. Preserve the MIT notice:
add a `NOTICE` (Phase 0) and a short provenance header to each ported file.
- **Never** copy this repo's AGPL code back into the MIT rebuild.
- `opstool` is **GPLv3** (only relevant to deferred `sections_mesh`); GPLv3 ↔
AGPLv3 are compatible. Keep it lazy/optional and do not vendor its source.
- Verify the provenance/license of any copied **icon/SVG assets** before
adopting the dark icon set (U7).
## 8. Risks and mitigations
| Risk | Mitigation |
|---|---|
| Big-bang port destabilises the working app | Strict additive phases; feature flags for diagram/report migration; keep old paths until parity is proven |
| Freezing/command rewrite stalls progress | Explicitly deferred (Phase 9); combinations/self-weight do **not** require it |
| Units enum change corrupts stored models | Keep 4-value enum; add a family mapping + `DisplayPrefs`; byte-identical dump test |
| Local-axis default breaks vertical members | Derive default per-element as today; override only on explicit user set; regression tests on vertical frames |
| opstool private-API coupling / GPL | Deferred; pin version + thin adapter; lazy import; attribution |
| Report pipeline pulls GUI deps into headless | Keep `matplotlib`/Typst optional and imported lazily; headless CI must pass without `[reports]` |
| `StrEnum` / 3.11-only syntax | Target py3.10: avoid `StrEnum`, `zip(strict=)`, etc. |
| No VCS in this checkout | Initialise git + branch before any code changes |
| Tests can't run locally (deps missing) | Phase 0 fixes env expectations; run gates in CI |
## 9. Tracking
Adopt the rebuild's `.otko-build/tasks.json` **pattern** (schema, gates,
`{id, phase, layer, specs, title, tests, status, deps}`), seeded from this
plan. Each task names the phase, the spec IDs it implements, and its tests
(the rebuild's `tasks.json` is a working reference). Keep build state out of
versioned source (`.gitignore` it) or commit it deliberately — team choice.
## 10. Recommended first step
Execute **Phase 0** in one branch. It is almost entirely additive, it turns
the currently-inert mypy/CI gate into a real one, and it establishes the
attribution and docs baseline the later ports depend on. Then proceed
`1 → 2 → 3 → 4 → 5`, pulling Phase 6/7 forward only if self-weight is needed
before reporting.

View file

@ -1,37 +0,0 @@
# OTKO — specifications (this repo)
This folder holds **OTKO's own planning documents**. It is deliberately
separate from the greenfield rebuild spec set at
`~/Sync/otko-development/specifications` (docs `00`–`14`), which stays the
reference for *what a conformant rebuild looks like*. Requirement IDs of
the form `[AREA-NNN]` cited in this folder refer to that rebuild set.
## Why this folder exists
`~/Sync/otko` (this repo) is the **working, feature-rich app** and is the
primary deliverable. `~/Sync/otko-development` is a **greenfield rebuild**
that conforms closely to the rebuild specs but is less capable in several
areas (transient/pushover/response-spectrum, richer element/material
coverage, `.osmodel` corpus, HDF5). The goal of the plan below is to
**harvest the rebuild's good, spec-conformant capabilities into this app
without breaking what already works** — not to rewrite this app to the
rebuild's architecture.
## Documents
| # | Document | Purpose |
|---|---|---|
| 15 | [15-rebuild-adoption-plan.md](15-rebuild-adoption-plan.md) | Dependency-ordered plan for porting rebuild capabilities into this repo, with adoption matrix, phases, gates, risks and explicit skips. |
## Ground rules (summary — see doc 15 §3)
- **Additive, never replacement.** This repo is a superset of the rebuild
in the solver/runner/export surface; protect that.
- **Keep the working app working:** `.osmodel` format, AGPL-3.0 license,
Python 3.10–3.12 support, existing tests green.
- **Adapt, don't copy:** re-type ported code to this repo's APIs
(`StaticResults`/`ModalResults`, mutable `Project`, 4-value `UnitSystem`).
- **One phase = one branch = independently shippable**, each with its own
tests and gate.
- **License hygiene:** the rebuild is MIT, this repo is AGPL-3.0; MIT→AGPL
is compatible, but ported files need attribution (see doc 15 §7).

View file

@ -6,11 +6,6 @@ from otko.commands.analysis import (
UpdateAnalysisCaseCommand,
)
from otko.commands.base import ProjectCommand
from otko.commands.combinations import (
AddCombinationsCommand,
DeleteCombinationsCommand,
UpdateCombinationCommand,
)
from otko.commands.constraints import (
AddEqualDOFConstraintCommand,
DeleteEqualDOFConstraintCommand,
@ -29,6 +24,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 +58,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,
@ -72,50 +67,25 @@ from otko.commands.transforms import (
__all__ = [
"ProjectCommand",
"AddNodesCommand",
"DeleteNodesCommand",
"SetRestraintCommand",
"SetMassCommand",
"AddNodesCommand", "DeleteNodesCommand", "SetRestraintCommand", "SetMassCommand",
"UpdateNodeFieldsCommand",
"AddElementsCommand",
"DeleteElementsCommand",
"AssignSectionCommand",
"AssignMaterialCommand",
"ReplaceElementsCommand",
"ConvertElementTypeCommand",
"AddElementsCommand", "DeleteElementsCommand",
"AssignSectionCommand", "AssignMaterialCommand",
"ReplaceElementsCommand", "ConvertElementTypeCommand",
"UpdateElementFieldsCommand",
"AddMaterialsCommand",
"DeleteMaterialsCommand",
"UpdateMaterialCommand",
"AddSectionsCommand",
"DeleteSectionsCommand",
"UpdateSectionCommand",
"AddNodalLoadsCommand",
"AddElementLoadsCommand",
"UpdateNodalLoadCommand",
"DeleteNodalLoadsCommand",
"UpdateElementLoadCommand",
"DeleteElementLoadsCommand",
"MoveNodesCommand",
"ReplicateCommand",
"MirrorCommand",
"Plane",
"AddAnalysisCasesCommand",
"DeleteAnalysisCasesCommand",
"UpdateAnalysisCaseCommand",
"AddCombinationsCommand",
"DeleteCombinationsCommand",
"UpdateCombinationCommand",
"AddMaterialsCommand", "DeleteMaterialsCommand", "UpdateMaterialCommand",
"AddSectionsCommand", "DeleteSectionsCommand", "UpdateSectionCommand",
"AddNodalLoadsCommand", "AddElementLoadsCommand",
"UpdateNodalLoadCommand", "DeleteNodalLoadsCommand",
"UpdateElementLoadCommand", "DeleteElementLoadsCommand",
"MoveNodesCommand", "ReplicateCommand", "MirrorCommand", "Plane",
"AddAnalysisCasesCommand", "DeleteAnalysisCasesCommand", "UpdateAnalysisCaseCommand",
"SetGridSystemCommand",
"SetCoordSystemsCommand",
"AddTimeSeriesCommand",
"AddLoadPatternCommand",
"UpdateTimeSeriesCommand",
"DeleteTimeSeriesCommand",
"UpdateLoadPatternCommand",
"DeleteLoadPatternCommand",
"AddTimeSeriesCommand", "AddLoadPatternCommand",
"UpdateTimeSeriesCommand", "DeleteTimeSeriesCommand",
"UpdateLoadPatternCommand", "DeleteLoadPatternCommand",
"AddEqualDOFConstraintCommand",
"UpdateEqualDOFConstraintCommand",
"DeleteEqualDOFConstraintCommand",
"UpdateEqualDOFConstraintCommand", "DeleteEqualDOFConstraintCommand",
"SetUnitsCommand",
]

View file

@ -13,7 +13,8 @@ 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,16 +35,19 @@ 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]] = []
def redo(self) -> None:
self._removed = [
(i, c) for i, c in enumerate(self.project.analyses) if c.id in self._case_ids
(i, c) for i, c in enumerate(self.project.analyses)
if c.id in self._case_ids
]
self.project.analyses[:] = [
c for c in self.project.analyses if c.id not in self._case_ids
]
self.project.analyses[:] = [c for c in self.project.analyses if c.id not in self._case_ids]
self._notify()
def undo(self) -> None:
@ -56,7 +60,7 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
class UpdateAnalysisCaseCommand(ProjectCommand):
"""Replace an analysis case at a given id."""
def __init__(self, vm: ProjectViewModel, new_case: Any) -> None:
def __init__(self, vm: "ProjectViewModel", new_case: Any) -> None:
super().__init__(vm, f"Edit analysis case {new_case.id}")
self._new = new_case
self._old: Any | None = None

View file

@ -27,18 +27,20 @@ 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.")
raise RuntimeError(
f"Cannot apply '{self.text()}': no active project."
)
return self._vm.project
def _notify(self) -> None:

View file

@ -1,80 +0,0 @@
"""Commands for load-combination management."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from otko.commands.base import ProjectCommand
if TYPE_CHECKING:
from otko.viewmodels import ProjectViewModel
class AddCombinationsCommand(ProjectCommand):
"""Add one or more load combinations."""
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)
def redo(self) -> None:
existing = {c.id for c in self.project.combinations}
for combo in self._combos:
if combo.id in existing:
raise ValueError(f"Load combination id {combo.id} already exists.")
self.project.combinations.extend(self._combos)
self._notify()
def undo(self) -> None:
ids = {c.id for c in self._combos}
self.project.combinations[:] = [c for c in self.project.combinations if c.id not in ids]
self._notify()
class DeleteCombinationsCommand(ProjectCommand):
"""Remove a set of load combinations."""
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]] = []
def redo(self) -> None:
self._removed = [
(i, c) for i, c in enumerate(self.project.combinations) if c.id in self._combo_ids
]
self.project.combinations[:] = [
c for c in self.project.combinations if c.id not in self._combo_ids
]
self._notify()
def undo(self) -> None:
for i, c in self._removed:
self.project.combinations.insert(min(i, len(self.project.combinations)), c)
self._removed.clear()
self._notify()
class UpdateCombinationCommand(ProjectCommand):
"""Replace a load combination at a given id."""
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
self._index: int | None = None
def redo(self) -> None:
for i, c in enumerate(self.project.combinations):
if c.id == self._new.id:
self._old = c
self._index = i
self.project.combinations[i] = self._new
self._notify()
return
raise KeyError(f"Load combination with id={self._new.id} not found.")
def undo(self) -> None:
if self._old is not None and self._index is not None:
self.project.combinations[self._index] = self._old
self._notify()

View file

@ -14,7 +14,7 @@ if TYPE_CHECKING:
class AddEqualDOFConstraintCommand(ProjectCommand):
"""Append an ``equalDOF`` constraint to the project (undoable)."""
def __init__(self, vm: ProjectViewModel, constraint: EqualDOFConstraint) -> None:
def __init__(self, vm: "ProjectViewModel", constraint: EqualDOFConstraint) -> None:
super().__init__(
vm,
f"Add equalDOF {constraint.retained_node}->{constraint.constrained_node}",
@ -34,7 +34,8 @@ class AddEqualDOFConstraintCommand(ProjectCommand):
def undo(self) -> None:
self.project.mp_constraints[:] = [
mp for mp in self.project.mp_constraints if mp != self._constraint
mp for mp in self.project.mp_constraints
if mp != self._constraint
]
self._notify()
@ -48,7 +49,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 +72,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
class DeleteEqualDOFConstraintCommand(ProjectCommand):
"""Remove ``equalDOF`` constraints by table-row index (no cascade)."""
def __init__(self, vm: ProjectViewModel, indices: set[int]) -> None:
def __init__(self, vm: "ProjectViewModel", indices: set[int]) -> None:
super().__init__(vm, f"Delete {len(indices)} constraint(s)")
self._indices = set(indices)
self._removed: list[tuple[int, EqualDOFConstraint]] = []

View file

@ -14,7 +14,7 @@ class AddElementsCommand(ProjectCommand):
"""Add one or more elements in a single undoable step."""
def __init__(
self, vm: ProjectViewModel, elements: list[Any], *, text: str | None = None
self, vm: "ProjectViewModel", elements: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(elements)} element(s)")
self._elements = list(elements)
@ -40,7 +40,7 @@ class AddElementsCommand(ProjectCommand):
class DeleteElementsCommand(ProjectCommand):
"""Remove a set of elements (no cascade — nodes are not affected)."""
def __init__(self, vm: ProjectViewModel, element_ids: set[int]) -> None:
def __init__(self, vm: "ProjectViewModel", element_ids: set[int]) -> None:
super().__init__(vm, f"Delete {len(element_ids)} element(s)")
self._element_ids = set(element_ids)
self._removed: list[tuple[int, Any]] = []
@ -68,7 +68,7 @@ class AssignSectionCommand(ProjectCommand):
(e.g. trusses, zero-length elements).
"""
def __init__(self, vm: ProjectViewModel, element_ids: set[int], section_id: int) -> None:
def __init__(self, vm: "ProjectViewModel", element_ids: set[int], section_id: int) -> None:
super().__init__(vm, f"Assign section {section_id} to {len(element_ids)} element(s)")
self._element_ids = set(element_ids)
self._section_id = section_id
@ -106,7 +106,7 @@ class ConvertElementTypeCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
element_ids: set[int],
target_type: str,
defaults: dict[str, Any] | None = None,
@ -185,7 +185,7 @@ class ReplaceElementsCommand(ProjectCommand):
for another while preserving the id and node connectivity.
"""
def __init__(self, vm: ProjectViewModel, replacements: list[Any]) -> None:
def __init__(self, vm: "ProjectViewModel", replacements: list[Any]) -> None:
super().__init__(vm, f"Replace {len(replacements)} element(s)")
self._replacements = {el.id: el for el in replacements}
self._previous: dict[int, Any] = {}
@ -217,7 +217,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
element_id: int,
fields: dict[str, Any],
) -> None:
@ -254,7 +254,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
class AssignMaterialCommand(ProjectCommand):
"""Set ``material_id`` on a set of elements (truss-style)."""
def __init__(self, vm: ProjectViewModel, element_ids: set[int], material_id: int) -> None:
def __init__(self, vm: "ProjectViewModel", element_ids: set[int], material_id: int) -> None:
super().__init__(vm, f"Assign material {material_id} to {len(element_ids)} element(s)")
self._element_ids = set(element_ids)
self._material_id = material_id

View file

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

View file

@ -35,7 +35,7 @@ class AddNodalLoadsCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
node_ids: set[int],
forces: tuple[float, float, float, float, float, float],
pattern_id: int | None = None,
@ -47,7 +47,7 @@ class AddNodalLoadsCommand(ProjectCommand):
self._forces = forces
self._pattern_id = pattern_id
self._new_pattern_name = new_pattern_name
self._new_ts_type = new_ts_type # "Linear" or "Constant"
self._new_ts_type = new_ts_type # "Linear" or "Constant"
self._created_ts: TimeSeries | None = None
self._created_pattern: PlainLoadPattern | None = None
self._added_loads: list[tuple[int, NodalLoad]] = [] # (pattern_id, load)
@ -101,10 +101,7 @@ class AddNodalLoadsCommand(ProjectCommand):
break
self._added_loads.clear()
# Roll back any infrastructure we created.
if (
self._created_pattern is not None
and self._created_pattern in self.project.load_patterns
):
if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series:
@ -122,7 +119,7 @@ class AddElementLoadsCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
element_ids: set[int],
wy: float = 0.0,
wz: float = 0.0,
@ -160,10 +157,7 @@ class AddElementLoadsCommand(ProjectCommand):
pattern = self._resolve_pattern()
for eid in self._element_ids:
load = UniformElementLoad(
element_id=eid,
wy=self._wy,
wz=self._wz,
wx=self._wx,
element_id=eid, wy=self._wy, wz=self._wz, wx=self._wx,
)
pattern.element_loads.append(load)
self._added_loads.append((pattern.id, load))
@ -177,10 +171,7 @@ class AddElementLoadsCommand(ProjectCommand):
pat.element_loads.remove(load)
break
self._added_loads.clear()
if (
self._created_pattern is not None
and self._created_pattern in self.project.load_patterns
):
if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series:

View file

@ -13,9 +13,7 @@ if TYPE_CHECKING:
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
) -> None:
def __init__(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,14 +34,15 @@ 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]] = []
def redo(self) -> None:
self._removed = [
(i, m) for i, m in enumerate(self.project.materials) if m.id in self._material_ids
(i, m) for i, m in enumerate(self.project.materials)
if m.id in self._material_ids
]
self.project.materials[:] = [
m for m in self.project.materials if m.id not in self._material_ids
@ -64,7 +63,7 @@ class UpdateMaterialCommand(ProjectCommand):
otherwise this is an Add+Delete, not an update.
"""
def __init__(self, vm: ProjectViewModel, new_material: Any) -> None:
def __init__(self, vm: "ProjectViewModel", new_material: Any) -> None:
super().__init__(vm, f"Edit material {new_material.id}")
self._new = new_material
self._old: Any | None = None

View file

@ -14,7 +14,7 @@ if TYPE_CHECKING:
class AddNodesCommand(ProjectCommand):
"""Add one or more nodes in a single undoable step."""
def __init__(self, vm: ProjectViewModel, nodes: list[Node], *, text: str | None = None) -> None:
def __init__(self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None) -> None:
super().__init__(vm, text or f"Add {len(nodes)} node(s)")
self._nodes = list(nodes)
@ -39,18 +39,17 @@ 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)
self._removed_elements: list[tuple[int, object]] = [] # (index, element)
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
self._removed_elements: list[tuple[int, object]] = [] # (index, element)
self._removed_mp_constraints: list[tuple[int, object]] = []
def redo(self) -> None:
# Cascade: snapshot every element that references a doomed node.
self._removed_elements = [
(i, el)
for i, el in enumerate(self.project.elements)
(i, el) for i, el in enumerate(self.project.elements)
if any(nid in self._node_ids for nid in el.nodes)
]
doomed_elem_ids = {el.id for _, el in self._removed_elements}
@ -59,8 +58,7 @@ class DeleteNodesCommand(ProjectCommand):
]
self._removed_mp_constraints = [
(i, mp)
for i, mp in enumerate(self.project.mp_constraints)
(i, mp) for i, mp in enumerate(self.project.mp_constraints)
if mp.retained_node in self._node_ids or mp.constrained_node in self._node_ids
]
doomed_mp = {id(mp) for _, mp in self._removed_mp_constraints}
@ -71,7 +69,9 @@ class DeleteNodesCommand(ProjectCommand):
self._removed_nodes = [
(i, n) for i, n in enumerate(self.project.nodes) if n.id in self._node_ids
]
self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._node_ids]
self.project.nodes[:] = [
n for n in self.project.nodes if n.id not in self._node_ids
]
self._notify()
def undo(self) -> None:
@ -93,7 +93,7 @@ class SetRestraintCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
node_ids: set[int],
restraint: tuple[bool, bool, bool, bool, bool, bool],
) -> None:
@ -128,7 +128,7 @@ class UpdateNodeFieldsCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
node_id: int,
fields: dict[str, Any],
) -> None:
@ -166,7 +166,7 @@ class SetMassCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
node_ids: set[int],
mass: tuple[float, float, float, float, float, float],
) -> None:

View file

@ -19,7 +19,7 @@ class SetUnitsCommand(ProjectCommand):
so both redo and undo notify (dirty + modelMutated) to refresh.
"""
def __init__(self, vm: ProjectViewModel, units: UnitSystem) -> None:
def __init__(self, vm: "ProjectViewModel", units: UnitSystem) -> None:
super().__init__(vm, f"Set display units to {units.value}")
self._units = units
self._previous: UnitSystem | None = None

View file

@ -13,9 +13,7 @@ if TYPE_CHECKING:
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
) -> None:
def __init__(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,14 +34,15 @@ 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]] = []
def redo(self) -> None:
self._removed = [
(i, s) for i, s in enumerate(self.project.sections) if s.id in self._section_ids
(i, s) for i, s in enumerate(self.project.sections)
if s.id in self._section_ids
]
self.project.sections[:] = [
s for s in self.project.sections if s.id not in self._section_ids
@ -60,7 +59,7 @@ class DeleteSectionsCommand(ProjectCommand):
class UpdateSectionCommand(ProjectCommand):
"""Replace a section's parameters at a given id."""
def __init__(self, vm: ProjectViewModel, new_section: Any) -> None:
def __init__(self, vm: "ProjectViewModel", new_section: Any) -> None:
super().__init__(vm, f"Edit section {new_section.id}")
self._new = new_section
self._old: Any | None = None

View file

@ -19,7 +19,7 @@ if TYPE_CHECKING:
class AddTimeSeriesCommand(ProjectCommand):
"""Append a :class:`TimeSeries` to the project (undoable)."""
def __init__(self, vm: ProjectViewModel, ts: TimeSeries) -> None:
def __init__(self, vm: "ProjectViewModel", ts: "TimeSeries") -> None:
super().__init__(vm, f"Add time series '{ts.name or ts.id}'")
self._ts = ts
@ -31,7 +31,9 @@ class AddTimeSeriesCommand(ProjectCommand):
self._notify()
def undo(self) -> None:
self.project.time_series[:] = [t for t in self.project.time_series if t.id != self._ts.id]
self.project.time_series[:] = [
t for t in self.project.time_series if t.id != self._ts.id
]
self._notify()
@ -41,7 +43,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 +69,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 +93,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 +119,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 +144,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
class DeleteLoadPatternCommand(ProjectCommand):
"""Remove load patterns (their child nodal/element loads go with them)."""
def __init__(self, vm: ProjectViewModel, pattern_ids: set[int]) -> None:
def __init__(self, vm: "ProjectViewModel", pattern_ids: set[int]) -> None:
super().__init__(vm, f"Delete {len(pattern_ids)} pattern(s)")
self._pattern_ids = set(pattern_ids)
self._removed: list[tuple[int, Any]] = []

View file

@ -15,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:
@ -44,9 +45,9 @@ class MoveNodesCommand(ProjectCommand):
for i, n in enumerate(self.project.nodes):
if n.id in self._node_ids:
self._previous[n.id] = n.coords
self.project.nodes[i] = n.model_copy(
update={"coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)}
)
self.project.nodes[i] = n.model_copy(update={
"coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)
})
self._notify()
def undo(self) -> 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],
@ -83,8 +84,7 @@ class ReplicateCommand(ProjectCommand):
# Snapshot the source nodes/elements once (won't change during redo).
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
src_elements = [
e
for e in self.project.elements
e for e in self.project.elements
if e.id in self._element_ids and all(nid in self._node_ids for nid in e.nodes)
]
next_node_id = self.project.next_node_id()
@ -95,34 +95,30 @@ class ReplicateCommand(ProjectCommand):
for k in range(1, self._n_copies + 1):
mapping: dict[int, int] = {}
for orig in src_nodes:
new_node = orig.model_copy(
update={
"id": next_node_id,
"coords": (
orig.coords[0] + k * dx,
orig.coords[1] + k * dy,
orig.coords[2] + k * dz,
),
}
)
new_node = orig.model_copy(update={
"id": next_node_id,
"coords": (orig.coords[0] + k * dx,
orig.coords[1] + k * dy,
orig.coords[2] + k * dz),
})
self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id
next_node_id += 1
for orig in src_elements:
new_elem = orig.model_copy(
update={
"id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes),
}
)
new_elem = orig.model_copy(update={
"id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes),
})
self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id)
next_elem_id += 1
self._notify()
def undo(self) -> None:
self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._added_node_ids]
self.project.nodes[:] = [
n for n in self.project.nodes if n.id not in self._added_node_ids
]
self.project.elements[:] = [
e for e in self.project.elements if e.id not in self._added_element_ids
]
@ -146,7 +142,7 @@ class MirrorCommand(ProjectCommand):
def __init__(
self,
vm: ProjectViewModel,
vm: "ProjectViewModel",
node_ids: set[int],
element_ids: set[int],
plane: Plane,
@ -170,8 +166,7 @@ class MirrorCommand(ProjectCommand):
def redo(self) -> None:
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
src_elements = [
e
for e in self.project.elements
e for e in self.project.elements
if e.id in self._element_ids and all(nid in self._node_ids for nid in e.nodes)
]
next_node_id = self.project.next_node_id()
@ -181,30 +176,28 @@ class MirrorCommand(ProjectCommand):
mapping: dict[int, int] = {}
for orig in src_nodes:
new_node = orig.model_copy(
update={
"id": next_node_id,
"coords": self._reflect(orig.coords, self._plane),
}
)
new_node = orig.model_copy(update={
"id": next_node_id,
"coords": self._reflect(orig.coords, self._plane),
})
self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id
next_node_id += 1
for orig in src_elements:
new_elem = orig.model_copy(
update={
"id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes),
}
)
new_elem = orig.model_copy(update={
"id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes),
})
self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id)
next_elem_id += 1
self._notify()
def undo(self) -> None:
self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._added_node_ids]
self.project.nodes[:] = [
n for n in self.project.nodes if n.id not in self._added_node_ids
]
self.project.elements[:] = [
e for e in self.project.elements if e.id not in self._added_element_ids
]

View file

@ -50,8 +50,6 @@ from otko.core.loads import (
ConstantTimeSeries,
ImposedSupportMotionPattern,
LinearTimeSeries,
LoadCombination,
LoadCombinationItem,
LoadPattern,
NodalLoad,
PathTimeSeries,
@ -167,8 +165,6 @@ __all__ = [
"PlainLoadPattern",
"UniformExcitationPattern",
"ImposedSupportMotionPattern",
"LoadCombination",
"LoadCombinationItem",
"NodalLoad",
"UniformElementLoad",
"ResponseSpectrum",

View file

@ -16,13 +16,11 @@ class Entity(BaseModel):
"""Base class for every persisted domain object."""
model_config = ConfigDict(
frozen=False, # individual setters allowed; we lock at the Project boundary
extra="forbid", # unknown JSON keys are an error, not a silent ignore
frozen=False, # individual setters allowed; we lock at the Project boundary
extra="forbid", # unknown JSON keys are an error, not a silent ignore
validate_assignment=True,
populate_by_name=True,
)
id: PositiveInt = Field(
..., description="Unique tag within its kind. Used as the OpenSees tag."
)
id: PositiveInt = Field(..., description="Unique tag within its kind. Used as the OpenSees tag.")
name: str = Field(default="", description="Optional human-readable label.")

View file

@ -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
@ -83,8 +83,7 @@ class TransientCase(Entity):
constraints: str = "Plain"
integrator: str = "Newmark"
integrator_params: tuple[float, float] = Field(
default=(0.5, 0.25),
description="Newmark gamma, beta (default = average acceleration).",
default=(0.5, 0.25), description="Newmark gamma, beta (default = average acceleration).",
)
algorithm: str = "Newton"
test: str = "NormDispIncr"
@ -101,7 +100,7 @@ class TransientCase(Entity):
rayleigh_beta_k: float = Field(
default=0.0,
description="Stiffness-proportional βK on CURRENT/tangent stiffness — "
"``rayleigh`` slot 2 (damps high frequencies).",
"``rayleigh`` slot 2 (damps high frequencies).",
)
rayleigh_beta_k_init: float = Field(
default=0.0,
@ -115,11 +114,11 @@ class TransientCase(Entity):
)
rayleigh_beta_k_comm: float = Field(
default=0.0,
description="Stiffness-proportional βK on COMMITTED stiffness — " "``rayleigh`` slot 4.",
description="Stiffness-proportional βK on COMMITTED stiffness — "
"``rayleigh`` slot 4.",
)
rayleigh_mode1_damping: float | None = Field(
default=None,
ge=0.0,
default=None, ge=0.0,
description=(
"If set, βK is computed as 2·ζ/√λ₁ (first-mode eigenvalue) and "
"overrides ``rayleigh_beta_k``. ``rayleigh_alpha_m`` still applies."
@ -196,7 +195,7 @@ class PushoverCase(Entity):
base_nodes: list[PositiveInt] = Field(
default_factory=list,
description="Nodes whose reactions sum into the 'base shear' for the curve. "
"Leave empty to use every restrained node in the project.",
"Leave empty to use every restrained node in the project.",
)
system: str = "BandGeneral"
constraints: str = "Plain"
@ -245,30 +244,23 @@ class ResponseSpectrumCase(Entity):
type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
modal_case_id: PositiveInt = Field(
...,
description="ID of the ModalCase whose mode shapes drive this analysis.",
..., description="ID of the ModalCase whose mode shapes drive this analysis.",
)
spectrum_id: PositiveInt = Field(
...,
description="ID of the ResponseSpectrum to apply.",
..., description="ID of the ResponseSpectrum to apply.",
)
direction: int = Field(
...,
ge=1,
le=6,
description="DOF direction (1..6) for the seismic excitation.",
..., ge=1, le=6, description="DOF direction (1..6) for the seismic excitation.",
)
combination: Literal["SRSS", "CQC"] = "SRSS"
damping_ratio: float | None = Field(
default=None,
ge=0.0,
le=1.0,
default=None, ge=0.0, le=1.0,
description="Override the spectrum's damping for CQC correlation. "
"Defaults to the spectrum's damping_ratio.",
"Defaults to the spectrum's damping_ratio.",
)
AnalysisCase = Annotated[
StaticCase | ModalCase | TransientCase | PushoverCase | ResponseSpectrumCase,
Union[StaticCase, ModalCase, TransientCase, PushoverCase, ResponseSpectrumCase],
Field(discriminator="type"),
]

View file

@ -159,8 +159,12 @@ def ensure_default_timeseries_and_pattern(
if existing is not None:
return existing
pattern_name = name or DEFAULT_PATTERN_NAME
ts = make_default_time_series(project.next_time_series_id(), kind=ts_kind, name=pattern_name)
ts = make_default_time_series(
project.next_time_series_id(), kind=ts_kind, name=pattern_name
)
project.time_series.append(ts)
pattern = make_default_pattern(project.next_pattern_id(), ts.id, name=pattern_name)
pattern = make_default_pattern(
project.next_pattern_id(), ts.id, name=pattern_name
)
project.load_patterns.append(pattern)
return pattern

View file

@ -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
@ -91,9 +91,7 @@ class ZeroLengthElement(Entity):
type: Literal["ZeroLength"] = "ZeroLength"
nodes: tuple[PositiveInt, PositiveInt]
material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1)
dofs: tuple[int, ...] = Field(
..., min_length=1, description="DOF directions, 1-indexed (1..6)."
)
dofs: tuple[int, ...] = Field(..., min_length=1, description="DOF directions, 1-indexed (1..6).")
do_rayleigh: bool = Field(
default=False,
description=(
@ -123,9 +121,7 @@ class ZeroLengthSectionElement(Entity):
type: Literal["ZeroLengthSection"] = "ZeroLengthSection"
nodes: tuple[PositiveInt, PositiveInt]
section_id: PositiveInt = Field(
..., description="Section attached to the two coincident nodes."
)
section_id: PositiveInt = Field(..., description="Section attached to the two coincident nodes.")
class BeamWithHingesElement(Entity):
@ -183,8 +179,7 @@ class QuadElement(Entity):
description="Surface pressure applied over the element (force / area).",
)
rho: float = Field(
default=0.0,
ge=0.0,
default=0.0, ge=0.0,
description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.",
)
b1: float = Field(
@ -198,14 +193,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"),
]

View file

@ -87,17 +87,15 @@ class GridSystem(BaseModel):
if isinstance(v, dict):
records.append(v)
else:
records.append(
{
"id": f"{prefix}{i + 1}",
"ordinate": float(v),
}
)
records.append({
"id": f"{prefix}{i + 1}",
"ordinate": float(v),
})
data[grid_key] = records
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)
@ -123,21 +121,21 @@ class GridSystem(BaseModel):
def bounds(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
"""Return ((xmin, xmax), (ymin, ymax), (zmin, zmax)) spanning the grid."""
def span(vs: list[float]) -> tuple[float, float]:
if not vs:
return (0.0, 0.0)
return (vs[0], vs[-1])
return span(self.x_lines), span(self.y_lines), span(self.z_lines)
def make_grid_lines(
axis: Literal["X", "Y", "Z"],
ordinates: list[float],
axis: Literal["X", "Y", "Z"], ordinates: list[float],
) -> list[GridLine]:
"""Helper: build default-metadata GridLine records from flat ordinates."""
return [GridLine(id=f"{axis}{i + 1}", ordinate=float(v)) for i, v in enumerate(ordinates)]
return [
GridLine(id=f"{axis}{i + 1}", ordinate=float(v))
for i, v in enumerate(ordinates)
]
class CoordinateSystem(BaseModel):
@ -162,9 +160,9 @@ class CoordinateSystem(BaseModel):
cz, sz = math.cos(rz), math.sin(rz)
# Rz · Ry · Rx (applied right-to-left: Rx first, then Ry, then Rz).
return [
[cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
[cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
[-sy, sx * cy, cx * cy],
[cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
[cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
[-sy, sx * cy, cx * cy],
]
def local_to_world(self, p_local: tuple[float, float, float]) -> tuple[float, float, float]:

View file

@ -12,9 +12,9 @@ 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
from pydantic import BaseModel, ConfigDict, Field, PositiveInt
from otko.core._base import Entity
@ -77,19 +77,15 @@ class ResponseSpectrum(Entity):
type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
periods: list[float] = Field(
...,
min_length=2,
..., min_length=2,
description="Periods (s), strictly increasing.",
)
accelerations: list[float] = Field(
...,
min_length=2,
..., min_length=2,
description="Spectral pseudo-accelerations (length must match `periods`).",
)
damping_ratio: float = Field(
default=0.05,
ge=0.0,
le=1.0,
default=0.05, ge=0.0, le=1.0,
description="Modal damping ratio the spectrum was built for.",
)
@ -110,7 +106,7 @@ class ResponseSpectrum(Entity):
TimeSeries = Annotated[
LinearTimeSeries | ConstantTimeSeries | PathTimeSeries,
Union[LinearTimeSeries, ConstantTimeSeries, PathTimeSeries],
Field(discriminator="type"),
]
@ -189,67 +185,13 @@ class ImposedSupportMotionPattern(Entity):
direction: int = Field(..., ge=1, le=6, description="DOF direction (1..6).")
disp_series_id: PositiveInt
node_ids: list[int] = Field(
...,
min_length=1,
..., min_length=1,
description="Support nodes driven by the motion (each restrained in `direction`).",
)
factor: float = 1.0
# ──────────────────────────── Load combinations ────────────────────────────
class LoadCombinationItem(BaseModel):
"""One term of a case-result load combination: ``factor × results(case_id)``."""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
case_id: PositiveInt = Field(..., description="AnalysisCase whose results are scaled.")
factor: float = Field(default=1.0, description="Scale applied to that case's results.")
class LoadCombination(Entity):
"""Named combination of *completed* analysis-case results (post-processing).
A ``Linear`` combination is the weighted sum
``Σ factor_i × results(case_i)`` over nodal displacements, reactions,
and element forces. Valid for linear-elastic static superposition
(e.g. ``1.2×Dead + 1.6×Live``) — combining nonlinear, transient, or
modal results is rejected at evaluation time, not here, so projects
stay loadable while the UI guides the user.
An ``Envelope`` combination reports the per-DOF, per-step maximum and
minimum across the factored terms instead of their sum.
"""
type: Literal["LoadCombination"] = "LoadCombination"
kind: Literal["Linear", "Envelope"] = Field(
default="Linear", description="Linear = weighted sum; Envelope = per-DOF max/min."
)
items: list[LoadCombinationItem] = Field(
..., min_length=1, description="Terms of the combination (≥1; ≥2 recommended)."
)
@model_validator(mode="after")
def _check_items(self) -> LoadCombination:
"""Reject duplicate case ids and non-finite factors."""
import math
seen: set[int] = set()
dup: set[int] = set()
for item in self.items:
if item.case_id in seen:
dup.add(item.case_id)
seen.add(item.case_id)
if not math.isfinite(item.factor):
raise ValueError(
f"LoadCombination {self.id}: factor for case {item.case_id} "
f"must be finite, got {item.factor!r}."
)
if dup:
raise ValueError(f"LoadCombination {self.id}: duplicate case ids: {sorted(dup)}.")
return self
LoadPattern = Annotated[
PlainLoadPattern | UniformExcitationPattern | ImposedSupportMotionPattern,
Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern],
Field(discriminator="type"),
]

View file

@ -11,7 +11,7 @@ https://openseespydoc.readthedocs.io/en/latest/src/uniaxialMaterial.html
from __future__ import annotations
from typing import Annotated, Literal
from typing import Annotated, Literal, Union
from pydantic import Field, PositiveFloat, model_validator
@ -58,9 +58,7 @@ class Steel02(Entity):
Fy: PositiveFloat
E0: PositiveFloat
b: float = Field(..., ge=0.0, le=1.0)
R0: float = Field(
default=18.0, description="Bauschinger curvature parameter (typically 10–20)."
)
R0: float = Field(default=18.0, description="Bauschinger curvature parameter (typically 10–20).")
cR1: float = Field(default=0.925)
cR2: float = Field(default=0.15)
@ -85,10 +83,7 @@ class Concrete02(Entity):
fpcu: float = Field(..., le=0.0)
epsU: float = Field(..., lt=0.0)
lambda_: float = Field(
...,
alias="lambda",
ge=0.0,
le=1.0,
..., alias="lambda", ge=0.0, le=1.0,
description="Ratio between unloading slope at epscu and initial slope.",
)
ft: PositiveFloat = Field(..., description="Tensile strength.")
@ -113,24 +108,20 @@ class Concrete04(Entity):
epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).")
Ec: PositiveFloat = Field(..., description="Initial tangent modulus.")
fct: float | None = Field(
default=None,
gt=0.0,
default=None, gt=0.0,
description="Maximum tensile strength. Omit for no-tension model.",
)
et: float | None = Field(
default=None,
gt=0.0,
default=None, gt=0.0,
description="Ultimate tensile strain. Required when fct is given.",
)
beta: float | None = Field(
default=None,
ge=0.0,
le=1.0,
default=None, ge=0.0, le=1.0,
description="Cyclic degradation factor on unloading stiffness. Requires fct and et.",
)
@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:
@ -147,8 +138,7 @@ class ElasticPP(Entity):
E: PositiveFloat
epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.")
epsy_neg: float | None = Field(
default=None,
description="Yield strain in compression (negative); defaults to -epsy_pos.",
default=None, description="Yield strain in compression (negative); defaults to -epsy_pos.",
)
eps0: float = Field(default=0.0, description="Initial strain.")
@ -186,8 +176,7 @@ class HystereticMaterial(Entity):
d1: float = Field(default=0.0, ge=0.0, description="Ductility damage, linear portion.")
d2: float = Field(default=0.0, ge=0.0, description="Ductility damage, cumulative portion.")
beta: float = Field(
default=0.0,
ge=0.0,
default=0.0, ge=0.0,
description="Unloading-stiffness degradation (0 = no degradation).",
)
@ -217,8 +206,7 @@ class HystereticSM(Entity):
type: Literal["HystereticSM"] = "HystereticSM"
pos_env: list[tuple[float, float]] = Field(
...,
min_length=1,
..., min_length=1,
description="Positive envelope (force, deformation) pairs, force first.",
)
neg_env: list[tuple[float, float]] = Field(
@ -229,16 +217,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."""

View file

@ -30,7 +30,7 @@ from otko.core.geometry import (
Node,
default_global_system,
)
from otko.core.loads import LoadCombination, LoadPattern, ResponseSpectrum, TimeSeries
from otko.core.loads import LoadPattern, ResponseSpectrum, TimeSeries
from otko.core.materials import Material
from otko.core.sections import Section
from otko.core.units import UnitSystem
@ -110,7 +110,6 @@ _INDEX_KINDS: tuple[str, ...] = (
"load_patterns",
"spectra",
"analyses",
"combinations",
)
@ -156,17 +155,15 @@ class Project(BaseModel):
"""
if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data:
legacy = data.pop("grid_system")
data["coord_systems"] = [
{
"name": "Global",
"coord": {},
"grid": legacy,
}
]
data["coord_systems"] = [{
"name": "Global",
"coord": {},
"grid": legacy,
}]
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 +171,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``,
@ -206,12 +203,10 @@ class Project(BaseModel):
return
# No Global system yet — create one with this grid.
from otko.core.geometry import CoordinateGridSystem
self.coord_systems.insert(
0,
CoordinateGridSystem(name="Global", grid=new_grid),
)
sections: list[Section] = Field(default_factory=list)
elements: list[Element] = Field(default_factory=list)
mp_constraints: list[EqualDOFConstraint] = Field(default_factory=list)
@ -219,11 +214,10 @@ class Project(BaseModel):
load_patterns: list[LoadPattern] = Field(default_factory=list)
spectra: list[ResponseSpectrum] = Field(default_factory=list)
analyses: list[AnalysisCase] = Field(default_factory=list)
combinations: list[LoadCombination] = Field(default_factory=list)
# ─────────────────── 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 +227,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),
@ -242,7 +236,6 @@ class Project(BaseModel):
("time series", self.time_series),
("load pattern", self.load_patterns),
("analysis", self.analyses),
("combination", self.combinations),
):
ids = [it.id for it in items]
if len(ids) != len(set(ids)):
@ -277,10 +270,6 @@ class Project(BaseModel):
def next_analysis_id(self) -> int:
return self._next_id(self.analyses)
def next_combination_id(self) -> int:
"""Return the next free load-combination id."""
return self._next_id(self.combinations)
# ─────────────────── lookups ───────────────────
def node(self, node_id: PositiveInt) -> Node:
return cast(Node, self._lookup("nodes", self.nodes, node_id, "node"))
@ -294,20 +283,6 @@ class Project(BaseModel):
def element(self, element_id: PositiveInt) -> Element:
return cast(Element, self._lookup("elements", self.elements, element_id, "element"))
def combination(self, combination_id: PositiveInt) -> LoadCombination:
"""Return the load combination with ``combination_id``.
Args:
combination_id: Combination tag to look up.
Raises:
KeyError: if no combination carries that id.
"""
return cast(
LoadCombination,
self._lookup("combinations", self.combinations, combination_id, "combination"),
)
def _lookup(self, kind: str, items: list[Any], target_id: int, label: str) -> Any:
"""Return the entity with ``target_id`` from ``items`` in O(1) time.
@ -420,14 +395,5 @@ class Project(BaseModel):
f"uses invalid DOF {dof} for ndf={self.ndf}."
)
analysis_ids = {c.id for c in self.analyses}
for combo in self.combinations:
for item in combo.items:
if item.case_id not in analysis_ids:
problems.append(
f"Combination {combo.id} refers to missing analysis case "
f"{item.case_id}."
)
if problems:
raise ValueError("Reference validation failed:\n - " + "\n - ".join(problems))

View file

@ -16,7 +16,7 @@ exactly so the runner can emit them without further translation.
from __future__ import annotations
from typing import Annotated, Literal
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"),
]
@ -75,12 +75,8 @@ class ElasticSection(Entity):
A: PositiveFloat
Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.")
Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.")
G: PositiveFloat | None = Field(
default=None, description="Shear modulus; required for 3D frames."
)
J: PositiveFloat | None = Field(
default=None, description="Torsional constant; required for 3D frames."
)
G: PositiveFloat | None = Field(default=None, description="Shear modulus; required for 3D frames.")
J: PositiveFloat | None = Field(default=None, description="Torsional constant; required for 3D frames.")
shape: SectionShape | None = Field(
default=None,
description=(
@ -165,12 +161,12 @@ class StraightLayer(BaseModel):
Patch = Annotated[
RectangularPatch | CircularPatch,
Union[RectangularPatch, CircularPatch],
Field(discriminator="kind"),
]
Layer = Annotated[
StraightLayer,
Union[StraightLayer],
Field(discriminator="kind"),
]
@ -202,8 +198,7 @@ class AggregatorDOF(BaseModel):
material_id: PositiveInt
dof: Literal["P", "Mz", "My", "Vy", "Vz", "T"] = Field(
...,
description="Section DOF code (OpenSees section-deformation names).",
..., description="Section DOF code (OpenSees section-deformation names).",
)
@ -225,6 +220,6 @@ class SectionAggregator(Entity):
Section = Annotated[
ElasticSection | FiberSection | SectionAggregator,
Union[ElasticSection, FiberSection, SectionAggregator],
Field(discriminator="type"),
]

View file

@ -42,46 +42,30 @@ class UnitLabels:
label axes without hard-coding any particular set of units.
"""
length: str # "m", "mm", "in", "ft"
force: str # "N", "kip"
moment: str # "N·m", "kip·in"
stress: str # "Pa", "MPa", "ksi", "ksf"
curvature: str # "1/m", "1/in", …
rotation: str # "rad" (always, no unit variants in practice)
length: str # "m", "mm", "in", "ft"
force: str # "N", "kip"
moment: str # "N·m", "kip·in"
stress: str # "Pa", "MPa", "ksi", "ksf"
curvature: str # "1/m", "1/in", …
rotation: str # "rad" (always, no unit variants in practice)
_LABELS: dict[UnitSystem, UnitLabels] = {
UnitSystem.SI_M_N: UnitLabels(
length="m",
force="N",
moment="N·m",
stress="Pa",
curvature="1/m",
rotation="rad",
length="m", force="N", moment="N·m",
stress="Pa", curvature="1/m", rotation="rad",
),
UnitSystem.SI_MM_N: UnitLabels(
length="mm",
force="N",
moment="N·mm",
stress="MPa",
curvature="1/mm",
rotation="rad",
length="mm", force="N", moment="N·mm",
stress="MPa", curvature="1/mm", rotation="rad",
),
UnitSystem.US_FT_KIP: UnitLabels(
length="ft",
force="kip",
moment="kip·ft",
stress="ksf",
curvature="1/ft",
rotation="rad",
length="ft", force="kip", moment="kip·ft",
stress="ksf", curvature="1/ft", rotation="rad",
),
UnitSystem.US_IN_KIP: UnitLabels(
length="in",
force="kip",
moment="kip·in",
stress="ksi",
curvature="1/in",
rotation="rad",
length="in", force="kip", moment="kip·in",
stress="ksi", curvature="1/in", rotation="rad",
),
}

View file

@ -6,12 +6,6 @@ worker lives in ``qt_workers`` and is the only module here that
imports PySide6.
"""
from otko.services.combinations import (
EnvelopeResults,
combine_static_results,
envelope_static_results,
evaluate_combination,
)
from otko.services.export import export_opspy, export_tcl
from otko.services.material_tester import (
CyclicSegment,
@ -34,16 +28,12 @@ from otko.services.results import (
__all__ = [
"PROJECT_FILE_SUFFIX",
"CyclicSegment",
"EnvelopeResults",
"LoadProtocol",
"MaterialTestResult",
"ModalResults",
"OpenSeesRunner",
"StaticResults",
"TransientResults",
"combine_static_results",
"envelope_static_results",
"evaluate_combination",
"export_opspy",
"export_tcl",
"load_project",

View file

@ -409,10 +409,7 @@ class OpenSeesEmitter:
if el.consistent_mass:
ebc_args.append("-cMass")
ops.element(
"elasticBeamColumn",
el.id,
*el.nodes,
*ebc_args,
"elasticBeamColumn", el.id, *el.nodes, *ebc_args,
)
case ForceBeamColumn():
tag = self._element_geom_transf_tag[el.id]
@ -433,13 +430,9 @@ class OpenSeesEmitter:
ops.element("dispBeamColumn", el.id, *el.nodes, tag, el.id)
case ZeroLengthElement():
zl_args: list[Any] = [
"zeroLength",
el.id,
*el.nodes,
"-mat",
*el.material_ids,
"-dir",
*el.dofs,
"zeroLength", el.id, *el.nodes,
"-mat", *el.material_ids,
"-dir", *el.dofs,
]
# ``-doRayleigh 1`` only when requested (default off, matching
# OpenSees' zeroLength default) — so an isolator can opt its
@ -654,27 +647,15 @@ class OpenSeesEmitter:
)
vel = np.gradient(np.asarray(ts.values, dtype=float), ts.dt)
ops.timeSeries(
"Path",
vel_tag,
"-dt",
ts.dt,
"-values",
*vel.tolist(),
"-factor",
ts.factor,
"Path", vel_tag, "-dt", ts.dt, "-values", *vel.tolist(),
"-factor", ts.factor,
)
for nid in pat.node_ids:
ops.remove("sp", nid, pat.direction)
ops.pattern("MultipleSupport", pat.id)
ops.groundMotion(
pat.id,
"Plain",
"-disp",
pat.disp_series_id,
"-vel",
vel_tag,
"-fact",
pat.factor,
pat.id, "Plain", "-disp", pat.disp_series_id, "-vel", vel_tag,
"-fact", pat.factor,
)
for nid in pat.node_ids:
ops.imposedMotion(nid, pat.direction, pat.id)

View file

@ -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
@ -56,7 +55,8 @@ def export_mode_shape_video(
else:
# MP4 / WebM use FFmpeg backend. macro_block_size=1 disables
# the auto-resize-for-h264 pass (still works on modern players).
iio.imwrite(output_path, frames, fps=fps, codec="libx264", macro_block_size=1)
iio.imwrite(output_path, frames, fps=fps, codec="libx264",
macro_block_size=1)
def export_time_history_video(
@ -94,4 +94,5 @@ def export_time_history_video(
if suffix == ".gif":
iio.imwrite(output_path, frames, duration=1.0 / fps, loop=0)
else:
iio.imwrite(output_path, frames, fps=fps, codec="libx264", macro_block_size=1)
iio.imwrite(output_path, frames, fps=fps, codec="libx264",
macro_block_size=1)

View file

@ -1,235 +0,0 @@
"""Case-result load combinations — post-processing superposition.
A :class:`LoadCombination` names a weighted set of *completed* analysis
results (e.g. ``1.2×Dead + 1.6×Live``). Evaluation is pure NumPy over
:class:`StaticResults` — no solver re-run — and is only valid for
linear-elastic static superposition. Combining any other result kind
(transient, modal, pushover, response-spectrum) raises :class:`TypeError`.
Two combination kinds:
- ``Linear`` → :func:`combine_static_results`: weighted sum, returned as
:class:`StaticResults` (``case_id``/``case_name`` taken from the combo).
- ``Envelope`` → :func:`envelope_static_results`: per-DOF, per-step
maximum/minimum across the factored terms, returned as
:class:`EnvelopeResults`.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from otko.core.loads import LoadCombination
from otko.services.results import StaticResults
@dataclass
class EnvelopeResults:
"""Per-DOF, per-step max/min across a combination's factored terms."""
case_id: int
case_name: str
n_steps: int
node_disp_max: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → array of shape (n_steps, ndf), per-DOF maxima."""
node_disp_min: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → array of shape (n_steps, ndf), per-DOF minima."""
node_reaction_max: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → array of shape (n_steps, ndf), per-DOF maxima."""
node_reaction_min: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → array of shape (n_steps, ndf), per-DOF minima."""
element_forces_max: dict[int, np.ndarray] = field(default_factory=dict)
"""element_id → array of shape (n_steps, n_components), maxima."""
element_forces_min: dict[int, np.ndarray] = field(default_factory=dict)
"""element_id → array of shape (n_steps, n_components), minima."""
def _check_compatible(results: Sequence[StaticResults]) -> int:
"""Validate equal steps, nodes, elements and shapes; return ``n_steps``.
Raises:
ValueError: on any incompatibility, with the offending detail.
"""
if not results:
raise ValueError("Need at least one result to combine.")
first = results[0]
n_steps = first.n_steps
node_ids = set(first.node_disp)
elem_ids = set(first.element_forces)
reaction_ids = set(first.node_reaction)
for res in results[1:]:
if res.n_steps != n_steps:
raise ValueError(
f"Step-count mismatch: case {first.case_id} has {n_steps} steps, "
f"case {res.case_id} has {res.n_steps}."
)
if set(res.node_disp) != node_ids:
raise ValueError(f"Node set mismatch: case {first.case_id} vs case {res.case_id}.")
if set(res.node_reaction) != reaction_ids:
raise ValueError(f"Reaction set mismatch: case {first.case_id} vs case {res.case_id}.")
if set(res.element_forces) != elem_ids:
raise ValueError(f"Element set mismatch: case {first.case_id} vs case {res.case_id}.")
for nid in node_ids:
if res.node_disp[nid].shape != first.node_disp[nid].shape:
raise ValueError(
f"Displacement shape mismatch at node {nid}: "
f"case {first.case_id} vs case {res.case_id}."
)
if res.node_reaction[nid].shape != first.node_reaction[nid].shape:
raise ValueError(
f"Reaction shape mismatch at node {nid}: "
f"case {first.case_id} vs case {res.case_id}."
)
for eid in elem_ids:
if res.element_forces[eid].shape != first.element_forces[eid].shape:
raise ValueError(
f"Element-force shape mismatch at element {eid}: "
f"case {first.case_id} vs case {res.case_id}."
)
return n_steps
def combine_static_results(
results: Sequence[StaticResults],
factors: Sequence[float],
*,
case_id: int,
case_name: str,
) -> StaticResults:
"""Return the weighted sum ``Σ factor_i × results_i``.
Args:
results: Source static results, one per combination term.
factors: Scale per term (same order as ``results``).
case_id: Tag for the returned results (the combination's id).
case_name: Label for the returned results (the combination's name).
Raises:
ValueError: on length mismatch, non-finite factor, or
incompatible result shapes.
"""
if len(results) != len(factors):
raise ValueError(f"Results/factors length mismatch: {len(results)} vs {len(factors)}.")
for factor in factors:
if not np.isfinite(factor):
raise ValueError(f"Combination factor must be finite, got {factor!r}.")
n_steps = _check_compatible(results)
node_disp: dict[int, np.ndarray] = {}
node_reaction: dict[int, np.ndarray] = {}
for nid in results[0].node_disp:
acc_d = np.zeros_like(results[0].node_disp[nid], dtype=float)
acc_r = np.zeros_like(results[0].node_reaction[nid], dtype=float)
for r, f in zip(results, factors, strict=False):
acc_d = acc_d + float(f) * r.node_disp[nid]
acc_r = acc_r + float(f) * r.node_reaction[nid]
node_disp[nid] = acc_d
node_reaction[nid] = acc_r
element_forces: dict[int, np.ndarray] = {}
for eid in results[0].element_forces:
acc_e = np.zeros_like(results[0].element_forces[eid], dtype=float)
for r, f in zip(results, factors, strict=False):
acc_e = acc_e + float(f) * r.element_forces[eid]
element_forces[eid] = acc_e
return StaticResults(
case_id=case_id,
case_name=case_name,
n_steps=n_steps,
node_disp=node_disp,
node_reaction=node_reaction,
element_forces=element_forces,
)
def envelope_static_results(
results: Sequence[StaticResults],
factors: Sequence[float],
*,
case_id: int,
case_name: str,
) -> EnvelopeResults:
"""Return per-DOF, per-step max/min across the factored terms.
Each term is ``factor_i × results_i``; the envelope takes the
element-wise maximum (and minimum) across terms at every step.
"""
if len(results) != len(factors):
raise ValueError(f"Results/factors length mismatch: {len(results)} vs {len(factors)}.")
for factor in factors:
if not np.isfinite(factor):
raise ValueError(f"Combination factor must be finite, got {factor!r}.")
n_steps = _check_compatible(results)
def _stack(getter: Any) -> dict[int, np.ndarray]:
keys = list(getter(results[0]).keys())
return {
k: np.stack([float(f) * getter(r)[k] for r, f in zip(results, factors, strict=False)])
for k in keys
}
disp = _stack(lambda r: r.node_disp)
reac = _stack(lambda r: r.node_reaction)
elem = _stack(lambda r: r.element_forces)
return EnvelopeResults(
case_id=case_id,
case_name=case_name,
n_steps=n_steps,
node_disp_max={k: v.max(axis=0) for k, v in disp.items()},
node_disp_min={k: v.min(axis=0) for k, v in disp.items()},
node_reaction_max={k: v.max(axis=0) for k, v in reac.items()},
node_reaction_min={k: v.min(axis=0) for k, v in reac.items()},
element_forces_max={k: v.max(axis=0) for k, v in elem.items()},
element_forces_min={k: v.min(axis=0) for k, v in elem.items()},
)
def evaluate_combination(
results_by_case_id: Mapping[int, Any],
combination: LoadCombination,
) -> StaticResults | EnvelopeResults:
"""Evaluate ``combination`` against already-computed results.
Args:
results_by_case_id: ``{analysis_case_id: results}`` — every
referenced case must be present as :class:`StaticResults`.
combination: The named combination to evaluate.
Returns:
:class:`StaticResults` for ``Linear`` combos,
:class:`EnvelopeResults` for ``Envelope`` combos.
Raises:
KeyError: if a referenced case has no entry in the map.
TypeError: if any referenced result is not :class:`StaticResults`
(superposition is only meaningful for static results).
ValueError: propagated from the combine/envelope helpers on
shape incompatibility.
"""
ordered: list[StaticResults] = []
factors: list[float] = []
for item in combination.items:
if item.case_id not in results_by_case_id:
raise KeyError(
f"Combination {combination.id}: no results for case {item.case_id} — "
"run that case first."
)
res = results_by_case_id[item.case_id]
if not isinstance(res, StaticResults):
raise TypeError(
f"Combination {combination.id}: case {item.case_id} produced "
f"{type(res).__name__}, not StaticResults — case-result "
"combinations only support (linear) static cases."
)
ordered.append(res)
factors.append(item.factor)
if combination.kind == "Envelope":
return envelope_static_results(
ordered, factors, case_id=combination.id, case_name=combination.name
)
return combine_static_results(
ordered, factors, case_id=combination.id, case_name=combination.name
)

View file

@ -24,7 +24,7 @@ class DeformationSource:
displacements to PyVista point arrays.
"""
displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
node_id_to_row: dict[int, int]
scale: float = 1.0
@ -38,11 +38,8 @@ class DeformationSource:
def static_to_deformation(
project: Project,
results: StaticResults,
*,
step: int = -1,
scale: float = 1.0,
project: Project, results: StaticResults, *,
step: int = -1, scale: float = 1.0,
) -> DeformationSource:
"""Build a DeformationSource from a static analysis's nodal displacements.
@ -60,16 +57,13 @@ def static_to_deformation(
# Take only translation DOFs (first 2 in 2D, first 3 in 3D).
n_take = min(3, snapshot.shape[0])
disp[node_id_to_row[nid], :n_take] = snapshot[:n_take]
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
return DeformationSource(displacements=disp,
node_id_to_row=node_id_to_row, scale=scale)
def modal_to_deformation(
project: Project,
results: ModalResults,
*,
mode: int = 0,
scale: float = 1.0,
phase: float = 1.0,
project: Project, results: ModalResults, *,
mode: int = 0, scale: float = 1.0, phase: float = 1.0,
) -> DeformationSource:
"""Build a DeformationSource from a modal analysis's mode shape.
@ -84,9 +78,10 @@ def modal_to_deformation(
mode_number = mode + 1 # mode_shapes is 1-indexed
if mode_number not in results.mode_shapes:
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
return DeformationSource(displacements=disp,
node_id_to_row=node_id_to_row, scale=scale)
eigvec = results.mode_shapes[mode_number] # dict: nid → np.ndarray of DOF values
eigvec = results.mode_shapes[mode_number] # dict: nid → np.ndarray of DOF values
for nid, vec in eigvec.items():
if nid not in node_id_to_row:
continue
@ -106,15 +101,12 @@ def modal_to_deformation(
norm_factor = (bbox * 0.05) / max_amp
disp *= norm_factor
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
return DeformationSource(displacements=disp,
node_id_to_row=node_id_to_row, scale=scale)
def transient_to_deformation_at_step(
project: Project,
results,
*,
step: int = 0,
scale: float = 1.0,
project: Project, results, *, step: int = 0, scale: float = 1.0,
) -> DeformationSource:
"""Build a DeformationSource from a transient analysis at one step.
@ -147,7 +139,8 @@ def transient_to_deformation_at_step(
norm_factor = (bbox * 0.10) / max_amp
disp *= norm_factor
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
return DeformationSource(displacements=disp,
node_id_to_row=node_id_to_row, scale=scale)
def linear_static_auto_scale(project: Project, results: StaticResults) -> float:

View file

@ -41,36 +41,27 @@ from otko.services.results import StaticResults
class ForceComponent(Enum):
"""Which force component to plot."""
N = "N" # axial
V2 = "V2" # shear in local y (in-plane shear for 2D)
V3 = "V3" # shear in local z
T = "T" # torsion
M2 = "M2" # moment about local y
M3 = "M3" # moment about local z (in-plane moment for 2D)
N = "N" # axial
V2 = "V2" # shear in local y (in-plane shear for 2D)
V3 = "V3" # shear in local z
T = "T" # torsion
M2 = "M2" # moment about local y
M3 = "M3" # moment about local z (in-plane moment for 2D)
# Map (component, end) → index into the local-force vector for 3D and 2D.
_INDEX_3D = {
(ForceComponent.N, "i"): 0,
(ForceComponent.N, "j"): 6,
(ForceComponent.V2, "i"): 1,
(ForceComponent.V2, "j"): 7,
(ForceComponent.V3, "i"): 2,
(ForceComponent.V3, "j"): 8,
(ForceComponent.T, "i"): 3,
(ForceComponent.T, "j"): 9,
(ForceComponent.M2, "i"): 4,
(ForceComponent.M2, "j"): 10,
(ForceComponent.M3, "i"): 5,
(ForceComponent.M3, "j"): 11,
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 6,
(ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 7,
(ForceComponent.V3, "i"): 2, (ForceComponent.V3, "j"): 8,
(ForceComponent.T, "i"): 3, (ForceComponent.T, "j"): 9,
(ForceComponent.M2, "i"): 4, (ForceComponent.M2, "j"): 10,
(ForceComponent.M3, "i"): 5, (ForceComponent.M3, "j"): 11,
}
_INDEX_2D = {
(ForceComponent.N, "i"): 0,
(ForceComponent.N, "j"): 3,
(ForceComponent.V2, "i"): 1,
(ForceComponent.V2, "j"): 4,
(ForceComponent.M3, "i"): 2,
(ForceComponent.M3, "j"): 5,
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
(ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 4,
(ForceComponent.M3, "i"): 2, (ForceComponent.M3, "j"): 5,
}
# Truss elements expose a different localForce layout than frames:
@ -78,12 +69,10 @@ _INDEX_2D = {
# 3D truss → 6-vector [N_i, 0, 0, N_j, 0, 0]
# So we map only the N component; other components return None.
_INDEX_TRUSS_2D = {
(ForceComponent.N, "i"): 0,
(ForceComponent.N, "j"): 2,
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 2,
}
_INDEX_TRUSS_3D = {
(ForceComponent.N, "i"): 0,
(ForceComponent.N, "j"): 3,
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
}
@ -133,7 +122,6 @@ def extract_diagram_data(
# Local import to avoid a cycle (element classes live in core.geometry).
from otko.core import CorotTrussElement, TrussElement
truss_types = (TrussElement, CorotTrussElement)
for el in project.elements:

View file

@ -35,7 +35,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from otko._const import OPENSEESPY_VERSION, __version__
from otko import __version__
from otko.core import (
ModalCase,
Project,
@ -49,7 +49,7 @@ from otko.services.opensees_runner import OpenSeesRunner
__all__ = ["RecordingOps", "export_opspy", "export_tcl"]
#: Pinned solver version written into every script header.
PINNED_OPENSEESPY = f"openseespy=={OPENSEESPY_VERSION}"
PINNED_OPENSEESPY = "openseespy==3.8.0.0"
#: Recorded call triple: (ops method name, positional args, keyword args).
RecordedCall = tuple[str, tuple[Any, ...], dict[str, Any]]
@ -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)
@ -362,7 +362,7 @@ def export_opspy(project: Project, case_id: int | None = None) -> str:
are appended after the model.
Returns:
The script source. The header pins the solver version,
The script source. The header pins ``openseespy==3.8.0.0``,
the OTKO version and the display units.
Raises:

View file

@ -63,9 +63,7 @@ class CyclicSegment(BaseModel):
current -> *compressive_peak* -> *tensile_peak* -> 0.
"""
compressive_peak: float = Field(
..., lt=0.0, description="Target compressive strain (negative)."
)
compressive_peak: float = Field(..., lt=0.0, description="Target compressive strain (negative).")
tensile_peak: float = Field(..., gt=0.0, description="Target tensile strain (positive).")
n_cycles: PositiveInt = Field(1, description="Number of complete excursions to run.")
@ -162,35 +160,17 @@ def _emit_uniaxial(ops: Any, mat: Any) -> None:
ops.uniaxialMaterial("Steel01", mat.id, *args)
case Steel02():
ops.uniaxialMaterial(
"Steel02",
mat.id,
mat.Fy,
mat.E0,
mat.b,
mat.R0,
mat.cR1,
mat.cR2,
"Steel02", mat.id, mat.Fy, mat.E0, mat.b, mat.R0, mat.cR1, mat.cR2,
)
case Concrete01():
ops.uniaxialMaterial(
"Concrete01",
mat.id,
mat.fpc,
mat.epsc0,
mat.fpcu,
mat.epsU,
"Concrete01", mat.id, mat.fpc, mat.epsc0, mat.fpcu, mat.epsU,
)
case Concrete02():
ops.uniaxialMaterial(
"Concrete02",
mat.id,
mat.fpc,
mat.epsc0,
mat.fpcu,
mat.epsU,
mat.lambda_,
mat.ft,
mat.Ets,
"Concrete02", mat.id,
mat.fpc, mat.epsc0, mat.fpcu, mat.epsU,
mat.lambda_, mat.ft, mat.Ets,
)
case Concrete04():
args = [mat.fpc, mat.epsc0, mat.epscu, mat.Ec]
@ -207,25 +187,10 @@ def _emit_uniaxial(ops: Any, mat: Any) -> None:
ops.uniaxialMaterial("ElasticPP", mat.id, *args)
case HystereticMaterial():
ops.uniaxialMaterial(
"Hysteretic",
mat.id,
mat.s1p,
mat.e1p,
mat.s2p,
mat.e2p,
mat.s3p,
mat.e3p,
mat.s1n,
mat.e1n,
mat.s2n,
mat.e2n,
mat.s3n,
mat.e3n,
mat.px,
mat.py,
mat.d1,
mat.d2,
mat.beta,
"Hysteretic", mat.id,
mat.s1p, mat.e1p, mat.s2p, mat.e2p, mat.s3p, mat.e3p,
mat.s1n, mat.e1n, mat.s2n, mat.e2n, mat.s3n, mat.e3n,
mat.px, mat.py, mat.d1, mat.d2, mat.beta,
)
case _:
raise TypeError(f"Unsupported material type: {type(mat).__name__}")
@ -274,13 +239,10 @@ def test_uniaxial_material(
"""
if ops_module is None:
import openseespy.opensees as _ops_default
ops_module = _ops_default
ops = ops_module
mat_name = getattr(material, "name", None) or str(
getattr(material, "type", type(material).__name__)
)
mat_name = getattr(material, "name", None) or str(getattr(material, "type", type(material).__name__))
try:
# Clear any leftover OpenSees state from a prior call or runner.

View file

@ -11,7 +11,6 @@ building the PathTimeSeries.
from __future__ import annotations
import contextlib
import re
from pathlib import Path
@ -47,8 +46,7 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
break
# New NGA format: "3930 0.00500 NPTS, DT"
m_new = re.match(
r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT",
stripped,
r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT", stripped,
)
if m_new:
npts = int(m_new.group(1))
@ -65,9 +63,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 +82,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

View file

@ -32,8 +32,8 @@ class AnalysisWorker(QObject):
started = Signal()
log = Signal(str)
finished = Signal(object) # emits StaticResults / ModalResults / TransientResults
failed = Signal(str) # human-readable error message + traceback
finished = Signal(object) # emits StaticResults / ModalResults / TransientResults
failed = Signal(str) # human-readable error message + traceback
def __init__(
self,
@ -52,10 +52,8 @@ class AnalysisWorker(QObject):
"""Slot to be invoked by ``QThread.started``."""
self.started.emit()
try:
self.log.emit(
f"Building model: {len(self._project.nodes)} nodes, "
f"{len(self._project.elements)} elements."
)
self.log.emit(f"Building model: {len(self._project.nodes)} nodes, "
f"{len(self._project.elements)} elements.")
runner = OpenSeesRunner(self._project)
self.log.emit(f"Running case '{self._case.name}' ({type(self._case).__name__}) ...")
results = runner.run(self._case, results_dir=self._results_dir)

View file

@ -56,8 +56,8 @@ class PushoverResults:
n_steps: int
control_node: int
control_dof: int
control_disp: np.ndarray # shape (n_steps + 1,), includes t=0
base_shear: np.ndarray # shape (n_steps + 1,), signed
control_disp: np.ndarray # shape (n_steps + 1,), includes t=0
base_shear: np.ndarray # shape (n_steps + 1,), signed
node_disp: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → shape (n_steps + 1, ndf) displacement history."""
element_forces: dict[int, np.ndarray] = field(default_factory=dict)
@ -158,7 +158,7 @@ class ResponseSpectrumResults:
case_id: int
case_name: str
direction: int
combination: str # "SRSS" or "CQC"
combination: str # "SRSS" or "CQC"
combined_disp: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → 3-vector of peak combined translational displacements."""
modes: list = field(default_factory=list)

View file

@ -52,10 +52,8 @@ def _angle_in_arc(angle: float, start: float, span: float) -> bool:
return ((angle - start) % 360.0) <= span + 1e-9
def bbox_for_section(
section: object,
project: Project | None = None,
) -> tuple[float, float] | None:
def bbox_for_section(section: object, project: "Project | None" = None,
) -> tuple[float, float] | None:
"""Return ``(width_y, height_z)`` of the section's local bounding box.
``None`` means "size could not be inferred" — the caller should skip
@ -71,7 +69,6 @@ def bbox_for_section(
SectionAggregator,
StraightLayer,
)
# ── ElasticSection: prefer explicit display dims; else back-solve.
if isinstance(section, ElasticSection):
explicit = _elastic_explicit_dims(section)

View file

@ -21,6 +21,7 @@ import numpy as np
from otko.core.sections import (
CircularPatch,
FiberSection,
Fibre,
RectangularPatch,
StraightLayer,
)
@ -34,9 +35,9 @@ class SectionProps:
area: float
centroid_y: float
centroid_z: float
Iy: float # about centroid
Iz: float # about centroid
fibre_yz: np.ndarray # (n, 3): y, z, area
Iy: float # about centroid
Iz: float # about centroid
fibre_yz: np.ndarray # (n, 3): y, z, area
def expand_fibres(sec: FiberSection) -> np.ndarray:
@ -64,11 +65,9 @@ def expand_fibres(sec: FiberSection) -> np.ndarray:
yc = p.y_center + r_mid * math.cos(theta)
zc = p.z_center + r_mid * math.sin(theta)
# Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad)
a = (
((p.r_inner + (ir + 1) * d_r) ** 2 - (p.r_inner + ir * d_r) ** 2)
* math.radians(d_theta)
/ 2.0
)
a = ((p.r_inner + (ir + 1) * d_r) ** 2
- (p.r_inner + ir * d_r) ** 2) \
* math.radians(d_theta) / 2.0
rows.append((yc, zc, a))
for lay in sec.layers:
@ -103,8 +102,8 @@ def compute_section_props(sec: FiberSection) -> SectionProps:
zc = float(np.sum(a * z) / total_a)
# Second moments of area about centroid (parallel axis from each fibre).
Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
return SectionProps(
n_fibres=len(fibres),

View file

@ -24,22 +24,20 @@ from otko.services.results import ModalResults
class ModeContribution:
"""Per-mode metadata for a response-spectrum analysis."""
mode_number: int # 1-indexed
period: float # s
frequency: float # Hz
angular_frequency: float # rad/s
participation_factor: float # Γ_i for the chosen direction
effective_mass: float # M_eff,i = Γ_i² · M_i
mass_ratio: float # M_eff,i / Σ m
sa_at_period: float # Sa(T_i) from spectrum
mode_number: int # 1-indexed
period: float # s
frequency: float # Hz
angular_frequency: float # rad/s
participation_factor: float # Γ_i for the chosen direction
effective_mass: float # M_eff,i = Γ_i² · M_i
mass_ratio: float # M_eff,i / Σ m
sa_at_period: float # Sa(T_i) from spectrum
modal_peak_disp: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → peak modal displacement vector (3D translations)."""
def mass_participation(
project: Project,
modal: ModalResults,
direction: int,
project: Project, modal: ModalResults, direction: int,
) -> list[ModeContribution]:
"""Compute Γ_i, M_eff,i and frequency for every mode.
@ -81,23 +79,21 @@ def mass_participation(
m_eff = 0.0
else:
gamma = numerator / denominator
m_eff = gamma**2 * denominator
m_eff = gamma ** 2 * denominator
omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1])))
period = (2.0 * np.pi / omega) if omega > 0.0 else float("inf")
ratio = (m_eff / total_mass) if total_mass > 0.0 else 0.0
out.append(
ModeContribution(
mode_number=mode_number,
period=period,
frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
angular_frequency=omega,
participation_factor=gamma,
effective_mass=m_eff,
mass_ratio=ratio,
sa_at_period=0.0, # filled in by combine_spectrum
)
)
out.append(ModeContribution(
mode_number=mode_number,
period=period,
frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
angular_frequency=omega,
participation_factor=gamma,
effective_mass=m_eff,
mass_ratio=ratio,
sa_at_period=0.0, # filled in by combine_spectrum
))
return out
@ -141,7 +137,8 @@ def combine_modal_response(
m.sa_at_period = 0.0
continue
m.sa_at_period = interp_sa(spectrum, m.period)
scale = m.participation_factor * m.sa_at_period / (m.angular_frequency**2)
scale = (m.participation_factor * m.sa_at_period
/ (m.angular_frequency ** 2))
shape = modal.mode_shapes[m.mode_number]
for nid, vec in shape.items():
n_take = min(3, vec.size)
@ -159,7 +156,7 @@ def combine_modal_response(
for m in modes:
u = m.modal_peak_disp.get(nid)
if u is not None:
sq_sum += u**2
sq_sum += u ** 2
combined[nid] = np.sqrt(sq_sum)
elif method.upper() == "CQC":
zeta = damping if damping is not None else spectrum.damping_ratio
@ -172,8 +169,8 @@ def combine_modal_response(
if wi <= 0.0 or wj <= 0.0:
continue
r = wj / wi
num = 8.0 * zeta**2 * (1.0 + r) * r**1.5
denom = (1.0 - r**2) ** 2 + 4.0 * zeta**2 * r * (1.0 + r) ** 2
num = 8.0 * zeta ** 2 * (1.0 + r) * r ** 1.5
denom = (1.0 - r ** 2) ** 2 + 4.0 * zeta ** 2 * r * (1.0 + r) ** 2
rho[i, j] = num / denom if denom > 0.0 else 0.0
for nid in node_ids:
sq_sum = np.zeros(3)

View file

@ -20,7 +20,7 @@ class AnalysisRunner(QObject):
started = Signal()
log = Signal(str)
finished = Signal(object) # StaticResults / ModalResults / TransientResults
finished = Signal(object) # StaticResults / ModalResults / TransientResults
failed = Signal(str)
runningChanged = Signal(bool)

View file

@ -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
@ -24,8 +23,8 @@ from otko.services import load_project, save_project
class ProjectViewModel(QObject):
"""Holds the current Project, its file path, dirty state, and undo stack."""
projectChanged = Signal(object) # emits Project | None
modelMutated = Signal() # same project, mutated by a command
projectChanged = Signal(object) # emits Project | None
modelMutated = Signal() # same project, mutated by a command
dirtyChanged = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
@ -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

View file

@ -49,7 +49,6 @@ from otko.views.dialogs import (
AssignZeroLengthSectionDialog,
CoordinateGridSystemsDialog,
LinearTimeSeriesDialog,
LoadCombinationManagerDialog,
MaterialLibraryDialog,
MaterialTesterDialog,
MirrorDialog,
@ -918,11 +917,6 @@ class ActionHandlers:
self._on_new()
AnalysisCaseManagerDialog(self._vm, self).exec()
def _on_combo_manager(self) -> None:
if self._vm.project is None:
self._on_new()
LoadCombinationManagerDialog(self._vm, self).exec()
def _on_run_analysis(self) -> None:
if self._vm.project is None:
QMessageBox.information(self, "Run Analysis", "Open or create a project first.")
@ -967,7 +961,10 @@ class ActionHandlers:
def _on_about(self) -> None:
box = QMessageBox(self)
box.setWindowTitle("About OTKO")
box.setText(f"<h3>OTKO {__version__}</h3>" "<p>A modern desktop GUI for OpenSeesPy.</p>")
box.setText(
f"<h3>OTKO {__version__}</h3>"
"<p>A modern desktop GUI for OpenSeesPy.</p>"
)
app_svg = Path(__file__).resolve().parent.parent / "resources" / "icons" / "app.svg"
if app_svg.is_file():
box.setIconPixmap(QIcon(str(app_svg)).pixmap(64, 64))

View file

@ -22,7 +22,6 @@ Convention:
from __future__ import annotations
import contextlib
import logging
from typing import Any
@ -35,21 +34,14 @@ from otko.services.element_forces import DiagramData, ForceComponent
_LOG = logging.getLogger("otko.diagram")
# Components that draw perpendicular to the element axis vs along it.
_PERPENDICULAR = {
ForceComponent.V2,
ForceComponent.V3,
ForceComponent.M2,
ForceComponent.M3,
ForceComponent.T,
}
_PERPENDICULAR = {ForceComponent.V2, ForceComponent.V3,
ForceComponent.M2, ForceComponent.M3, ForceComponent.T}
# Which local axis the value is plotted along (2 = local y, 3 = local z).
_LOCAL_AXIS = {
ForceComponent.V2: 2,
ForceComponent.M3: 2,
ForceComponent.V3: 3,
ForceComponent.M2: 3,
ForceComponent.T: 2,
ForceComponent.V2: 2, ForceComponent.M3: 2,
ForceComponent.V3: 3, ForceComponent.M2: 3,
ForceComponent.T: 2,
}
@ -77,10 +69,8 @@ class DiagramRenderer:
# All values are zero → no diagram to draw. This is normal —
# e.g. asking for "torsion" on a planar bending model. Log
# a hint so the user understands the empty viewport.
_LOG.info(
"All '%s' values are zero for this analysis step " "— nothing to draw.",
data.component.name,
)
_LOG.info("All '%s' values are zero for this analysis step "
"— nothing to draw.", data.component.name)
return
node_pos = {n.id: np.asarray(n.coords, dtype=float) for n in project.nodes}
@ -89,8 +79,8 @@ class DiagramRenderer:
is_perpendicular = data.component in _PERPENDICULAR
axis_id = _LOCAL_AXIS.get(data.component, 2)
polys: list[np.ndarray] = [] # vertex arrays for each polygon
scalars: list[float] = [] # one value per polygon (avg of end values)
polys: list[np.ndarray] = [] # vertex arrays for each polygon
scalars: list[float] = [] # one value per polygon (avg of end values)
for k, eid in enumerate(data.element_ids):
elem = elem_lookup.get(int(eid))
if elem is None:
@ -116,19 +106,15 @@ class DiagramRenderer:
perp = self._local_perp(pi, pj, 2)
if perp is None:
continue
width = abs(v_i) * 0.5 # half-width fall-off
width = abs(v_i) * 0.5 # half-width fall-off
if width == 0.0:
width = abs(v_j) * 0.5
if width == 0.0:
continue
quad = np.vstack(
[
pi - perp * width,
pj - perp * width,
pj + perp * width,
pi + perp * width,
]
)
quad = np.vstack([
pi - perp * width, pj - perp * width,
pj + perp * width, pi + perp * width,
])
polys.append(quad)
scalars.append(0.5 * (data.values_i[k] + data.values_j[k]))
@ -152,9 +138,7 @@ class DiagramRenderer:
# Symmetric color range so zero stays at the colormap mid-point.
vmax = float(np.max(np.abs(scalars))) or 1.0
# Defensive: accept either ForceComponent enum or its name string.
comp_label = (
data.component.value if hasattr(data.component, "value") else str(data.component)
)
comp_label = data.component.value if hasattr(data.component, "value") else str(data.component)
self._actor = self._plotter.add_mesh(
mesh,
scalars="value",
@ -173,7 +157,8 @@ class DiagramRenderer:
# ── Numerical labels at the global min and max element ends. ──
self._label_actor = self._add_value_labels(project, data, scale)
def _add_value_labels(self, project: Project, data: DiagramData, scale: float) -> Any:
def _add_value_labels(self, project: Project, data: DiagramData,
scale: float) -> Any:
"""Annotate the diagram's extreme ends with their numerical values.
Avoids visual clutter by labelling only the two ends carrying the
@ -218,10 +203,9 @@ class DiagramRenderer:
try:
return self._plotter.add_point_labels(
positions,
labels,
positions, labels,
font_size=14,
point_size=0, # don't draw the underlying points
point_size=0, # don't draw the underlying points
shape=None,
always_visible=True,
pickable=False,
@ -255,8 +239,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 ─────────────────────────────────────────────────────

View file

@ -12,6 +12,7 @@ needing a reference to the renderer.
from __future__ import annotations
import os
from typing import Any
import numpy as np
@ -25,7 +26,7 @@ from otko.views.canvas3d.model_renderer import ModelRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
PICK_DEBUG = False
_PICK_DEBUG = os.environ.get("OSS_PICK_DEBUG") == "1"
class ModelCanvas(QtInteractor): # type: ignore[misc]
@ -126,7 +127,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
cx = qt_x * dpr
cy = (h_logical - qt_y) * dpr
if PICK_DEBUG:
if _PICK_DEBUG:
print(
f"[pick] click qt=({qt_x:.0f},{qt_y:.0f}) → vtk=({cx:.0f},{cy:.0f}) "
f"viewport_logical=({self.width()}x{h_logical}) dpr={dpr}"
@ -147,7 +148,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
if node_screen is not None and len(node_screen):
d2 = (node_screen[:, 0] - cx) ** 2 + (node_screen[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
if PICK_DEBUG:
if _PICK_DEBUG:
print(
f"[pick] nearest node id={node_ids[idx]} "
f"screen={node_screen[idx]} d={np.sqrt(d2[idx]):.1f}px "
@ -180,7 +181,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
closest = a_screen + t[:, None] * ab
d2 = ((p - closest) ** 2).sum(axis=1)
idx = int(np.argmin(d2))
if PICK_DEBUG:
if _PICK_DEBUG:
print(
f"[pick] nearest frame id={frame_ids[idx]} "
f"d={float(np.sqrt(d2[idx])):.1f}px "
@ -190,7 +191,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._dispatch_pick("element", int(frame_ids[idx]))
return
if PICK_DEBUG:
if _PICK_DEBUG:
print("[pick] no hit within tolerance")
# ── Empty-click fallback: pixel-space snap to grid intersections. ──
@ -201,7 +202,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
snapped = self._nearest_grid_intersection_px(cx, cy, grid_tol_px)
if snapped is not None:
self.emptyClicked.emit(float(snapped[0]), float(snapped[1]), float(snapped[2]))
elif PICK_DEBUG:
elif _PICK_DEBUG:
print("[pick] off-grid click — no snap target within tolerance")
def _grid_intersections_world(self) -> np.ndarray | None:
@ -259,7 +260,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
return None
d2 = (screen[:, 0] - cx) ** 2 + (screen[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
if PICK_DEBUG:
if _PICK_DEBUG:
print(
f"[grid-snap] nearest intersection "
f"world={world_points[idx]} d={float(np.sqrt(d2[idx])):.1f}px "

View file

@ -10,10 +10,9 @@ 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, ClassVar
from typing import Any
import numpy as np
import pyvista as pv
@ -25,11 +24,11 @@ from otko.core import (
ElasticBeamColumn,
ForceBeamColumn,
NodalLoad,
QuadElement,
UniformElementLoad,
PlainLoadPattern,
Project,
QuadElement,
TrussElement,
UniformElementLoad,
ZeroLengthElement,
ZeroLengthSectionElement,
)
@ -185,8 +184,8 @@ class ModelRenderer:
r, g, b = (int(round(x * 255)) for x in rgb)
return f"#{r:02x}{g:02x}{b:02x}"
_NODE_LUT: ClassVar[list[str]] = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected
_FRAME_LUT: ClassVar[list[str]] = ["#338cd9", "#00ffff"] # blue normal, cyan selected
_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
@ -220,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:
@ -328,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:
@ -373,8 +376,10 @@ 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,
@ -479,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).
@ -498,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]
@ -624,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])
@ -675,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])
@ -1102,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()
@ -1124,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

View file

@ -19,15 +19,15 @@ class RenderStyle:
background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb"
node_color: str = "#f0a500" # warm gold
node_selected_color: str = "#00d4ff" # bright cyan
node_color: str = "#f0a500" # warm gold
node_selected_color: str = "#00d4ff" # bright cyan
frame_color: str = "#1f1f1f"
truss_color: str = "#2e5cb8"
zerolength_color: str = "#a020f0"
selected_color: str = "#00d4ff"
fix_color: str = "#c0392b" # firebrick
fix_color: str = "#c0392b" # firebrick
pin_color: str = "#c0392b"
roller_color: str = "#e67e22"
custom_support_color: str = "#7f8c8d"
@ -45,7 +45,7 @@ class RenderStyle:
support_relative_size: float = 0.025
support_min_size: float = 0.10
load_relative_length: float = 0.10 # arrow length / bbox_diag
load_relative_length: float = 0.10 # arrow length / bbox_diag
load_min_length: float = 0.5
selection_thickness_factor: float = 1.6 # multiplier for selected actors

View file

@ -12,7 +12,6 @@ from otko.views.dialogs.assign_property import (
from otko.views.dialogs.assign_support import PRESETS, AssignSupportDialog
from otko.views.dialogs.assign_zls import AssignZeroLengthSectionDialog
from otko.views.dialogs.case_manager import AnalysisCaseManagerDialog
from otko.views.dialogs.combination_dialog import LoadCombinationManagerDialog
from otko.views.dialogs.coord_grid_systems import (
CoordinateGridSystemsDialog,
CoordSystemDataDialog,
@ -61,7 +60,6 @@ __all__ = [
"DisplayOptionsDialog",
"GridSystemDialog",
"LinearTimeSeriesDialog",
"LoadCombinationManagerDialog",
"MaterialLibraryDialog",
"MaterialTesterDialog",
"MirrorDialog",

View file

@ -40,15 +40,12 @@ class AddNodeDialog(QDialog):
the node lands on the active plane by default.
"""
def __init__(
self,
next_node_id: int,
grid: GridSystem,
ndm: int = 3,
parent: QWidget | None = None,
*,
plane_elevation: float | None = None,
) -> None:
def __init__(self, next_node_id: int,
grid: GridSystem,
ndm: int = 3,
parent: QWidget | None = None,
*,
plane_elevation: float | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Add Node")
self._next_id = next_node_id
@ -74,18 +71,16 @@ class AddNodeDialog(QDialog):
else:
self._z.setVisible(False)
if self._plane_elevation is not None and self._ndm == 3:
form.addRow(
QLabel(
f"<i>Suggested Z = active working-plane level "
f"({self._plane_elevation:g}).</i>",
)
)
form.addRow(QLabel(
f"<i>Suggested Z = active working-plane level "
f"({self._plane_elevation:g}).</i>",
))
root.addLayout(form)
self._snap_cb = QCheckBox("Snap to nearest grid intersection")
self._snap_cb.setChecked(
bool(self._grid.x_lines or self._grid.y_lines or self._grid.z_lines)
)
self._snap_cb.setChecked(bool(
self._grid.x_lines or self._grid.y_lines or self._grid.z_lines
))
root.addWidget(self._snap_cb)
buttons = QDialogButtonBox(

View file

@ -42,12 +42,10 @@ class AssignEqualDOFDialog(QDialog):
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.addWidget(
QLabel(
"Tie selected nodes together in chosen DOFs using OpenSees "
"<code>equalDOF</code>.",
)
)
layout.addWidget(QLabel(
"Tie selected nodes together in chosen DOFs using OpenSees "
"<code>equalDOF</code>.",
))
form = QFormLayout()
@ -66,7 +64,7 @@ class AssignEqualDOFDialog(QDialog):
dof_form = QFormLayout()
self._dof_boxes: list[QCheckBox] = []
labels = ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz")
active_labels = labels[:2] + (labels[5:6] if self._ndf == 3 else labels[2 : self._ndf])
active_labels = labels[:2] + (labels[5:6] if self._ndf == 3 else labels[2:self._ndf])
if self._ndf == 2:
active_labels = labels[:2]
elif self._ndf == 3:
@ -81,10 +79,7 @@ class AssignEqualDOFDialog(QDialog):
if self._suggested_dofs is not None and dof_id in self._suggested_dofs:
cb.setChecked(True)
self._dof_boxes.append(cb)
dof_form.addRow(
f"DOF {i if self._ndf != 3 else (1 if label=='Ux' else 2 if label=='Uy' else 3)}:",
cb,
)
dof_form.addRow(f"DOF {i if self._ndf != 3 else (1 if label=='Ux' else 2 if label=='Uy' else 3)}:", cb)
layout.addLayout(dof_form)
buttons = QDialogButtonBox(

View file

@ -64,7 +64,9 @@ class AssignHingeDialog(QDialog):
def _build_ui(self, n_selected: int) -> None:
layout = QVBoxLayout(self)
header = f"Convert <b>{n_selected}</b> selected element(s) to BeamWithHinges."
header = (
f"Convert <b>{n_selected}</b> selected element(s) to BeamWithHinges."
)
prefill = self._selection_prefill()
if prefill is None and self._element_ids:
header += " <i>Selection is mixed — unit-scaled defaults shown.</i>"
@ -158,20 +160,18 @@ class AssignHingeDialog(QDialog):
if el is None:
return None
if getattr(el, "type", None) == "BeamWithHinges":
collected.append(
{
"section_i_id": getattr(el, "section_i_id", None),
"section_j_id": getattr(el, "section_j_id", None),
"lp_i": getattr(el, "lp_i", None),
"lp_j": getattr(el, "lp_j", None),
"E": getattr(el, "E", None),
"A": getattr(el, "A", None),
"Iz": getattr(el, "Iz", None),
"Iy": getattr(el, "Iy", None),
"G": getattr(el, "G", None),
"J": getattr(el, "J", None),
}
)
collected.append({
"section_i_id": getattr(el, "section_i_id", None),
"section_j_id": getattr(el, "section_j_id", None),
"lp_i": getattr(el, "lp_i", None),
"lp_j": getattr(el, "lp_j", None),
"E": getattr(el, "E", None),
"A": getattr(el, "A", None),
"Iz": getattr(el, "Iz", None),
"Iy": getattr(el, "Iy", None),
"G": getattr(el, "G", None),
"J": getattr(el, "J", None),
})
continue
sec_id = getattr(el, "section_id", None)
if sec_id is None:
@ -185,22 +185,21 @@ class AssignHingeDialog(QDialog):
# prefill the hinge pickers with the section itself.
collected.append({"section_i_id": sec_id, "section_j_id": sec_id})
continue
collected.append(
{
"section_i_id": sec_id,
"section_j_id": sec_id,
"E": getattr(sec, "E", None),
"A": getattr(sec, "A", None),
"Iz": getattr(sec, "Iz", None),
"Iy": getattr(sec, "Iy", None),
"G": getattr(sec, "G", None),
"J": getattr(sec, "J", None),
}
)
collected.append({
"section_i_id": sec_id,
"section_j_id": sec_id,
"E": getattr(sec, "E", None),
"A": getattr(sec, "A", None),
"Iz": getattr(sec, "Iz", None),
"Iy": getattr(sec, "Iy", None),
"G": getattr(sec, "G", None),
"J": getattr(sec, "J", None),
})
if not collected:
return None
merged: dict = {}
for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", "E", "A", "Iz", "Iy", "G", "J"):
for key in ("section_i_id", "section_j_id", "lp_i", "lp_j",
"E", "A", "Iz", "Iy", "G", "J"):
vals = {c.get(key) for c in collected if c.get(key) is not None}
if len(vals) == 1:
merged[key] = next(iter(vals))

View file

@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QInputDialog,
QLabel,
QLineEdit,
QVBoxLayout,
@ -79,7 +80,9 @@ class AssignLoadDialog(QDialog):
pf.addRow("Active:", self._active_pattern_label)
self._new_name_edit = QLineEdit("Pattern")
self._new_name_edit.setPlaceholderText("Name for the new pattern (e.g. RefMoment)")
self._new_name_edit.setPlaceholderText(
"Name for the new pattern (e.g. RefMoment)"
)
pf.addRow("New name:", self._new_name_edit)
# TimeSeries type selector — only used when creating a new

View file

@ -8,8 +8,6 @@ it maps cleanly onto our existing :class:`SetMassCommand`.
from __future__ import annotations
import contextlib
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
@ -58,22 +56,16 @@ class AssignMassesDialog(QDialog):
cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
form = QFormLayout()
self._mx = self._spin(cur[0])
form.addRow("Translation X:", self._mx)
self._my = self._spin(cur[1])
form.addRow("Translation Y:", self._my)
self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx)
self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my)
if self._ndf >= 3:
self._mz = self._spin(cur[2])
form.addRow("Translation Z:", self._mz)
self._mz = self._spin(cur[2]); form.addRow("Translation Z:", self._mz)
else:
self._mz = self._spin(cur[2])
if self._ndf == 6:
self._mxx = self._spin(cur[3])
form.addRow("Rotation X (Ixx):", self._mxx)
self._myy = self._spin(cur[4])
form.addRow("Rotation Y (Iyy):", self._myy)
self._mzz = self._spin(cur[5])
form.addRow("Rotation Z (Izz):", self._mzz)
self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx)
self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy)
self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz)
else:
self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4])
@ -85,7 +77,8 @@ class AssignMassesDialog(QDialog):
root.addWidget(self._xy_link)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
@ -105,16 +98,14 @@ 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)."""
return (
self._mx.value(),
self._my.value(),
self._mz.value(),
self._mxx.value(),
self._myy.value(),
self._mzz.value(),
self._mx.value(), self._my.value(), self._mz.value(),
self._mxx.value(), self._myy.value(), self._mzz.value(),
)

View file

@ -41,9 +41,9 @@ class AssignSectionDialog(QDialog):
layout.addWidget(QLabel(self._current_note()))
if not self._sections:
layout.addWidget(
QLabel("<i>No sections defined. Open Define → Section Library first.</i>")
)
layout.addWidget(QLabel(
"<i>No sections defined. Open Define → Section Library first.</i>"
))
self._combo = QComboBox()
for s in self._sections:
@ -115,15 +115,15 @@ class AssignMaterialDialog(QDialog):
def _build_ui(self, n_elements: int) -> None:
layout = QVBoxLayout(self)
layout.addWidget(
QLabel(f"Apply material to <b>{n_elements}</b> selected truss/zero-length element(s).")
)
layout.addWidget(QLabel(
f"Apply material to <b>{n_elements}</b> selected truss/zero-length element(s)."
))
layout.addWidget(QLabel(self._current_note()))
if not self._materials:
layout.addWidget(
QLabel("<i>No materials defined. Open Define → Material Library first.</i>")
)
layout.addWidget(QLabel(
"<i>No materials defined. Open Define → Material Library first.</i>"
))
self._combo = QComboBox()
for m in self._materials:

View file

@ -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,12 +22,13 @@ 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),
"Roller (Z)": (False, False, True, False, False, False),
"Pin": (True, True, True, False, False, False),
"Fix": (True, True, True, True, True, True),
"Free": (False, False, False, False, False, False),
"Roller (Z)": (False, False, True, False, False, False),
"Pin": (True, True, True, False, False, False),
"Fix": (True, True, True, True, True, True),
}

View file

@ -25,9 +25,8 @@ from otko.core import Project
class AssignZeroLengthSectionDialog(QDialog):
"""Modal dialog: pick a section, confirm creation between 2 nodes."""
def __init__(
self, project: Project, node_ids: tuple[int, int], parent: QWidget | None = None
) -> None:
def __init__(self, project: Project, node_ids: tuple[int, int],
parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Assign Zero-Length Section")
self._project = project
@ -36,14 +35,12 @@ class AssignZeroLengthSectionDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.addWidget(
QLabel(
f"Connect node <b>{self._node_ids[0]}</b> and "
f"<b>{self._node_ids[1]}</b> through a zero-length "
"section element. The two nodes must share the same "
"coordinates."
)
)
root.addWidget(QLabel(
f"Connect node <b>{self._node_ids[0]}</b> and "
f"<b>{self._node_ids[1]}</b> through a zero-length "
"section element. The two nodes must share the same "
"coordinates."
))
form = QFormLayout()
self._section_cb = QComboBox()
@ -56,7 +53,8 @@ class AssignZeroLengthSectionDialog(QDialog):
root.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)

View file

@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
QListWidget,
QListWidgetItem,
QSpinBox,
QVBoxLayout,
QWidget,
)
@ -36,14 +37,9 @@ from otko.core import (
# ─────────────────────────── helpers ───────────────────────────
def _spin(
default: float = 0.0,
*,
decimals: int = 6,
minimum: float = -1e15,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
def _spin(default: float = 0.0, *, decimals: int = 6,
minimum: float = -1e15, maximum: float = 1e15,
step: float = 1.0) -> QDoubleSpinBox:
sb = QDoubleSpinBox()
sb.setRange(minimum, maximum)
sb.setDecimals(decimals)
@ -231,14 +227,7 @@ class CaseFormBase(QWidget):
# ─────────────────────────── Static ───────────────────────────
_STATIC_SYSTEMS = [
"BandGeneral",
"BandSPD",
"ProfileSPD",
"SparseGeneral",
"UmfPack",
"FullGeneral",
]
_STATIC_SYSTEMS = ["BandGeneral", "BandSPD", "ProfileSPD", "SparseGeneral", "UmfPack", "FullGeneral"]
_CONSTRAINTS = ["Plain", "Lagrange", "Penalty", "Transformation"]
_INTEGRATORS_STATIC = ["LoadControl", "DisplacementControl", "ArcLength"]
_ALGORITHMS = ["Linear", "Newton", "ModifiedNewton", "KrylovNewton", "BFGS", "Broyden"]
@ -302,8 +291,7 @@ class StaticCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> StaticCase:
return StaticCase(
id=cid,
name=self._name_edit.text(),
id=cid, name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(StaticCase, self._patterns_picker, self._pattern_factor_spins),
n_steps=self._n_steps.value(),
@ -334,9 +322,9 @@ class ModalCaseForm(CaseFormBase):
self._solver.addItems(["genBandArpack", "fullGenLapack", "symmBandLapack"])
self._layout.addRow("Number of modes:", self._n_modes)
self._layout.addRow("Solver:", self._solver)
self._layout.addRow(
QLabel("<i>The runner auto-falls back to fullGenLapack for very small models.</i>")
)
self._layout.addRow(QLabel(
"<i>The runner auto-falls back to fullGenLapack for very small models.</i>"
))
def _populate_specific(self, c: ModalCase) -> None:
self._n_modes.setValue(c.n_modes)
@ -344,8 +332,7 @@ class ModalCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> ModalCase:
return ModalCase(
id=cid,
name=self._name_edit.text(),
id=cid, name=self._name_edit.text(),
n_modes=self._n_modes.value(),
solver=self._solver.currentText(),
)
@ -372,19 +359,13 @@ class TransientCaseForm(CaseFormBase):
self._remove_patterns_picker = _make_pattern_picker(patterns)
self._dt = _spin(0.01, decimals=8, minimum=1e-12, step=1e-3)
self._n_steps = _int_spin(1000, minimum=1, maximum=10_000_000)
self._system = QComboBox()
self._system.addItems(_STATIC_SYSTEMS)
self._constraints = QComboBox()
self._constraints.addItems(_CONSTRAINTS)
self._integrator = QComboBox()
self._integrator.addItems(_INTEGRATORS_TRANSIENT)
self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS)
self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS)
self._integrator = QComboBox(); self._integrator.addItems(_INTEGRATORS_TRANSIENT)
self._gamma = _spin(0.5, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._beta = _spin(0.25, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._algorithm = QComboBox()
self._algorithm.addItems(_ALGORITHMS)
self._algorithm.setCurrentText("Newton")
self._test = QComboBox()
self._test.addItems(_TESTS)
self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS); self._algorithm.setCurrentText("Newton")
self._test = QComboBox(); self._test.addItems(_TESTS)
self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
self._max_iter = _int_spin(25)
self._alpha_m = _spin(0.0, decimals=8, minimum=0.0, maximum=1e12, step=1e-4)
@ -395,12 +376,10 @@ class TransientCaseForm(CaseFormBase):
self._layout.addRow(self._patterns_picker)
self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>"))
self._layout.addRow(self._preload_picker)
self._layout.addRow(
QLabel(
"<i>Run these Static cases first, then hold them constant via "
"loadConst -time 0.0 before the transient starts.</i>"
)
)
self._layout.addRow(QLabel(
"<i>Run these Static cases first, then hold them constant via "
"loadConst -time 0.0 before the transient starts.</i>"
))
self._layout.addRow(QLabel("<b>Patterns to remove after preload (optional):</b>"))
self._layout.addRow(self._remove_patterns_picker)
self._layout.addRow("dt:", self._dt)
@ -417,20 +396,17 @@ class TransientCaseForm(CaseFormBase):
self._layout.addRow("Rayleigh αM:", self._alpha_m)
self._layout.addRow("Rayleigh βK:", self._beta_k)
self._layout.addRow("Mode-1 damping ratio:", self._mode1_damping)
self._layout.addRow(
QLabel(
"<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
"after preload and uses it instead of the manual βK value.</i>"
)
)
self._layout.addRow(QLabel(
"<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
"after preload and uses it instead of the manual βK value.</i>"
))
def _populate_specific(self, c: TransientCase) -> None:
_select_pattern_ids(self._patterns_picker, c.pattern_ids)
_set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {})
_select_case_ids(self._preload_picker, c.preload_case_ids)
_select_pattern_ids(self._remove_patterns_picker, c.remove_patterns)
self._dt.setValue(c.dt)
self._n_steps.setValue(c.n_steps)
self._dt.setValue(c.dt); self._n_steps.setValue(c.n_steps)
self._system.setCurrentText(c.system)
self._constraints.setCurrentText(c.constraints)
self._integrator.setCurrentText(c.integrator)
@ -447,14 +423,12 @@ class TransientCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> TransientCase:
mode1_damping = self._mode1_damping.value()
return TransientCase(
id=cid,
name=self._name_edit.text(),
id=cid, name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(TransientCase, self._patterns_picker, self._pattern_factor_spins),
preload_case_ids=_selected_case_ids(self._preload_picker),
remove_patterns=_selected_pattern_ids(self._remove_patterns_picker),
dt=self._dt.value(),
n_steps=self._n_steps.value(),
dt=self._dt.value(), n_steps=self._n_steps.value(),
system=self._system.currentText(),
constraints=self._constraints.currentText(),
integrator=self._integrator.currentText(),
@ -488,18 +462,12 @@ class PushoverCaseForm(CaseFormBase):
self._target = _spin(0.1, decimals=6, minimum=-1e6, maximum=1e6, step=0.001)
self._step = _spin(0.001, decimals=8, minimum=1e-12, step=1e-4)
self._base_nodes = QLineEdit()
self._base_nodes.setPlaceholderText(
"comma-separated node ids (leave blank for all supports)"
)
self._system = QComboBox()
self._system.addItems(_STATIC_SYSTEMS)
self._constraints = QComboBox()
self._constraints.addItems(_CONSTRAINTS)
self._algorithm = QComboBox()
self._algorithm.addItems(_ALGORITHMS)
self._base_nodes.setPlaceholderText("comma-separated node ids (leave blank for all supports)")
self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS)
self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS)
self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS)
self._algorithm.setCurrentText("Newton")
self._test = QComboBox()
self._test.addItems(_TESTS)
self._test = QComboBox(); self._test.addItems(_TESTS)
self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
self._max_iter = _int_spin(25)
@ -507,12 +475,10 @@ class PushoverCaseForm(CaseFormBase):
self._layout.addRow(self._patterns_picker)
self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>"))
self._layout.addRow(self._preload_picker)
self._layout.addRow(
QLabel(
"<i>Run these Static cases first, then hold them constant via "
"loadConst -time 0.0 before the pushover starts.</i>"
)
)
self._layout.addRow(QLabel(
"<i>Run these Static cases first, then hold them constant via "
"loadConst -time 0.0 before the pushover starts.</i>"
))
self._layout.addRow("Control node:", self._control_node)
self._layout.addRow("Control DOF:", self._control_dof)
self._layout.addRow("Target displacement:", self._target)
@ -545,8 +511,7 @@ class PushoverCaseForm(CaseFormBase):
txt = self._base_nodes.text().strip()
base_ids = [int(x) for x in txt.replace(",", " ").split() if x] if txt else []
return PushoverCase(
id=cid,
name=self._name_edit.text(),
id=cid, name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(PushoverCase, self._patterns_picker, self._pattern_factor_spins),
preload_case_ids=_selected_case_ids(self._preload_picker),
@ -587,12 +552,10 @@ class ResponseSpectrumCaseForm(CaseFormBase):
self._layout.addRow("Direction (DOF):", self._direction)
self._layout.addRow("Combination:", self._combination)
self._layout.addRow("Damping (CQC override):", self._damping)
self._layout.addRow(
QLabel(
"<i>Damping is used by CQC modal correlation only; "
"leave at 0 to use the spectrum's own damping ratio.</i>",
)
)
self._layout.addRow(QLabel(
"<i>Damping is used by CQC modal correlation only; "
"leave at 0 to use the spectrum's own damping ratio.</i>",
))
def _populate_specific(self, c: ResponseSpectrumCase) -> None:
self._modal_case.setValue(c.modal_case_id)
@ -605,8 +568,7 @@ class ResponseSpectrumCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> ResponseSpectrumCase:
damp_val = self._damping.value()
return ResponseSpectrumCase(
id=cid,
name=self._name_edit.text(),
id=cid, name=self._name_edit.text(),
modal_case_id=self._modal_case.value(),
spectrum_id=self._spectrum_id.value(),
direction=self._direction.value(),

View file

@ -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,
@ -27,14 +27,10 @@ from otko.commands import (
UpdateAnalysisCaseCommand,
)
from otko.core import (
ModalCase,
PushoverCase,
ResponseSpectrumCase,
StaticCase,
TransientCase,
ModalCase, PushoverCase, ResponseSpectrumCase, StaticCase, 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
@ -60,23 +56,16 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt
return ModalCase(id=cid, name="Modal", n_modes=3)
if kind == "Transient":
return TransientCase(
id=cid,
name="Transient",
pattern_ids=[_first_pattern_id(project)],
dt=0.01,
n_steps=1000,
id=cid, name="Transient", pattern_ids=[_first_pattern_id(project)],
dt=0.01, n_steps=1000,
)
if kind == "Pushover":
if not project.nodes:
raise ValueError("Define at least one node before adding a Pushover case.")
return PushoverCase(
id=cid,
name="Pushover",
pattern_ids=[_first_pattern_id(project)],
control_node=project.nodes[0].id,
control_dof=1,
target_disp=0.1,
step_size=0.001,
id=cid, name="Pushover", pattern_ids=[_first_pattern_id(project)],
control_node=project.nodes[0].id, control_dof=1,
target_disp=0.1, step_size=0.001,
)
if kind == "ResponseSpectrum":
modal_ids = [c.id for c in project.analyses if isinstance(c, ModalCase)]
@ -84,15 +73,13 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt
raise ValueError("Add a Modal case before adding a ResponseSpectrum case.")
if not project.spectra:
raise ValueError(
"Define at least one response spectrum before adding " "a ResponseSpectrum case."
"Define at least one response spectrum before adding "
"a ResponseSpectrum case."
)
return ResponseSpectrumCase(
id=cid,
name="ResponseSpectrum",
modal_case_id=modal_ids[0],
spectrum_id=project.spectra[0].id,
direction=1,
combination="SRSS",
id=cid, name="ResponseSpectrum",
modal_case_id=modal_ids[0], spectrum_id=project.spectra[0].id,
direction=1, combination="SRSS",
)
raise ValueError(f"Unknown analysis type: {kind}.")
@ -163,7 +150,7 @@ class AnalysisCaseManagerDialog(QDialog):
if self._list.currentItem() is not None:
selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
self._list.clear()
for c in self._vm.project.analyses if self._vm.project else []:
for c in (self._vm.project.analyses if self._vm.project else []):
label = f"#{c.id} {c.name or '(unnamed)'} [{c.type}]"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, c.id)
@ -214,31 +201,22 @@ class AnalysisCaseManagerDialog(QDialog):
# patterns). Do NOT fall through to the general type picker —
# Static/Transient/Pushover would get dangling pattern refs.
QMessageBox.information(
self,
"No patterns",
self, "No patterns",
"No load patterns defined yet — only a Modal case can be "
"added (Modal works without patterns). Define a pattern "
"first for Static, Transient, or Pushover cases.",
)
kind, ok = QInputDialog.getItem(
self,
"Add analysis case",
"Type:",
["Modal"],
current=0,
editable=False,
self, "Add analysis case", "Type:",
["Modal"], current=0, editable=False,
)
if not ok:
return
self._create_case_of_kind("Modal")
return
kind, ok = QInputDialog.getItem(
self,
"Add analysis case",
"Type:",
list(_DEFAULTS.keys()),
current=0,
editable=False,
self, "Add analysis case", "Type:",
list(_DEFAULTS.keys()), current=0, editable=False,
)
if not ok:
return

View file

@ -1,258 +0,0 @@
"""Load Combination manager — library-style CRUD over case-result combos.
A combination names a weighted set of *completed* static-case results
(e.g. ``1.2×Dead + 1.6×Live``). Evaluation is post-processing
superposition in :mod:`otko.services.combinations` — the dialog only
edits the definition; running happens from File → Run → Combinations.
"""
from __future__ import annotations
from pydantic import ValidationError
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from otko.commands import (
AddCombinationsCommand,
DeleteCombinationsCommand,
UpdateCombinationCommand,
)
from otko.core import LoadCombination, LoadCombinationItem
from otko.viewmodels import ProjectViewModel
def _spin(default: float = 1.0) -> QDoubleSpinBox:
"""Factor editor: wide range, 3 decimals, auto-selects its row on edit."""
sb = QDoubleSpinBox()
sb.setRange(-1e9, 1e9)
sb.setDecimals(3)
sb.setSingleStep(0.1)
sb.setValue(default)
sb.setMaximumWidth(100)
sb.setToolTip("Scale applied to this case's results (1.0 = full).")
return sb
class CombinationForm(QWidget):
"""Edit one combination: name, kind, and per-case factors."""
def __init__(self, vm: ProjectViewModel, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._vm = vm
self._combo_id: int | None = None
self._spins: dict[int, QDoubleSpinBox] = {}
layout = QFormLayout(self)
self._name_edit = QLineEdit()
layout.addRow("Name:", self._name_edit)
self._kind = QComboBox()
self._kind.addItems(["Linear", "Envelope"])
self._kind.setToolTip(
"Linear = weighted sum Σ factor×results. "
"Envelope = per-DOF max/min across factored terms."
)
layout.addRow("Kind:", self._kind)
layout.addRow(QLabel("<b>Cases (tick to include; factor beside each):</b>"))
self._cases = QListWidget()
self._cases.setMaximumHeight(220)
layout.addRow(self._cases)
layout.addRow(
QLabel(
"<i>Only linear-static cases combine meaningfully. "
"Transient / modal / pushover results are rejected at evaluation.</i>"
)
)
# ── populate / read ──────────────────────────────────────────
def populate(self, combo: LoadCombination) -> None:
"""Fill the form from ``combo`` (case list rebuilt from the project)."""
self._combo_id = combo.id
self._name_edit.setText(combo.name)
self._kind.setCurrentText(combo.kind)
self._rebuild_case_list(combo)
def _rebuild_case_list(self, combo: LoadCombination | None = None) -> None:
factors = {i.case_id: i.factor for i in combo.items} if combo else {}
self._cases.clear()
self._spins.clear()
project = self._vm.project
if project is None:
return
for case in project.analyses:
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, case.id)
self._cases.addItem(item)
cell = QWidget()
row = QHBoxLayout(cell)
row.setContentsMargins(2, 0, 2, 0)
row.addWidget(QLabel(f"#{case.id} {case.name or '(unnamed)'} [{case.type}]"), 1)
spin = _spin(float(factors.get(case.id, 1.0)))
spin.valueChanged.connect(lambda _v, _it=item: _it.setSelected(True))
row.addWidget(spin)
self._cases.setItemWidget(item, cell)
self._spins[case.id] = spin
if case.id in factors:
item.setSelected(True)
def read(self) -> LoadCombination:
"""Build a validated combination from the form (raises on bad input)."""
if self._combo_id is None:
raise ValueError("No combination selected.")
selected = [i.data(Qt.ItemDataRole.UserRole) for i in self._cases.selectedItems()]
if not selected:
raise ValueError("Select at least one analysis case for this combination.")
items = [
LoadCombinationItem(case_id=int(cid), factor=float(self._spins[int(cid)].value()))
for cid in selected
]
return LoadCombination(
id=self._combo_id,
name=self._name_edit.text(),
kind=self._kind.currentText(), # type: ignore[arg-type]
items=items,
)
class LoadCombinationManagerDialog(QDialog):
"""CRUD dialog for load combinations."""
def __init__(self, vm: ProjectViewModel, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Load Combinations")
self.resize(720, 520)
self._vm = vm
self._build_ui()
self._refresh_list()
self._vm.modelMutated.connect(self._refresh_list)
def _build_ui(self) -> None:
outer = QVBoxLayout(self)
outer.addWidget(
QLabel(
"<b>Load Combinations</b> — weighted sets of completed static-case "
"results (e.g. 1.2×Dead + 1.6×Live). Evaluate from File → Run."
)
)
body = QHBoxLayout()
outer.addLayout(body, stretch=1)
left = QVBoxLayout()
self._list = QListWidget()
self._list.currentRowChanged.connect(self._on_row_changed)
left.addWidget(self._list, stretch=1)
btn_row = QHBoxLayout()
self._add_btn = QPushButton("Add…")
self._delete_btn = QPushButton("Delete")
self._add_btn.clicked.connect(self._on_add)
self._delete_btn.clicked.connect(self._on_delete)
btn_row.addWidget(self._add_btn)
btn_row.addWidget(self._delete_btn)
left.addLayout(btn_row)
body.addLayout(left, stretch=1)
right = QVBoxLayout()
self._form = CombinationForm(self._vm)
right.addWidget(self._form, stretch=1)
self._apply_btn = QPushButton("Apply changes")
self._apply_btn.clicked.connect(self._on_apply)
right.addWidget(self._apply_btn, alignment=Qt.AlignmentFlag.AlignRight)
body.addLayout(right, stretch=2)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
outer.addWidget(buttons)
# ── list ─────────────────────────────────────────────────────
def _refresh_list(self) -> None:
selected_id = None
if self._list.currentItem() is not None:
selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
self._list.clear()
for c in self._vm.project.combinations if self._vm.project else []:
label = f"#{c.id} {c.name or '(unnamed)'} [{c.kind}]"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, c.id)
self._list.addItem(item)
if selected_id is not None:
self._select_by_id(selected_id)
if self._list.currentRow() < 0 and self._list.count():
self._list.setCurrentRow(0)
if not self._list.count():
self._form._combo_id = None
def _selected(self) -> LoadCombination | None:
item = self._list.currentItem()
if item is None or self._vm.project is None:
return None
cid = item.data(Qt.ItemDataRole.UserRole)
return next((c for c in self._vm.project.combinations if c.id == cid), None)
def _on_row_changed(self, _row: int) -> None:
combo = self._selected()
if combo is None:
return
self._form.populate(combo)
def _select_by_id(self, target_id: int) -> None:
for i in range(self._list.count()):
if self._list.item(i).data(Qt.ItemDataRole.UserRole) == target_id:
self._list.setCurrentRow(i)
return
# ── mutations ────────────────────────────────────────────────
def _on_apply(self) -> None:
try:
new_combo = self._form.read()
self._vm.apply_command(UpdateCombinationCommand(self._vm, new_combo))
except (ValidationError, ValueError) as exc:
QMessageBox.critical(self, "Validation error", str(exc))
def _on_add(self) -> None:
if self._vm.project is None:
return
if not self._vm.project.analyses:
QMessageBox.information(
self,
"No cases",
"Define at least one analysis case first (Analyze → Cases…).",
)
return
new_id = self._vm.project.next_combination_id()
first = self._vm.project.analyses[0]
try:
combo = LoadCombination(
id=new_id,
name="New",
items=[LoadCombinationItem(case_id=first.id)],
)
except (ValidationError, ValueError) as exc:
QMessageBox.warning(self, "Could not create combination", str(exc))
return
self._vm.apply_command(AddCombinationsCommand(self._vm, [combo]))
self._select_by_id(new_id)
def _on_delete(self) -> None:
combo = self._selected()
if combo is None:
return
reply = QMessageBox.question(
self, "Delete combination", f"Delete load combination #{combo.id}?"
)
if reply != QMessageBox.StandardButton.Yes:
return
self._vm.apply_command(DeleteCombinationsCommand(self._vm, {combo.id}))

View file

@ -61,7 +61,8 @@ class CoordSystemDataDialog(QDialog):
) -> None:
super().__init__(parent)
self.setWindowTitle(
"Edit Coordinate/Grid System" if existing is not None else "Add Coordinate/Grid System"
"Edit Coordinate/Grid System"
if existing is not None else "Add Coordinate/Grid System"
)
self._is_global = is_global
self._existing = existing
@ -87,33 +88,28 @@ class CoordSystemDataDialog(QDialog):
loc_box = QGroupBox("Location and Orientation (relative to Global)")
loc_form = QFormLayout(loc_box)
self._ox = self._spin()
self._oy = self._spin()
self._oz = self._spin()
self._ox = self._spin(); self._oy = self._spin(); self._oz = self._spin()
origin_row = QHBoxLayout()
for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)):
origin_row.addWidget(QLabel(f"{label}:"))
origin_row.addWidget(w)
origin_wrap = QWidget()
origin_wrap.setLayout(origin_row)
origin_wrap = QWidget(); origin_wrap.setLayout(origin_row)
loc_form.addRow("Origin:", origin_wrap)
self._rx = self._rot_spin()
self._ry = self._rot_spin()
self._rz = self._rot_spin()
self._rx = self._rot_spin(); self._ry = self._rot_spin(); self._rz = self._rot_spin()
rot_row = QHBoxLayout()
for label, w in (("about X", self._rx), ("about Y", self._ry), ("about Z", self._rz)):
rot_row.addWidget(QLabel(f"{label}:"))
rot_row.addWidget(w)
rot_wrap = QWidget()
rot_wrap.setLayout(rot_row)
rot_wrap = QWidget(); rot_wrap.setLayout(rot_row)
loc_form.addRow("Rotation (deg):", rot_wrap)
if self._is_global:
for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz):
w.setEnabled(False)
loc_box.setToolTip(
"Global system is anchored at the world origin " "with identity orientation.",
"Global system is anchored at the world origin "
"with identity orientation.",
)
layout.addWidget(loc_box)
@ -164,7 +160,8 @@ class CoordSystemDataDialog(QDialog):
# ── Buttons ──────────────────────────────────────────────
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
@ -192,13 +189,9 @@ class CoordSystemDataDialog(QDialog):
def _load_existing(self, cs: CoordinateGridSystem) -> None:
self._name_edit.setText(cs.name)
ox, oy, oz = cs.coord.origin
self._ox.setValue(ox)
self._oy.setValue(oy)
self._oz.setValue(oz)
self._ox.setValue(ox); self._oy.setValue(oy); self._oz.setValue(oz)
rx, ry, rz = cs.coord.rotation_deg
self._rx.setValue(rx)
self._ry.setValue(ry)
self._rz.setValue(rz)
self._rx.setValue(rx); self._ry.setValue(ry); self._rz.setValue(rz)
self._x_edit.setText(_spacings_text(cs.grid.x_lines))
self._y_edit.setText(_spacings_text(cs.grid.y_lines))
self._z_edit.setText(_spacings_text(cs.grid.z_lines))
@ -252,7 +245,9 @@ class CoordinateGridSystemsDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("Coordinate/Grid Systems")
self.resize(600, 420)
self._systems: list[CoordinateGridSystem] = [s.model_copy(deep=True) for s in systems]
self._systems: list[CoordinateGridSystem] = [
s.model_copy(deep=True) for s in systems
]
self._build_ui()
self._refresh_list()
@ -295,7 +290,8 @@ class CoordinateGridSystemsDialog(QDialog):
right.addStretch(1)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
@ -336,7 +332,9 @@ class CoordinateGridSystemsDialog(QDialog):
self._btn_modify.setEnabled(cs is not None)
self._cb_general.blockSignals(True)
self._cb_general.setChecked(cs.grid.is_general if cs is not None else False)
self._cb_general.setEnabled(cs is not None and not cs.grid.is_general)
self._cb_general.setEnabled(
cs is not None and not cs.grid.is_general
)
self._cb_general.blockSignals(False)
# ── actions ──────────────────────────────────────────────────
@ -353,17 +351,14 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
dlg = DefineGridSystemDataDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
new_sys = dlg.system()
if any(s.name == new_sys.name for s in self._systems):
new_sys = new_sys.model_copy(
update={
"name": self._unique_name(new_sys.name),
}
)
new_sys = new_sys.model_copy(update={
"name": self._unique_name(new_sys.name),
})
self._systems.append(new_sys)
self._refresh_list(select_name=new_sys.name)
@ -385,11 +380,8 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
dlg = DefineGridSystemDataDialog(
existing=cs,
is_global=cs.is_global(),
parent=self,
existing=cs, is_global=cs.is_global(), parent=self,
)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
@ -400,11 +392,9 @@ class CoordinateGridSystemsDialog(QDialog):
# Enforce unique names against siblings.
others = [s for s in self._systems if s is not cs]
if any(s.name == updated.name for s in others):
updated = updated.model_copy(
update={
"name": self._unique_name(updated.name),
}
)
updated = updated.model_copy(update={
"name": self._unique_name(updated.name),
})
row = self._list.currentRow()
self._systems[row] = updated
self._refresh_list(select_name=updated.name)
@ -424,8 +414,7 @@ class CoordinateGridSystemsDialog(QDialog):
if cs.grid.is_general and not checked:
# SAP2000: once converted to General, can't convert back.
QMessageBox.information(
self,
"Convert to General",
self, "Convert to General",
"Once a system is converted to General, it cannot be "
"converted back to a regular Cartesian system.",
)

View file

@ -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)
@ -134,9 +127,9 @@ class _AxisGridTable(QWidget):
prev = 0.0
for i, v in enumerate(ords):
if i == 0:
out.append(v) # first row: absolute position
out.append(v) # first row: absolute position
else:
out.append(v - prev) # subsequent rows: spacing from previous
out.append(v - prev) # subsequent rows: spacing from previous
prev = v
return out
@ -176,28 +169,23 @@ class _AxisGridTable(QWidget):
visible_widget = self._table.cellWidget(row, 3)
bubble_widget = self._table.cellWidget(row, 4)
color_item = self._table.item(row, 5)
out.append(
GridLine(
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
ordinate=ords[row] if row < len(ords) else 0.0,
line_type=(
line_type_widget.currentText() # type: ignore[union-attr]
if isinstance(line_type_widget, QComboBox)
else "Primary"
),
visible=(
visible_widget.isChecked() # type: ignore[union-attr]
if isinstance(visible_widget, QCheckBox)
else True
),
bubble_loc=(
bubble_widget.currentText() # type: ignore[union-attr]
if isinstance(bubble_widget, QComboBox)
else "End"
),
color=(color_item.text() if color_item else "#808080"),
)
)
out.append(GridLine(
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
ordinate=ords[row] if row < len(ords) else 0.0,
line_type=(
line_type_widget.currentText() # type: ignore[union-attr]
if isinstance(line_type_widget, QComboBox) else "Primary"
),
visible=(
visible_widget.isChecked() # type: ignore[union-attr]
if isinstance(visible_widget, QCheckBox) else True
),
bubble_loc=(
bubble_widget.currentText() # type: ignore[union-attr]
if isinstance(bubble_widget, QComboBox) else "End"
),
color=(color_item.text() if color_item else "#808080"),
))
return out
# ── row ops ───────────────────────────────────────────────────
@ -230,8 +218,7 @@ class _AxisGridTable(QWidget):
vis = QCheckBox()
vis.setChecked(ln.visible)
vis_wrap = QWidget()
lay = QHBoxLayout(vis_wrap)
lay.setContentsMargins(0, 0, 0, 0)
lay = QHBoxLayout(vis_wrap); lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(vis)
lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
# We keep the QCheckBox directly; the wrap is optional visual
@ -263,13 +250,13 @@ class _AxisGridTable(QWidget):
lines = self.collect_lines()
lines.sort(key=lambda ln: ln.ordinate)
self._table.setRowCount(0)
self._show_spacing = False # reset to ordinate view after sort
self._show_spacing = False # reset to ordinate view after sort
self._table.setHorizontalHeaderLabels(self.COLUMNS)
for ln in lines:
self._append_row(ln)
def _on_cell_double_clicked(self, row: int, col: int) -> None:
if col == 5: # Color picker
if col == 5: # Color picker
item = self._table.item(row, col)
current = QColor(item.text() if item else "#808080")
new = QColorDialog.getColor(current, self, "Grid Line Color")
@ -291,7 +278,8 @@ class DefineGridSystemDataDialog(QDialog):
) -> None:
super().__init__(parent)
self.setWindowTitle(
"Define Grid System Data" if existing is not None else "Add Grid System"
"Define Grid System Data" if existing is not None
else "Add Grid System"
)
self.resize(780, 620)
self._is_global = is_global
@ -340,7 +328,9 @@ class DefineGridSystemDataDialog(QDialog):
self._btn_locate.clicked.connect(self._on_locate_origin)
if self._is_global:
self._btn_locate.setEnabled(False)
self._btn_locate.setToolTip("Global system is anchored at the world origin.")
self._btn_locate.setToolTip(
"Global system is anchored at the world origin."
)
head.addWidget(self._btn_locate)
root.addLayout(head)
@ -385,7 +375,8 @@ class DefineGridSystemDataDialog(QDialog):
bottom.addWidget(self._btn_quick)
bottom.addStretch(1)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
@ -397,7 +388,8 @@ class DefineGridSystemDataDialog(QDialog):
ox, oy, oz = self._origin
rx, ry, rz = self._rotation_deg
self._origin_label.setText(
f"Origin: ({ox:g}, {oy:g}, {oz:g}) " f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
f"Origin: ({ox:g}, {oy:g}, {oz:g}) "
f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
)
def _load_existing(self, cs: CoordinateGridSystem) -> None:
@ -423,7 +415,6 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog,
)
dlg = CoordSystemLocationOrientationDialog(
origin=self._origin,
rotation_deg=self._rotation_deg,
@ -438,13 +429,11 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.quick_grid_lines import (
QuickGridLinesDialog,
)
dlg = QuickGridLinesDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
xs, ys, zs = dlg.ordinates()
from otko.core.geometry import make_grid_lines
# Revert to ordinate display mode before replacing rows.
self._rb_ordinates.setChecked(True)
self._tab_x.load_lines(make_grid_lines("X", xs))
@ -476,8 +465,7 @@ class DefineGridSystemDataDialog(QDialog):
return CoordinateGridSystem(
name=self._name_edit.text().strip(),
coord=CoordinateSystem(
origin=self._origin,
rotation_deg=self._rotation_deg,
origin=self._origin, rotation_deg=self._rotation_deg,
),
grid=grid,
)

View file

@ -141,11 +141,9 @@ class AssignDistributedLoadDialog(QDialog):
def values(self) -> tuple[float, float, float]:
"""Return (wy, wz, wx)."""
return (
self._spinboxes["wy"].value(),
self._spinboxes["wz"].value(),
self._spinboxes["wx"].value(),
)
return (self._spinboxes["wy"].value(),
self._spinboxes["wz"].value(),
self._spinboxes["wx"].value())
def selected_pattern_id(self) -> int | None:
"""Return the chosen pattern id, or ``None`` if the user picked

View file

@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QLineEdit,
QLabel,
QVBoxLayout,
QWidget,
)
@ -63,9 +63,9 @@ def _coords_from_spacings(spacings: list[float], origin: float = 0.0) -> list[fl
class GridSystemDialog(QDialog):
"""Dialog for entering X/Y/Z grid-line coordinates."""
def __init__(
self, next_node_id: int, existing: GridSystem | None = None, parent: QWidget | None = None
) -> None:
def __init__(self, next_node_id: int,
existing: GridSystem | None = None,
parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Grid System")
self._next_id = next_node_id
@ -104,7 +104,9 @@ class GridSystemDialog(QDialog):
self._visible_cb.setChecked(True)
layout.addWidget(self._visible_cb)
self._generate_nodes_cb = QCheckBox("Also create nodes at every intersection")
self._generate_nodes_cb = QCheckBox(
"Also create nodes at every intersection"
)
self._generate_nodes_cb.setChecked(False)
layout.addWidget(self._generate_nodes_cb)
@ -126,7 +128,6 @@ class GridSystemDialog(QDialog):
def _load_existing(self, grid: GridSystem) -> None:
"""Pre-fill editors from an existing GridSystem (as spacings)."""
def to_spacings_text(coords: list[float]) -> str:
if not coords:
return ""
@ -135,7 +136,6 @@ class GridSystemDialog(QDialog):
# Single line at nonzero origin — degenerate, show empty.
return ""
return ", ".join(f"{s:g}" for s in spacings)
self._x_edit.setText(to_spacings_text(grid.x_lines))
self._y_edit.setText(to_spacings_text(grid.y_lines))
self._z_edit.setText(to_spacings_text(grid.z_lines))
@ -152,7 +152,8 @@ class GridSystemDialog(QDialog):
else:
extra = "<br>Nodes will NOT be created automatically."
self._preview.setText(
f"Grid: <b>{len(xs)}</b> × <b>{len(ys)}</b> × <b>{len(zs)}</b> lines." f"{extra}"
f"Grid: <b>{len(xs)}</b> × <b>{len(ys)}</b> × <b>{len(zs)}</b> lines."
f"{extra}"
)
except (ValueError, IndexError) as exc:
self._preview.setText(f"<span style='color:red'>Parse error: {exc}</span>")

View file

@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget,
@ -35,35 +36,28 @@ class CoordSystemLocationOrientationDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.addWidget(
QLabel(
"Enter the system's origin and orientation relative to <b>Global</b>. "
"Rotations are XYZ Euler angles in degrees."
)
)
root.addWidget(QLabel(
"Enter the system's origin and orientation relative to <b>Global</b>. "
"Rotations are XYZ Euler angles in degrees."
))
origin_box = QGroupBox("Origin (world units)")
of = QFormLayout(origin_box)
self._ox = self._spin()
of.addRow("X:", self._ox)
self._oy = self._spin()
of.addRow("Y:", self._oy)
self._oz = self._spin()
of.addRow("Z:", self._oz)
self._ox = self._spin(); of.addRow("X:", self._ox)
self._oy = self._spin(); of.addRow("Y:", self._oy)
self._oz = self._spin(); of.addRow("Z:", self._oz)
root.addWidget(origin_box)
rot_box = QGroupBox("Rotation about axes (degrees)")
rf = QFormLayout(rot_box)
self._rx = self._rot_spin()
rf.addRow("about X:", self._rx)
self._ry = self._rot_spin()
rf.addRow("about Y:", self._ry)
self._rz = self._rot_spin()
rf.addRow("about Z:", self._rz)
self._rx = self._rot_spin(); rf.addRow("about X:", self._rx)
self._ry = self._rot_spin(); rf.addRow("about Y:", self._ry)
self._rz = self._rot_spin(); rf.addRow("about Z:", self._rz)
root.addWidget(rot_box)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)

View file

@ -33,16 +33,10 @@ from otko.core import (
# ─────────────────────────── helpers ───────────────────────────
def _spin(
default: float = 0.0,
*,
decimals: int = 6,
minimum: float = -1e15,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
def _spin(default: float = 0.0, *, decimals: int = 6,
minimum: float = -1e15, maximum: float = 1e15,
step: float = 1.0) -> QDoubleSpinBox:
from PySide6.QtCore import QLocale
sb = QDoubleSpinBox()
# Force C locale so "." is always the decimal separator.
sb.setLocale(QLocale(QLocale.Language.C))
@ -116,11 +110,8 @@ class Steel01Form(MaterialFormBase):
def _read_specific(self, mid: int) -> Steel01:
return Steel01(
id=mid,
name=self._name_edit.text(),
Fy=self._fy.value(),
E0=self._e0.value(),
b=self._b.value(),
id=mid, name=self._name_edit.text(),
Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
)
@ -136,34 +127,19 @@ class Steel02Form(MaterialFormBase):
self._r0 = _spin(18.0, decimals=2, minimum=10.0, maximum=20.0, step=0.5)
self._cR1 = _spin(0.925, decimals=4, step=0.01)
self._cR2 = _spin(0.15, decimals=4, step=0.01)
for label, w in (
("Fy:", self._fy),
("E0:", self._e0),
("b:", self._b),
("R0:", self._r0),
("cR1:", self._cR1),
("cR2:", self._cR2),
):
for label, w in (("Fy:", self._fy), ("E0:", self._e0), ("b:", self._b),
("R0:", self._r0), ("cR1:", self._cR1), ("cR2:", self._cR2)):
self._layout.addRow(label, w)
def _populate_specific(self, m: Steel02) -> None:
self._fy.setValue(m.Fy)
self._e0.setValue(m.E0)
self._b.setValue(m.b)
self._r0.setValue(m.R0)
self._cR1.setValue(m.cR1)
self._cR2.setValue(m.cR2)
self._fy.setValue(m.Fy); self._e0.setValue(m.E0); self._b.setValue(m.b)
self._r0.setValue(m.R0); self._cR1.setValue(m.cR1); self._cR2.setValue(m.cR2)
def _read_specific(self, mid: int) -> Steel02:
return Steel02(
id=mid,
name=self._name_edit.text(),
Fy=self._fy.value(),
E0=self._e0.value(),
b=self._b.value(),
R0=self._r0.value(),
cR1=self._cR1.value(),
cR2=self._cR2.value(),
id=mid, name=self._name_edit.text(),
Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
R0=self._r0.value(), cR1=self._cR1.value(), cR2=self._cR2.value(),
)
@ -180,28 +156,19 @@ class Concrete01Form(MaterialFormBase):
self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
self._fpcu = _spin(-15e6, step=1.0, maximum=0.0)
self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
for label, w in (
("f'c (-):", self._fpc),
("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu),
("ε_U (-):", self._epsU),
):
for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU)):
self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete01) -> None:
self._fpc.setValue(m.fpc)
self._epsc0.setValue(m.epsc0)
self._fpcu.setValue(m.fpcu)
self._epsU.setValue(m.epsU)
self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
def _read_specific(self, mid: int) -> Concrete01:
return Concrete01(
id=mid,
name=self._name_edit.text(),
fpc=self._fpc.value(),
epsc0=self._epsc0.value(),
fpcu=self._fpcu.value(),
epsU=self._epsU.value(),
id=mid, name=self._name_edit.text(),
fpc=self._fpc.value(), epsc0=self._epsc0.value(),
fpcu=self._fpcu.value(), epsU=self._epsU.value(),
)
@ -220,36 +187,23 @@ class Concrete02Form(MaterialFormBase):
self._lambda = _spin(0.1, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._ft = _spin(3e6, step=1.0, minimum=1e-9)
self._ets = _spin(2e9, step=1.0, minimum=1e-9)
for label, w in (
("f'c (-):", self._fpc),
("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu),
("ε_U (-):", self._epsU),
("λ (unload ratio):", self._lambda),
("ft (tensile):", self._ft),
("Ets (soften):", self._ets),
):
for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU),
("λ (unload ratio):", self._lambda),
("ft (tensile):", self._ft), ("Ets (soften):", self._ets)):
self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete02) -> None:
self._fpc.setValue(m.fpc)
self._epsc0.setValue(m.epsc0)
self._fpcu.setValue(m.fpcu)
self._epsU.setValue(m.epsU)
self._lambda.setValue(m.lambda_)
self._ft.setValue(m.ft)
self._ets.setValue(m.Ets)
self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
self._lambda.setValue(m.lambda_); self._ft.setValue(m.ft); self._ets.setValue(m.Ets)
def _read_specific(self, mid: int) -> Concrete02:
return Concrete02(
id=mid,
name=self._name_edit.text(),
fpc=self._fpc.value(),
epsc0=self._epsc0.value(),
fpcu=self._fpcu.value(),
epsU=self._epsU.value(),
ft=self._ft.value(),
Ets=self._ets.value(),
id=mid, name=self._name_edit.text(),
fpc=self._fpc.value(), epsc0=self._epsc0.value(),
fpcu=self._fpcu.value(), epsU=self._epsU.value(),
ft=self._ft.value(), Ets=self._ets.value(),
**{"lambda": self._lambda.value()},
)
@ -289,12 +243,9 @@ class Concrete04Form(MaterialFormBase):
fct_val = self._fct.value()
et_val = self._et.value()
return Concrete04(
id=mid,
name=self._name_edit.text(),
fpc=self._fpc.value(),
epsc0=self._epsc0.value(),
epscu=self._epscu.value(),
Ec=self._Ec.value(),
id=mid, name=self._name_edit.text(),
fpc=self._fpc.value(), epsc0=self._epsc0.value(),
epscu=self._epscu.value(), Ec=self._Ec.value(),
fct=fct_val if fct_val > 0.0 else None,
et=et_val if et_val > 0.0 else None,
)
@ -312,15 +263,12 @@ class ElasticUniaxialForm(MaterialFormBase):
self._layout.addRow("η (damping):", self._eta)
def _populate_specific(self, m: ElasticUniaxial) -> None:
self._e.setValue(m.E)
self._eta.setValue(m.eta)
self._e.setValue(m.E); self._eta.setValue(m.eta)
def _read_specific(self, mid: int) -> ElasticUniaxial:
return ElasticUniaxial(
id=mid,
name=self._name_edit.text(),
E=self._e.value(),
eta=self._eta.value(),
id=mid, name=self._name_edit.text(),
E=self._e.value(), eta=self._eta.value(),
)
@ -338,17 +286,12 @@ class ElasticIsotropicForm(MaterialFormBase):
self._layout.addRow("ρ (density):", self._rho)
def _populate_specific(self, m: ElasticIsotropic) -> None:
self._e.setValue(m.E)
self._nu.setValue(m.nu)
self._rho.setValue(m.rho)
self._e.setValue(m.E); self._nu.setValue(m.nu); self._rho.setValue(m.rho)
def _read_specific(self, mid: int) -> ElasticIsotropic:
return ElasticIsotropic(
id=mid,
name=self._name_edit.text(),
E=self._e.value(),
nu=self._nu.value(),
rho=self._rho.value(),
id=mid, name=self._name_edit.text(),
E=self._e.value(), nu=self._nu.value(), rho=self._rho.value(),
)
@ -364,15 +307,12 @@ class ElasticPPForm(MaterialFormBase):
self._layout.addRow("ε_y (yield strain):", self._epsy)
def _populate_specific(self, m: ElasticPP) -> None:
self._e.setValue(m.E)
self._epsy.setValue(m.epsy_pos)
self._e.setValue(m.E); self._epsy.setValue(m.epsy_pos)
def _read_specific(self, mid: int) -> ElasticPP:
return ElasticPP(
id=mid,
name=self._name_edit.text(),
E=self._e.value(),
epsy_pos=self._epsy.value(),
id=mid, name=self._name_edit.text(),
E=self._e.value(), epsy_pos=self._epsy.value(),
)

View file

@ -7,9 +7,9 @@ committed. Each Add/Edit/Delete is its own undoable step.
from __future__ import annotations
from pydantic import ValidationError
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from pydantic import ValidationError
from otko.commands import (
AddMaterialsCommand,
@ -149,12 +150,7 @@ class MaterialLibraryDialog(QDialog):
return
kinds = list(FORM_REGISTRY.keys())
kind, ok = QInputDialog.getItem(
self,
"Add material",
"Type:",
kinds,
current=0,
editable=False,
self, "Add material", "Type:", kinds, current=0, editable=False,
)
if not ok:
return
@ -183,10 +179,9 @@ class MaterialLibraryDialog(QDialog):
if material is None:
return
reply = QMessageBox.question(
self,
"Delete material",
self, "Delete material",
f"Delete material #{material.id} ({material.type})?\n"
"Elements that reference it will be invalid until reassigned.",
"Elements that reference it will be invalid until reassigned."
)
if reply != QMessageBox.StandardButton.Yes:
return

View file

@ -24,12 +24,12 @@ class MirrorDialog(QDialog):
def _build_ui(self, n_nodes: int, n_elements: int) -> None:
layout = QVBoxLayout(self)
layout.addWidget(
QLabel(f"Mirror <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s).")
)
layout.addWidget(
QLabel("<i>Only elements whose endpoints are both in the selection are copied.</i>")
)
layout.addWidget(QLabel(
f"Mirror <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s)."
))
layout.addWidget(QLabel(
"<i>Only elements whose endpoints are both in the selection are copied.</i>"
))
box = QGroupBox("Reflection plane (passes through origin)")
box_layout = QVBoxLayout(box)

View file

@ -44,13 +44,11 @@ class PathTimeSeriesDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.addWidget(
QLabel(
"<b>Path TimeSeries</b> — tabulated values sampled at a "
"uniform time step. Used by UniformExcitation (ground "
"motion) and by PlainLoadPattern scaled forces."
)
)
root.addWidget(QLabel(
"<b>Path TimeSeries</b> — tabulated values sampled at a "
"uniform time step. Used by UniformExcitation (ground "
"motion) and by PlainLoadPattern scaled forces."
))
form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion")
@ -67,7 +65,7 @@ class PathTimeSeriesDialog(QDialog):
self._factor_spin.setRange(-1e12, 1e12)
self._factor_spin.setDecimals(6)
self._factor_spin.setSingleStep(1.0)
self._factor_spin.setValue(386.4) # default: convert g → in/s²
self._factor_spin.setValue(386.4) # default: convert g → in/s²
self._factor_spin.setToolTip(
"Multiplier applied to every value at runtime. Typical use: "
"386.4 for ground motion in g → in/s² (US_IN_KIP), 9.81 for "
@ -86,7 +84,9 @@ class PathTimeSeriesDialog(QDialog):
btn_row.addWidget(self._btn_plain)
root.addLayout(btn_row)
self._status = QLabel("<i>No data loaded — use one of the import buttons.</i>")
self._status = QLabel(
"<i>No data loaded — use one of the import buttons.</i>"
)
self._status.setWordWrap(True)
self._status.setStyleSheet("color: #666;")
root.addWidget(self._status)
@ -102,7 +102,8 @@ class PathTimeSeriesDialog(QDialog):
root.addWidget(self._preview, 1)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
@ -111,16 +112,14 @@ class PathTimeSeriesDialog(QDialog):
# ── file-import slots ───────────────────────────────────────────
def _on_import_peer(self) -> None:
fname, _ = QFileDialog.getOpenFileName(
self,
"Import PEER record",
"",
"PEER records (*.at2 *.AT2);;All files (*)",
self, "Import PEER record",
"", "PEER records (*.at2 *.AT2);;All files (*)",
)
if not fname:
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
@ -133,21 +132,20 @@ class PathTimeSeriesDialog(QDialog):
def _on_import_plain(self) -> None:
fname, _ = QFileDialog.getOpenFileName(
self,
"Import plain values",
"",
"Text files (*.txt *.csv *.dat);;All files (*)",
self, "Import plain values",
"", "Text files (*.txt *.csv *.dat);;All files (*)",
)
if not fname:
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
self._status.setText(
f"Loaded <b>{len(vals)}</b> values from plain-text file. " "Set Δt manually above."
f"Loaded <b>{len(vals)}</b> values from plain-text file. "
"Set Δt manually above."
)
self._refresh_preview()
@ -169,9 +167,7 @@ class PathTimeSeriesDialog(QDialog):
def _on_accept(self) -> None:
if not self._values:
QMessageBox.warning(
self,
"No data",
"Import a record first.",
self, "No data", "Import a record first.",
)
return
self.accept()

View file

@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
@ -30,56 +31,39 @@ class QuickGridLinesDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.addWidget(
QLabel(
"<b>Quick-define a regular (Cartesian) grid.</b><br>"
"Each axis: number of lines, equal spacing, first line coordinate."
)
)
root.addWidget(QLabel(
"<b>Quick-define a regular (Cartesian) grid.</b><br>"
"Each axis: number of lines, equal spacing, first line coordinate."
))
self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root)
self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root)
self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root)
# Sensible defaults.
self._x_n.setValue(3)
self._x_s.setValue(6.0)
self._x_f.setValue(0.0)
self._y_n.setValue(3)
self._y_s.setValue(6.0)
self._y_f.setValue(0.0)
self._z_n.setValue(2)
self._z_s.setValue(3.0)
self._z_f.setValue(0.0)
self._x_n.setValue(3); self._x_s.setValue(6.0); self._x_f.setValue(0.0)
self._y_n.setValue(3); self._y_s.setValue(6.0); self._y_f.setValue(0.0)
self._z_n.setValue(2); self._z_s.setValue(3.0); self._z_f.setValue(0.0)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
def _axis_group(
self,
title: str,
parent_layout: QVBoxLayout,
self, title: str, parent_layout: QVBoxLayout,
) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]:
box = QGroupBox(title)
row = QHBoxLayout(box)
n = QSpinBox()
n.setRange(0, 200)
n.setValue(3)
s = QDoubleSpinBox()
s.setRange(0.0, 1e6)
s.setDecimals(4)
s.setSingleStep(0.5)
s.setValue(1.0)
f = QDoubleSpinBox()
f.setRange(-1e6, 1e6)
f.setDecimals(4)
f.setSingleStep(0.5)
f.setValue(0.0)
n = QSpinBox(); n.setRange(0, 200); n.setValue(3)
s = QDoubleSpinBox(); s.setRange(0.0, 1e6); s.setDecimals(4)
s.setSingleStep(0.5); s.setValue(1.0)
f = QDoubleSpinBox(); f.setRange(-1e6, 1e6); f.setDecimals(4)
f.setSingleStep(0.5); f.setValue(0.0)
row.addWidget(QLabel("Number of lines:"))
row.addWidget(n)
@ -92,10 +76,8 @@ class QuickGridLinesDialog(QDialog):
def ordinates(self) -> tuple[list[float], list[float], list[float]]:
"""Return (xs, ys, zs) — flat lists of ordinate values."""
def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]:
return [f.value() + i * s.value() for i in range(n.value())]
return (
axis(self._x_n, self._x_s, self._x_f),
axis(self._y_n, self._y_s, self._y_f),

View file

@ -29,7 +29,7 @@ with <b>Define → Add Plain Load Pattern…</b> or
<b>Define → Add Uniform Excitation…</b> for seismic input.</p>
<h4>4&nbsp;— Draw the model</h4>
<p>Tools toolbar (top): <b>Select</b>, <b>Draw Node</b> (F1),
<p>Tools toolbar (left): <b>Select</b>, <b>Draw Node</b> (F1),
<b>Draw Frame</b> (F2), <b>Draw Truss</b> (F3, icon-less by design).
<b>Define → Coordinate System/Grids…</b> (Ctrl+G) and <b>Add Node…</b> (Ctrl+N)
for precise layout; use the top-toolbar <b>Level</b> combo in Top/Front/Right views.</p>

View file

@ -42,12 +42,12 @@ class ReplicateDialog(QDialog):
def _build_ui(self, n_nodes: int, n_elements: int) -> None:
layout = QVBoxLayout(self)
layout.addWidget(
QLabel(f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s).")
)
layout.addWidget(
QLabel("<i>Only elements whose endpoints are both in the selection are copied.</i>")
)
layout.addWidget(QLabel(
f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s)."
))
layout.addWidget(QLabel(
"<i>Only elements whose endpoints are both in the selection are copied.</i>"
))
form = QFormLayout()

View file

@ -57,18 +57,6 @@ class RunAnalysisDialog(QDialog):
row.addWidget(self._case_combo, stretch=1)
layout.addLayout(row)
combo_row = QHBoxLayout()
combo_row.addWidget(QLabel("Combination:"))
self._combo_combo = QComboBox()
combo_row.addWidget(self._combo_combo, stretch=1)
self._eval_btn = QPushButton("Evaluate")
self._eval_btn.setToolTip(
"Run each referenced static case, then combine results "
"in post-processing (linear superposition, no extra solver input)."
)
combo_row.addWidget(self._eval_btn)
layout.addLayout(combo_row)
# Run-time damping overrides for transient cases only.
self._damping_box = QGroupBox("Rayleigh damping (transient only)")
form = QFormLayout(self._damping_box)
@ -91,14 +79,12 @@ class RunAnalysisDialog(QDialog):
self._mode1_damping.setSingleStep(0.01)
form.addRow("Mode-1 damping zeta:", self._mode1_damping)
form.addRow(
QLabel(
"<i>Values are applied for this run only via a copy of the case — "
"the stored case is not modified. If mode-1 damping is "
"greater than zero, the runner computes betaK from the first "
"mode after preload and overrides the manual betaK value.</i>",
)
)
form.addRow(QLabel(
"<i>Values are applied for this run only via a copy of the case — "
"the stored case is not modified. If mode-1 damping is "
"greater than zero, the runner computes betaK from the first "
"mode after preload and overrides the manual betaK value.</i>",
))
layout.addWidget(self._damping_box)
self._progress = QProgressBar()
@ -112,8 +98,7 @@ class RunAnalysisDialog(QDialog):
layout.addWidget(self._log, stretch=1)
self._buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Close,
parent=self,
QDialogButtonBox.StandardButton.Close, parent=self,
)
self._run_btn = QPushButton("Run")
self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole)
@ -121,7 +106,6 @@ class RunAnalysisDialog(QDialog):
def _wire(self) -> None:
self._run_btn.clicked.connect(self._on_run)
self._eval_btn.clicked.connect(self._on_evaluate_combo)
self._buttons.rejected.connect(self.reject)
self._case_combo.currentIndexChanged.connect(self._on_case_changed)
@ -149,21 +133,15 @@ class RunAnalysisDialog(QDialog):
def _populate_cases(self) -> None:
self._case_combo.clear()
self._combo_combo.clear()
if self._vm.project is None:
self._run_btn.setEnabled(False)
self._eval_btn.setEnabled(False)
self._damping_box.setVisible(False)
return
for case in self._vm.project.analyses:
label = f"#{case.id} {case.name or '(unnamed)'} [{case.type}]"
self._case_combo.addItem(label, userData=case.id)
for combo in self._vm.project.combinations:
label = f"#{combo.id} {combo.name or '(unnamed)'} [{combo.kind}]"
self._combo_combo.addItem(label, userData=combo.id)
has_cases = self._case_combo.count() > 0
self._run_btn.setEnabled(has_cases)
self._eval_btn.setEnabled(self._combo_combo.count() > 0)
if not has_cases:
# No transient case can be selected, so the run-only damping
# overrides are meaningless — hide them alongside the disabled Run.
@ -179,13 +157,11 @@ class RunAnalysisDialog(QDialog):
return
if case.type == "Transient":
mode1 = float(self._mode1_damping.value())
case = case.model_copy(
update={
"rayleigh_alpha_m": float(self._alpha_m.value()),
"rayleigh_beta_k": float(self._beta_k.value()),
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
}
)
case = case.model_copy(update={
"rayleigh_alpha_m": float(self._alpha_m.value()),
"rayleigh_beta_k": float(self._beta_k.value()),
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
})
self._results = None
self._log.clear()
results_dir: Path | None = None
@ -193,52 +169,9 @@ 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:
"""Run each referenced static case, then combine in post-processing.
Synchronous (linear statics are fast): each case runs through
:class:`OpenSeesRunner` on the GUI thread, then
:func:`evaluate_combination` superposes the results. The combined
output is forwarded through ``runner.finished`` so the main
window displays it like any other result.
"""
from otko.services.combinations import evaluate_combination
from otko.services.opensees_runner import OpenSeesRunner
if self._runner.is_running or self._vm.project is None:
return
combo_id = self._combo_combo.currentData()
project = self._vm.project
combo = next((c for c in project.combinations if c.id == combo_id), None)
if combo is None:
return
self._results = None
self._log.clear()
self._log.appendPlainText(f"Evaluating combination '{combo.name}' [{combo.kind}] ...")
try:
case_by_id = {c.id: c for c in project.analyses}
collected: dict[int, Any] = {}
for item in combo.items:
case = case_by_id.get(item.case_id)
if case is None:
raise ValueError(f"Combination references missing case id={item.case_id}.")
self._log.appendPlainText(
f" running case #{case.id} '{case.name}' × {item.factor:g} ..."
)
collected[item.case_id] = OpenSeesRunner(project).run(case)
combined = evaluate_combination(collected, combo)
self._results = combined
self._log.appendPlainText(f"--- Done. Returned {type(combined).__name__}. ---")
self._runner.finished.emit(combined)
except Exception as exc:
import traceback
self._log.appendPlainText("--- FAILED ---")
self._log.appendPlainText(f"{exc}\n{traceback.format_exc()}")
def _on_started(self) -> None:
self._log.appendPlainText("--- Analysis started ---")
@ -258,6 +191,4 @@ class RunAnalysisDialog(QDialog):
self._progress.setVisible(running)
# With zero cases Run must stay disabled even when idle.
self._run_btn.setEnabled((not running) and self._case_combo.count() > 0)
self._eval_btn.setEnabled((not running) and self._combo_combo.count() > 0)
self._case_combo.setEnabled(not running)
self._combo_combo.setEnabled(not running)

View file

@ -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 (
@ -44,31 +45,22 @@ from otko.services.section_properties import (
)
_PATCH_COLORS = [
"#4e79a7",
"#f28e2b",
"#e15759",
"#76b7b2",
"#59a14f",
"#edc948",
"#b07aa1",
"#ff9da7",
"#4e79a7", "#f28e2b", "#e15759", "#76b7b2",
"#59a14f", "#edc948", "#b07aa1", "#ff9da7",
]
class FiberSectionEditor(QDialog):
"""Modal dialog: build a FiberSection from patches + layers."""
def __init__(
self,
material_ids: list[int],
existing: FiberSection | None = None,
parent: QWidget | None = None,
) -> None:
def __init__(self, material_ids: list[int],
existing: FiberSection | None = None,
parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Fiber Section Editor")
self.resize(1000, 650)
self._material_ids = material_ids
self._patches: list[Any] = [] # RectangularPatch | CircularPatch
self._patches: list[Any] = [] # RectangularPatch | CircularPatch
self._layers: list[StraightLayer] = []
self._section_id: int = existing.id if existing else 1
self._section_name: str = existing.name if existing else ""
@ -114,37 +106,25 @@ class FiberSectionEditor(QDialog):
pf.addRow("Material:", self._patch_mat)
# Rect fields
self._rect_yi = self._spin(-0.15)
self._rect_zi = self._spin(-0.15)
self._rect_yj = self._spin(0.15)
self._rect_zj = self._spin(0.15)
self._rect_ny = self._ispin(8)
self._rect_nz = self._ispin(8)
self._rect_yi = self._spin(-0.15); self._rect_zi = self._spin(-0.15)
self._rect_yj = self._spin(0.15); self._rect_zj = self._spin(0.15)
self._rect_ny = self._ispin(8); self._rect_nz = self._ispin(8)
self._rect_rows = [
("y_i:", self._rect_yi),
("z_i:", self._rect_zi),
("y_j:", self._rect_yj),
("z_j:", self._rect_zj),
("n_fib_y:", self._rect_ny),
("n_fib_z:", self._rect_nz),
("y_i:", self._rect_yi), ("z_i:", self._rect_zi),
("y_j:", self._rect_yj), ("z_j:", self._rect_zj),
("n_fib_y:", self._rect_ny), ("n_fib_z:", self._rect_nz),
]
for label, widget in self._rect_rows:
pf.addRow(label, widget)
# Circ fields (initially hidden)
self._circ_yc = self._spin(0.0)
self._circ_zc = self._spin(0.0)
self._circ_ri = self._spin(0.0)
self._circ_ro = self._spin(0.15)
self._circ_nc = self._ispin(16)
self._circ_nr = self._ispin(4)
self._circ_yc = self._spin(0.0); self._circ_zc = self._spin(0.0)
self._circ_ri = self._spin(0.0); self._circ_ro = self._spin(0.15)
self._circ_nc = self._ispin(16); self._circ_nr = self._ispin(4)
self._circ_rows = [
("y_center:", self._circ_yc),
("z_center:", self._circ_zc),
("r_inner:", self._circ_ri),
("r_outer:", self._circ_ro),
("n_circ:", self._circ_nc),
("n_rad:", self._circ_nr),
("y_center:", self._circ_yc), ("z_center:", self._circ_zc),
("r_inner:", self._circ_ri), ("r_outer:", self._circ_ro),
("n_circ:", self._circ_nc), ("n_rad:", self._circ_nr),
]
for label, widget in self._circ_rows:
pf.addRow(label, widget)
@ -165,16 +145,12 @@ class FiberSectionEditor(QDialog):
lf.addRow("Material:", self._layer_mat)
self._layer_nbars = self._ispin(4)
self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12)
self._layer_ys = self._spin(-0.12)
self._layer_zs = self._spin(-0.12)
self._layer_ye = self._spin(0.12)
self._layer_ze = self._spin(-0.12)
self._layer_ys = self._spin(-0.12); self._layer_zs = self._spin(-0.12)
self._layer_ye = self._spin(0.12); self._layer_ze = self._spin(-0.12)
lf.addRow("n_bars:", self._layer_nbars)
lf.addRow("bar_area:", self._layer_area)
lf.addRow("y_start:", self._layer_ys)
lf.addRow("z_start:", self._layer_zs)
lf.addRow("y_end:", self._layer_ye)
lf.addRow("z_end:", self._layer_ze)
lf.addRow("y_start:", self._layer_ys); lf.addRow("z_start:", self._layer_zs)
lf.addRow("y_end:", self._layer_ye); lf.addRow("z_end:", self._layer_ze)
self._add_layer_btn = QPushButton("Add layer")
self._add_layer_btn.clicked.connect(self._on_add_layer)
lf.addRow(self._add_layer_btn)
@ -221,7 +197,8 @@ class FiberSectionEditor(QDialog):
# ── helpers ─────────────────────────────────────────────────────
@staticmethod
def _spin(default: float = 0.0, *, step: float = 0.01, minimum: float = -1e6) -> QDoubleSpinBox:
def _spin(default: float = 0.0, *, step: float = 0.01,
minimum: float = -1e6) -> QDoubleSpinBox:
sb = QDoubleSpinBox()
sb.setRange(minimum, 1e6)
sb.setDecimals(6)
@ -237,7 +214,7 @@ class FiberSectionEditor(QDialog):
return sb
def _on_patch_type_changed(self, idx: int) -> None:
is_rect = idx == 0
is_rect = (idx == 0)
for _, w in self._rect_rows:
w.setVisible(is_rect)
for _, w in self._circ_rows:
@ -249,38 +226,28 @@ class FiberSectionEditor(QDialog):
if self._patch_type.currentIndex() == 0:
p = RectangularPatch(
material_id=mid,
n_fib_y=self._rect_ny.value(),
n_fib_z=self._rect_nz.value(),
y_i=self._rect_yi.value(),
z_i=self._rect_zi.value(),
y_j=self._rect_yj.value(),
z_j=self._rect_zj.value(),
n_fib_y=self._rect_ny.value(), n_fib_z=self._rect_nz.value(),
y_i=self._rect_yi.value(), z_i=self._rect_zi.value(),
y_j=self._rect_yj.value(), z_j=self._rect_zj.value(),
)
self._patches.append(p)
self._item_list.addItem(
QListWidgetItem(
f"Rect patch mat={mid} "
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
f"{p.n_fib_y}×{p.n_fib_z}",
)
)
self._item_list.addItem(QListWidgetItem(
f"Rect patch mat={mid} "
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
f"{p.n_fib_y}×{p.n_fib_z}",
))
else:
p = CircularPatch(
material_id=mid,
n_fib_circ=self._circ_nc.value(),
n_fib_rad=self._circ_nr.value(),
y_center=self._circ_yc.value(),
z_center=self._circ_zc.value(),
r_inner=self._circ_ri.value(),
r_outer=self._circ_ro.value(),
n_fib_circ=self._circ_nc.value(), n_fib_rad=self._circ_nr.value(),
y_center=self._circ_yc.value(), z_center=self._circ_zc.value(),
r_inner=self._circ_ri.value(), r_outer=self._circ_ro.value(),
)
self._patches.append(p)
self._item_list.addItem(
QListWidgetItem(
f"Circ patch mat={mid} "
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
)
)
self._item_list.addItem(QListWidgetItem(
f"Circ patch mat={mid} "
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
))
self._refresh_preview()
def _on_add_layer(self) -> None:
@ -289,17 +256,13 @@ class FiberSectionEditor(QDialog):
material_id=mid,
n_bars=self._layer_nbars.value(),
bar_area=self._layer_area.value(),
y_start=self._layer_ys.value(),
z_start=self._layer_zs.value(),
y_end=self._layer_ye.value(),
z_end=self._layer_ze.value(),
y_start=self._layer_ys.value(), z_start=self._layer_zs.value(),
y_end=self._layer_ye.value(), z_end=self._layer_ze.value(),
)
self._layers.append(lay)
self._item_list.addItem(
QListWidgetItem(
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
)
)
self._item_list.addItem(QListWidgetItem(
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
))
self._refresh_preview()
def _on_remove(self) -> None:
@ -317,12 +280,14 @@ class FiberSectionEditor(QDialog):
# ── preview ─────────────────────────────────────────────────────
def _refresh_preview(self) -> None:
self._preview.clear()
sec = FiberSection(id=999999, patches=list(self._patches), layers=list(self._layers))
sec = FiberSection(id=999999, patches=list(self._patches),
layers=list(self._layers))
props = compute_section_props(sec)
if props.n_fibres == 0:
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:
@ -330,9 +295,8 @@ class FiberSectionEditor(QDialog):
for lay in self._layers:
mat_ids.append(lay.material_id)
unique_mats = sorted(set(mat_ids)) if mat_ids else [1]
mat_to_color = {
mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] for i, mid in enumerate(unique_mats)
}
mat_to_color = {mid: _PATCH_COLORS[i % len(_PATCH_COLORS)]
for i, mid in enumerate(unique_mats)}
# Draw patch fibres as squares, layer fibres as circles.
# Expand per-patch for color assignment.
@ -343,13 +307,9 @@ class FiberSectionEditor(QDialog):
continue
color = mat_to_color.get(p.material_id, "#888888")
self._preview.plot(
sub[:, 0],
sub[:, 1],
pen=None,
symbol="s",
symbolSize=6,
symbolBrush=color,
symbolPen=None,
sub[:, 0], sub[:, 1],
pen=None, symbol="s", symbolSize=6,
symbolBrush=color, symbolPen=None,
)
for lay in self._layers:
sub_sec = FiberSection(id=999999, layers=[lay])
@ -358,24 +318,16 @@ class FiberSectionEditor(QDialog):
continue
color = mat_to_color.get(lay.material_id, "#ff0000")
self._preview.plot(
sub[:, 0],
sub[:, 1],
pen=None,
symbol="o",
symbolSize=8,
symbolBrush=color,
symbolPen=pg.mkPen("#ffffff", width=1),
sub[:, 0], sub[:, 1],
pen=None, symbol="o", symbolSize=8,
symbolBrush=color, symbolPen=pg.mkPen("#ffffff", width=1),
)
# Centroid marker
self._preview.plot(
[props.centroid_y],
[props.centroid_z],
pen=None,
symbol="+",
symbolSize=16,
symbolBrush=None,
symbolPen=pg.mkPen("#ff0000", width=2),
[props.centroid_y], [props.centroid_z],
pen=None, symbol="+", symbolSize=16,
symbolBrush=None, symbolPen=pg.mkPen("#ff0000", width=2),
)
self._props_label.setText(
@ -389,22 +341,16 @@ class FiberSectionEditor(QDialog):
for p in sec.patches:
self._patches.append(p)
if isinstance(p, RectangularPatch):
self._item_list.addItem(
QListWidgetItem(
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
)
)
self._item_list.addItem(QListWidgetItem(
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
))
elif isinstance(p, CircularPatch):
self._item_list.addItem(
QListWidgetItem(
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
)
)
self._item_list.addItem(QListWidgetItem(
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
))
for lay in sec.layers:
if isinstance(lay, StraightLayer):
self._layers.append(lay)
self._item_list.addItem(
QListWidgetItem(
f"Layer mat={lay.material_id} {lay.n_bars} bars",
)
)
self._item_list.addItem(QListWidgetItem(
f"Layer mat={lay.material_id} {lay.n_bars} bars",
))

View file

@ -19,23 +19,15 @@ from PySide6.QtWidgets import (
)
from otko.core import (
AngleShape,
ElasticSection,
FiberSection,
PipeShape,
RectShape,
SectionAggregator,
)
def _spin(
default: float = 0.0,
*,
decimals: int = 8,
minimum: float = 1e-12,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
def _spin(default: float = 0.0, *, decimals: int = 8,
minimum: float = 1e-12, maximum: float = 1e15,
step: float = 1.0) -> QDoubleSpinBox:
sb = QDoubleSpinBox()
sb.setRange(minimum, maximum)
sb.setDecimals(decimals)
@ -97,13 +89,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:
@ -114,14 +107,9 @@ class ElasticSectionForm(SectionFormBase):
self._iy = _spin(8.33e-6, step=1e-7)
self._g = _spin(80e9, step=1e9)
self._j = _spin(1e-6, step=1e-7)
for label, w in (
("E:", self._e),
("A:", self._a),
("Iz:", self._iz),
("Iy:", self._iy),
("G:", self._g),
("J:", self._j),
):
for label, w in (("E:", self._e), ("A:", self._a),
("Iz:", self._iz), ("Iy:", self._iy),
("G:", self._g), ("J:", self._j)):
self._layout.addRow(label, w)
self._layout.addRow(QLabel("<i>Iy, G, J required for 3D models.</i>"))
@ -129,14 +117,14 @@ class ElasticSectionForm(SectionFormBase):
self._layout.addRow(QLabel("<i>Extruded-view shape (display only).</i>"))
self._shape = QComboBox()
self._shape.addItem("(none)", None)
for key in ("rect", "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 +150,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 +173,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`.
@ -231,12 +196,10 @@ class FiberSectionSummaryForm(SectionFormBase):
self._summary.setWordWrap(True)
self._summary.setStyleSheet("color: #555;")
self._layout.addRow(self._summary)
self._layout.addRow(
QLabel(
"<i>Edit this fiber section from the Section Library list — "
"Add / Modify uses the visual Fiber Section Editor.</i>"
)
)
self._layout.addRow(QLabel(
"<i>Edit this fiber section from the Section Library list — "
"Add / Modify uses the visual Fiber Section Editor.</i>"
))
self._cached: FiberSection | None = None
def _populate_specific(self, s: FiberSection) -> None:
@ -277,9 +240,9 @@ class SectionAggregatorSummaryForm(SectionFormBase):
def _populate_specific(self, s: SectionAggregator) -> None:
self._cached = s
pairings = (
"<br>".join(f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings) or "(none)"
)
pairings = "<br>".join(
f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings
) or "(none)"
self._summary.setText(
f"<b>{s.name or 'Section Aggregator'}</b><br>"
f"Wraps section: {s.section_id}<br>"
@ -305,9 +268,10 @@ def form_for(section: Any) -> SectionFormBase:
# Graceful fallback — unknown section types display a minimal
# placeholder instead of crashing the entire dialog.
form = SectionFormBase()
form._layout.addRow(
QLabel(f"<i>No form registered for section type " f"<b>{section.type}</b> yet.</i>")
)
form._layout.addRow(QLabel(
f"<i>No form registered for section type "
f"<b>{section.type}</b> yet.</i>"
))
form._section_id = section.id
form._name_edit.setText(getattr(section, "name", "") or "")
form._name_edit.setEnabled(False)

View file

@ -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,
@ -113,7 +113,6 @@ class SectionLibraryDialog(QDialog):
"""
from otko.core import FiberSection
from otko.views.dialogs.section_editor import FiberSectionEditor
sec = self._selected_section()
if not isinstance(sec, FiberSection) or self._vm.project is None:
return
@ -157,12 +156,7 @@ class SectionLibraryDialog(QDialog):
summary_kinds = {"FiberSection", "SectionAggregator"}
kinds = [k for k in FORM_REGISTRY if k not in summary_kinds]
kind, ok = QInputDialog.getItem(
self,
"Add section",
"Type:",
kinds,
current=0,
editable=False,
self, "Add section", "Type:", kinds, current=0, editable=False,
)
if not ok:
return
@ -182,7 +176,6 @@ class SectionLibraryDialog(QDialog):
if self._vm.project is None:
return
from otko.views.dialogs.section_editor import FiberSectionEditor
mat_ids = [m.id for m in self._vm.project.materials]
new_id = self._vm.project.next_section_id()
dlg = FiberSectionEditor(mat_ids, parent=self)
@ -203,10 +196,9 @@ class SectionLibraryDialog(QDialog):
if section is None:
return
reply = QMessageBox.question(
self,
"Delete section",
self, "Delete section",
f"Delete section #{section.id} ({section.type})?\n"
"Frame elements that reference it will be invalid until reassigned.",
"Frame elements that reference it will be invalid until reassigned."
)
if reply != QMessageBox.StandardButton.Yes:
return

View file

@ -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)"),
@ -50,12 +51,10 @@ class UniformExcitationDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.addWidget(
QLabel(
"<b>UniformExcitation</b> — apply a base ground motion "
"to every free node in the chosen DOF direction."
)
)
root.addWidget(QLabel(
"<b>UniformExcitation</b> — apply a base ground motion "
"to every free node in the chosen DOF direction."
))
form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion")
@ -85,13 +84,16 @@ class UniformExcitationDialog(QDialog):
self._factor_spin.setDecimals(6)
self._factor_spin.setSingleStep(0.1)
self._factor_spin.setValue(1.0)
self._factor_spin.setToolTip("Extra scale applied on top of the TimeSeries' own factor.")
self._factor_spin.setToolTip(
"Extra scale applied on top of the TimeSeries' own factor."
)
form.addRow("Factor:", self._factor_spin)
root.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)

View file

@ -11,7 +11,7 @@ from __future__ import annotations
from contextlib import suppress
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDockWidget, QMessageBox, QTreeWidget, QTreeWidgetItem
from PySide6.QtWidgets import QDockWidget, QMessageBox, QPlainTextEdit, QTreeWidget, QTreeWidgetItem
from otko.services.deformation import (
linear_static_auto_scale,
@ -34,7 +34,6 @@ from otko.services.results import (
)
from otko.views.canvas3d.model_renderer import RendererMode
from otko.views.docks import (
ConsoleDock,
DeformedShapeView,
ForceDiagramView,
HysteresisView,
@ -104,9 +103,13 @@ class DockManager:
self.tabifyDockWidget(props_dock, tree_dock)
tree_dock.raise_()
self._console_dock = ConsoleDock(self)
self._console = self._console_dock.view
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self._console_dock)
self._console = QPlainTextEdit()
self._console.setReadOnly(True)
self._console.setPlaceholderText("Logs and OpenSeesPy output will appear here.")
console_dock = QDockWidget("Console", self)
console_dock.setWidget(self._console)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, console_dock)
self._console_dock = console_dock
self._table = TableDock()
table_dock = QDockWidget("Table", self)
@ -119,7 +122,7 @@ class DockManager:
results_dock.setWidget(self._results_panel)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, results_dock)
self._results_dock = results_dock
self.tabifyDockWidget(self._console_dock, table_dock)
self.tabifyDockWidget(console_dock, table_dock)
self.tabifyDockWidget(table_dock, results_dock)
table_dock.raise_()

View file

@ -1,6 +1,5 @@
"""Reusable dock widget contents."""
from otko.views.docks.console import ConsoleDock
from otko.views.docks.deformed_shape import DeformedShapeView
from otko.views.docks.force_diagram import ForceDiagramView
from otko.views.docks.hysteresis import HysteresisView
@ -13,15 +12,8 @@ from otko.views.docks.table_dock import TableDock
from otko.views.docks.time_history import TimeHistoryView
__all__ = [
"PropertyEditorDock",
"ResultsPanel",
"TableDock",
"DeformedShapeView",
"ModeShapeAnimator",
"ForceDiagramView",
"TimeHistoryView",
"HysteresisView",
"PushoverCurveView",
"ResponseSpectrumView",
"ConsoleDock",
"PropertyEditorDock", "ResultsPanel", "TableDock",
"DeformedShapeView", "ModeShapeAnimator",
"ForceDiagramView", "TimeHistoryView", "HysteresisView",
"PushoverCurveView", "ResponseSpectrumView",
]

View file

@ -1,108 +0,0 @@
# Ported from otko-development (MIT), (c) 2026 OTKO contributors
"""The console dock: the timestamped analysis log."""
from __future__ import annotations
from datetime import datetime
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import (
QApplication,
QDockWidget,
QHBoxLayout,
QPlainTextEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
__all__ = ["ConsoleDock"]
#: Recognised severities; anything else is treated as information.
_SEVERITIES = ("info", "warning", "error", "success")
class ConsoleDock(QDockWidget):
"""Show analysis log lines with a timestamp and severity label.
The dock never decides anything about the model: it only renders text the
viewmodel or worker emits. Lines are appended, so the log is a faithful
running record of the session.
"""
def __init__(self, parent: QWidget | None = None) -> None:
"""Create the console dock with a read-only, monospaced log view."""
super().__init__("Console", parent)
self.setObjectName("consoleDock")
self._view = QPlainTextEdit(self)
self._view.setReadOnly(True)
self._view.setObjectName("consoleView")
self._view.setMaximumBlockCount(5000)
self._view.setPlaceholderText("Logs and OpenSeesPy output will appear here.")
self._view.setFont(self._monospace_font())
clear_button = QPushButton("Clear", self)
clear_button.setToolTip("Clear the console log")
clear_button.setStatusTip("Clear the console log")
clear_button.clicked.connect(self.clear)
copy_button = QPushButton("Copy All", self)
copy_button.setToolTip("Copy the whole log to the clipboard")
copy_button.setStatusTip("Copy the whole log to the clipboard")
copy_button.clicked.connect(self._copy_all)
buttons = QHBoxLayout()
buttons.addStretch(1)
buttons.addWidget(copy_button)
buttons.addWidget(clear_button)
layout = QVBoxLayout()
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(4)
layout.addWidget(self._view, 1)
layout.addLayout(buttons)
container = QWidget(self)
container.setLayout(layout)
self.setWidget(container)
# -- API -----------------------------------------------------------
def log(self, message: str, severity: str = "info") -> None:
"""Append ``message`` with a timestamp and ``severity`` tag.
Every line carries a wall-clock time and a severity so a failure is
easy to spot after a run.
"""
tag = severity if severity in _SEVERITIES else "info"
stamp = datetime.now().strftime("%H:%M:%S")
self._view.appendPlainText(f"{stamp} [{tag.upper():<7}] {message}")
def clear(self) -> None:
"""Remove every line from the log."""
self._view.clear()
@property
def text(self) -> str:
"""Return the full log text (used by tests and copy-all)."""
return self._view.toPlainText()
@property
def view(self) -> QPlainTextEdit:
"""Return the inner text view (for legacy append-only call sites)."""
return self._view
# -- Internals -----------------------------------------------------
def _copy_all(self) -> None:
"""Copy the whole log to the clipboard."""
clipboard = QApplication.clipboard()
if clipboard is not None:
clipboard.setText(self.text)
@staticmethod
def _monospace_font() -> QFont:
"""Return a fixed-width font so timestamps align."""
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
font.setPointSize(10)
return font

View file

@ -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 (
@ -31,7 +31,8 @@ class DeformedShapeView(QWidget):
scaleChanged = Signal(float)
closed = Signal()
def __init__(self, suggested_scale: float = 1.0, parent: QWidget | None = None) -> None:
def __init__(self, suggested_scale: float = 1.0,
parent: QWidget | None = None) -> None:
super().__init__(parent)
self._suggested = max(suggested_scale, 1e-6)
self._build_ui()
@ -40,9 +41,9 @@ class DeformedShapeView(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.addWidget(QLabel("<b>Deformed Shape</b>"))
layout.addWidget(
QLabel("<i>The slider scales displacement around the suggested factor.</i>")
)
layout.addWidget(QLabel(
"<i>The slider scales displacement around the suggested factor.</i>"
))
form = QFormLayout()
@ -52,8 +53,8 @@ class DeformedShapeView(QWidget):
# Multiplier ranging 0.1× — 10× the suggested scale.
self._slider = QSlider(Qt.Orientation.Horizontal)
self._slider.setRange(1, 1000) # represents 0.01 — 10.00
self._slider.setValue(100) # 1.00 ×
self._slider.setRange(1, 1000) # represents 0.01 — 10.00
self._slider.setValue(100) # 1.00 ×
self._slider.valueChanged.connect(self._on_slider)
self._spin = QDoubleSpinBox()

View file

@ -10,6 +10,8 @@ Exposes two signals:
from __future__ import annotations
import math
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
@ -29,11 +31,12 @@ from otko.services.element_forces import ForceComponent
class ForceDiagramView(QWidget):
"""Compact controls for live-updating an element-force diagram."""
componentChanged = Signal(object) # ForceComponent
changed = Signal(object, float) # (ForceComponent, scale)
componentChanged = Signal(object) # ForceComponent
changed = Signal(object, float) # (ForceComponent, scale)
closed = Signal()
def __init__(self, suggested_scale: float = 1.0, parent: QWidget | None = None) -> None:
def __init__(self, suggested_scale: float = 1.0,
parent: QWidget | None = None) -> None:
super().__init__(parent)
self._scale_base = max(suggested_scale, 1e-12)
self._pending_component_change = False
@ -84,7 +87,7 @@ class ForceDiagramView(QWidget):
self._slider = QSlider(Qt.Orientation.Horizontal)
self._slider.setRange(1, 1000)
self._slider.setValue(500) # midpoint = suggested scale
self._slider.setValue(500) # midpoint = suggested scale
form.addRow("", self._slider)
root.addWidget(group)

View file

@ -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

View file

@ -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,16 +26,14 @@ 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
# bridge between them.
_CONVERTIBLE_ELEMENT_TYPES = [
"Truss",
"CorotTruss",
"ElasticBeamColumn",
"ForceBeamColumn",
"DispBeamColumn",
"Truss", "CorotTruss",
"ElasticBeamColumn", "ForceBeamColumn", "DispBeamColumn",
]
@ -54,9 +53,7 @@ class PropertyEditorDock(QScrollArea):
self._layout.setContentsMargins(8, 8, 8, 8)
self.setWidget(self._inner)
self._project: Project | None = None
self.on_apply_mass: (
Callable[[int, tuple[float, float, float, float, float, float]], None] | None
) = None
self.on_apply_mass: Callable[[int, tuple[float, float, float, float, float, float]], None] | None = None
# Element callbacks — all optional; wired by MainWindow.
self.on_change_element_type: Callable[[int, str], None] | None = None
self.on_change_element_material: Callable[[int, int], None] | None = None
@ -124,12 +121,9 @@ class PropertyEditorDock(QScrollArea):
self._layout.addWidget(QLabel(f"<h3>Node #{node.id}</h3>"))
form = QFormLayout()
form.addRow("Name:", QLabel(node.name or "—"))
form.addRow(
"X, Y, Z:",
QLabel(
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
),
)
form.addRow("X, Y, Z:", QLabel(
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
))
form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint)))
self._layout.addLayout(form)
@ -185,7 +179,6 @@ class PropertyEditorDock(QScrollArea):
def _on_type_changed(new_type: str, _eid: int = el.id) -> None:
if new_type != el.type and self.on_change_element_type is not None:
self.on_change_element_type(_eid, new_type)
type_cb.currentTextChanged.connect(_on_type_changed)
form.addRow("Type:", type_cb)
else:
@ -195,19 +188,16 @@ class PropertyEditorDock(QScrollArea):
form.addRow("Nodes:", QLabel(", ".join(str(n) for n in el.nodes)))
# ── Section picker for frame elements. ──
if (
hasattr(el, "section_id")
and self._project is not None
and self._project.sections
and self.on_change_element_section is not None
):
if (hasattr(el, "section_id")
and self._project is not None
and self._project.sections
and self.on_change_element_section is not None):
sec_cb = QComboBox()
for s in self._project.sections:
sec_cb.addItem(
f"#{s.id} {s.name or s.type}",
s.id,
f"#{s.id} {s.name or s.type}", s.id,
)
idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
if idx >= 0:
sec_cb.setCurrentIndex(idx)
@ -215,23 +205,20 @@ class PropertyEditorDock(QScrollArea):
sid = sec_cb.currentData()
if sid is not None and self.on_change_element_section is not None:
self.on_change_element_section(_eid, int(sid))
sec_cb.currentIndexChanged.connect(_on_section_changed)
form.addRow("Section:", sec_cb)
elif hasattr(el, "section_id"):
form.addRow("Section id:", QLabel(str(el.section_id)))
# ── Material picker for truss / uniaxial-material elements. ──
if (
hasattr(el, "material_id")
and self._project is not None
and self._project.materials
and self.on_change_element_material is not None
):
if (hasattr(el, "material_id")
and self._project is not None
and self._project.materials
and self.on_change_element_material is not None):
mat_cb = QComboBox()
for m in self._project.materials:
mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id)
idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
if idx >= 0:
mat_cb.setCurrentIndex(idx)
@ -239,7 +226,6 @@ class PropertyEditorDock(QScrollArea):
mid = mat_cb.currentData()
if mid is not None and self.on_change_element_material is not None:
self.on_change_element_material(_eid, int(mid))
mat_cb.currentIndexChanged.connect(_on_material_changed)
form.addRow("Material:", mat_cb)
elif hasattr(el, "material_id"):
@ -250,12 +236,9 @@ class PropertyEditorDock(QScrollArea):
area_spin = QDoubleSpinBox()
area_spin.setRange(1e-12, 1e6)
area_spin.setDecimals(8)
area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
area_spin.setSingleStep(
float(el.area) * 0.1 # type: ignore[attr-defined]
if el.area
else 0.001
) # type: ignore[attr-defined]
area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
area_spin.setSingleStep(float(el.area) * 0.1 # type: ignore[attr-defined]
if el.area else 0.001) # type: ignore[attr-defined]
# Commit on editingFinished so we don't dispatch a command on
# every keystroke (which would spam the undo stack).
@ -272,7 +255,6 @@ class PropertyEditorDock(QScrollArea):
return
if self.on_change_element_fields is not None:
self.on_change_element_fields(_eid, {"area": new_val})
area_spin.editingFinished.connect(_on_area_edited)
form.addRow("Area:", area_spin)
elif hasattr(el, "area"):
@ -286,17 +268,13 @@ class PropertyEditorDock(QScrollArea):
def _show_multi(self, node_ids: frozenset[int], element_ids: frozenset[int]) -> None:
self._layout.addWidget(QLabel("<h3>Multi-selection</h3>"))
if node_ids:
self._layout.addWidget(
QLabel(
f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}",
)
)
self._layout.addWidget(QLabel(
f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}",
))
if element_ids:
self._layout.addWidget(
QLabel(
f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}",
)
)
self._layout.addWidget(QLabel(
f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}",
))
self._layout.addStretch(1)
@staticmethod
@ -310,6 +288,4 @@ class PropertyEditorDock(QScrollArea):
sorted_ids = sorted(ids)
if len(sorted_ids) <= limit:
return ", ".join(str(i) for i in sorted_ids)
return (
", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"
)
return ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"

View file

@ -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:

View file

@ -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
@ -35,8 +36,7 @@ class ResponseSpectrumView(QWidget):
self._build_ui()
def set_results(
self,
results: ResponseSpectrumResults | None,
self, results: ResponseSpectrumResults | None,
spectrum: ResponseSpectrum | None,
) -> None:
self._plot.clear()
@ -59,13 +59,9 @@ class ResponseSpectrumView(QWidget):
self._plot.plot(p_dense, a_dense, pen=pen, name="Sa(T)")
# Original control points.
self._plot.plot(
list(spectrum.periods),
list(spectrum.accelerations),
pen=None,
symbol="s",
symbolSize=7,
symbolBrush="#1f77b4",
symbolPen=None,
list(spectrum.periods), list(spectrum.accelerations),
pen=None, symbol="s", symbolSize=7,
symbolBrush="#1f77b4", symbolPen=None,
name="Control pts",
)
@ -81,19 +77,14 @@ class ResponseSpectrumView(QWidget):
# Slight vertical jitter for duplicate periods
y_offset = count * m.sa_at_period * 0.04
self._plot.plot(
[m.period],
[m.sa_at_period + y_offset],
pen=None,
symbol="o",
symbolSize=12,
symbolBrush="#d62728",
symbolPen=pg.mkPen("#ffffff", width=1),
[m.period], [m.sa_at_period + y_offset],
pen=None, symbol="o", symbolSize=12,
symbolBrush="#d62728", symbolPen=pg.mkPen("#ffffff", width=1),
name=f"Mode {m.mode_number}" if count == 0 else None,
)
# Small text label right next to the marker.
txt = pg.TextItem(
f" M{m.mode_number}",
color="#d62728",
f" M{m.mode_number}", color="#d62728",
anchor=(0.0, 0.5),
)
txt.setPos(m.period, m.sa_at_period + y_offset)
@ -142,17 +133,9 @@ class ResponseSpectrumView(QWidget):
splitter.addWidget(self._plot)
self._table = QTableWidget(0, 7)
self._table.setHorizontalHeaderLabels(
[
"Mode",
"T (s)",
"f (Hz)",
"Γ",
"M_eff",
"Mass %",
"Sa(T)",
]
)
self._table.setHorizontalHeaderLabels([
"Mode", "T (s)", "f (Hz)", "Γ", "M_eff", "Mass %", "Sa(T)",
])
self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch,
)

View file

@ -20,7 +20,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from otko.services.combinations import EnvelopeResults
from otko.services.results import (
ModalResults,
StaticResults,
@ -60,18 +59,7 @@ class ResultsPanel(QWidget):
"""
self._ndm, self._ndf = ndm, ndf
self._tabs.clear()
if isinstance(results, EnvelopeResults):
self._title.setText(
f"<b>Combination (Envelope) — #{results.case_id} "
f"'{results.case_name}'</b> ({results.n_steps} step(s))"
)
self._tabs.addTab(
self._build_envelope_table(results, "disp"), "Displacements (max/min)"
)
self._tabs.addTab(
self._build_envelope_table(results, "reaction"), "Reactions (max/min)"
)
elif isinstance(results, StaticResults):
if isinstance(results, StaticResults):
self._title.setText(
f"<b>Static — case #{results.case_id} '{results.case_name}'</b> "
f"({results.n_steps} step(s))"
@ -79,7 +67,9 @@ class ResultsPanel(QWidget):
self._tabs.addTab(self._build_static_disp_table(results), "Displacements")
self._tabs.addTab(self._build_static_reaction_table(results), "Reactions")
elif isinstance(results, ModalResults):
self._title.setText(f"<b>Modal — case #{results.case_id} '{results.case_name}'</b>")
self._title.setText(
f"<b>Modal — case #{results.case_id} '{results.case_name}'</b>"
)
self._tabs.addTab(self._build_modal_table(results), "Frequencies")
elif isinstance(results, TransientResults):
self._title.setText(
@ -119,28 +109,6 @@ class ResultsPanel(QWidget):
self._set_cell(table, i, j + 1, f"{val:.6g}")
return self._wrap(table, "Final-step reactions")
def _build_envelope_table(self, r: EnvelopeResults, kind: str) -> QWidget:
"""Max/min table for envelope ``kind`` (``"disp"`` or ``"reaction"``)."""
max_map = r.node_disp_max if kind == "disp" else r.node_reaction_max
min_map = r.node_disp_min if kind == "disp" else r.node_reaction_min
rows = sorted(max_map.keys())
if not rows:
return self._empty_table_widget()
ndf = max_map[rows[0]].shape[1]
dofs = dof_labels(ndf, self._ndm, self._ndf, kind="disp" if kind == "disp" else "force")
headers = ["Node", *[f"{d} max" for d in dofs], *[f"{d} min" for d in dofs]]
table = self._make_table(headers, len(rows))
for i, nid in enumerate(rows):
self._set_cell(table, i, 0, str(nid))
for j, val in enumerate(max_map[nid][-1]):
self._set_cell(table, i, j + 1, f"{val:.6g}")
for j, val in enumerate(min_map[nid][-1]):
self._set_cell(table, i, j + 1 + ndf, f"{val:.6g}")
caption = (
"Final-step displacement envelope" if kind == "disp" else "Final-step reaction envelope"
)
return self._wrap(table, caption)
def _build_modal_table(self, r: ModalResults) -> QWidget:
n = len(r.eigenvalues)
headers = ["Mode", "Eigenvalue (rad²/s²)", "ω (rad/s)", "f (Hz)", "T (s)"]
@ -156,17 +124,15 @@ class ResultsPanel(QWidget):
def _transient_summary(self, r: TransientResults) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(
QLabel(
f"<b>Steps:</b> {r.n_steps}<br>"
f"<b>dt:</b> {r.dt:g}<br>"
f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>"
f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
)
)
layout.addWidget(
QLabel("<i>Time-history plots and animation will appear here in Phase 7.</i>")
)
layout.addWidget(QLabel(
f"<b>Steps:</b> {r.n_steps}<br>"
f"<b>dt:</b> {r.dt:g}<br>"
f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>"
f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
))
layout.addWidget(QLabel(
"<i>Time-history plots and animation will appear here in Phase 7.</i>"
))
layout.addStretch(1)
return w

View file

@ -35,7 +35,6 @@ from PySide6.QtWidgets import (
from otko.commands import (
AddAnalysisCasesCommand,
AddCombinationsCommand,
AddElementLoadsCommand,
AddElementsCommand,
AddEqualDOFConstraintCommand,
@ -48,7 +47,6 @@ from otko.commands import (
AssignMaterialCommand,
AssignSectionCommand,
DeleteAnalysisCasesCommand,
DeleteCombinationsCommand,
DeleteElementLoadsCommand,
DeleteElementsCommand,
DeleteEqualDOFConstraintCommand,
@ -61,7 +59,6 @@ from otko.commands import (
SetMassCommand,
SetRestraintCommand,
UpdateAnalysisCaseCommand,
UpdateCombinationCommand,
UpdateElementFieldsCommand,
UpdateElementLoadCommand,
UpdateEqualDOFConstraintCommand,
@ -78,8 +75,6 @@ from otko.core import (
ElasticUniaxial,
EqualDOFConstraint,
LinearTimeSeries,
LoadCombination,
LoadCombinationItem,
NodalLoad,
Node,
PlainLoadPattern,
@ -174,7 +169,6 @@ _ANALYSIS_ATTRS = [
"tolerance",
"max_iter",
]
_COMBO_HEADERS = ["id", "name", "kind", "cases", "factors"]
_TAB_ORDER = (
"Nodes",
@ -187,7 +181,6 @@ _TAB_ORDER = (
"Element Loads",
"Constraints",
"Analyses",
"Combinations",
)
@ -197,7 +190,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)
@ -277,7 +270,6 @@ class TableDock(QWidget):
"Element Loads": self._load_element_loads,
"Constraints": self._load_constraints,
"Analyses": self._load_analyses,
"Combinations": self._load_combinations,
}
for name in _TAB_ORDER:
page = QWidget()
@ -425,7 +417,6 @@ class TableDock(QWidget):
"Element Loads": self._commit_element_load,
"Constraints": self._commit_constraint,
"Analyses": self._commit_analysis,
"Combinations": self._commit_combination,
}[tab]
handler(self._tables[tab], item)
except (ValueError, KeyError, IndexError) as exc:
@ -1035,7 +1026,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 "—"
@ -1104,49 +1095,6 @@ class TableDock(QWidget):
updated = self._dry_run(case, {attr: value})
self._dispatch(UpdateAnalysisCaseCommand(vm, updated))
# ── Combinations ───────────────────────────────────────────────
def _load_combinations(self, table: QTableWidget) -> None:
combos = list(self._project.combinations) if self._project is not None else []
table.setColumnCount(len(_COMBO_HEADERS))
table.setHorizontalHeaderLabels(_COMBO_HEADERS)
table.setRowCount(len(combos))
for r, combo in enumerate(combos):
self._put(table, r, 0, str(combo.id), editable=False, data=combo.id)
self._put(table, r, 1, combo.name, data=combo.id)
self._put(table, r, 2, combo.kind, data=combo.id)
self._put(
table,
r,
3,
", ".join(str(i.case_id) for i in combo.items),
editable=False,
data=combo.id,
)
self._put(
table,
r,
4,
", ".join(f"{i.factor:g}" for i in combo.items),
editable=False,
data=combo.id,
)
def _commit_combination(self, table: QTableWidget, item: QTableWidgetItem) -> None:
vm, project = self._bound()
row, col = item.row(), item.column()
cid = self._row_key(table, row)
combo = next(c for c in project.combinations if c.id == cid)
if col == 1:
updated = self._dry_run(combo, {"name": item.text()})
elif col == 2:
raw = item.text().strip()
if raw not in ("Linear", "Envelope"):
raise ValueError("kind: expected 'Linear' or 'Envelope'.")
updated = self._dry_run(combo, {"kind": raw})
else:
return
self._dispatch(UpdateCombinationCommand(vm, updated))
# ── Add / Delete rows ──────────────────────────────────────────
def _require_project(self, tab: str) -> Any | None:
if self._vm is None or self._project is None:
@ -1244,16 +1192,6 @@ class TableDock(QWidget):
pattern_ids=[project.load_patterns[0].id],
)
self._dispatch(AddAnalysisCasesCommand(vm, [case]))
elif tab == "Combinations":
if not project.analyses:
self._say("Combinations: define an analysis case first.")
return
combo = LoadCombination(
id=project.next_combination_id(),
name="New",
items=[LoadCombinationItem(case_id=project.analyses[0].id)],
)
self._dispatch(AddCombinationsCommand(vm, [combo]))
except Exception as exc:
label = tab[:-1] if tab.endswith("s") else tab
self._say(f"Add {label} failed: {exc}")
@ -1326,8 +1264,6 @@ class TableDock(QWidget):
self._dispatch(DeleteLoadPatternCommand(vm, ids))
elif tab == "Analyses":
self._dispatch(DeleteAnalysisCasesCommand(vm, ids))
elif tab == "Combinations":
self._dispatch(DeleteCombinationsCommand(vm, ids))
except Exception as exc:
self._say(f"Delete {tab} failed: {exc}")
self.refresh()

View file

@ -10,9 +10,9 @@ runner to record additional series (planned for a later phase).
from __future__ import annotations
import contextlib
from typing import Any
import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
@ -208,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()

View file

@ -1,97 +0,0 @@
# Ported from otko-development (MIT), (c) 2026 OTKO contributors
"""Small presentation helpers shared by docks and dialogs.
Keeps labels, unit hints and numeric parsing in one place so every surface
names a field the same way and rejects bad input with the same message shape.
Pure helpers only — no Qt imports live here.
"""
from __future__ import annotations
from otko.core import UnitLabels, UnitSystem, labels_for
__all__ = [
"DOF_LABELS",
"MASS_LABELS",
"RESTRAINT_LABELS",
"format_number",
"parse_float",
"parse_int",
"unit_label",
]
#: Six translational/rotational DOF labels, indexed 0-5.
DOF_LABELS: tuple[str, ...] = ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz")
#: Six mass-component labels, indexed 0-5.
MASS_LABELS: tuple[str, ...] = ("Mx", "My", "Mz", "Ixx", "Iyy", "Izz")
#: Human-readable restraint component names (restraint + rotation).
RESTRAINT_LABELS: tuple[str, ...] = (
"Ux",
"Uy",
"Uz",
"Rx",
"Ry",
"Rz",
)
#: Map a public quantity name to the :class:`UnitLabels` field that holds it.
_QUANTITY_FIELDS: dict[str, str] = {
"length": "length",
"force": "force",
"moment": "moment",
"stress": "stress",
"curvature": "curvature",
"rotation": "rotation",
}
def format_number(value: float) -> str:
"""Return a compact, round-trippable string for ``value``.
Integral floats lose the decimal point; everything else keeps up to six
significant digits so a round-trip through a table cell is exact enough
for engineering values.
"""
if value == int(value):
return str(int(value))
return f"{value:.6g}"
def parse_float(text: str, field: str, *, allow_empty: bool = False) -> float:
"""Parse ``text`` as a float or raise ``ValueError`` naming ``field``.
An empty string is accepted when ``allow_empty`` is set and yields ``0.0``
so a blank optional cell is not treated as an error.
"""
stripped = text.strip()
if not stripped:
if allow_empty:
return 0.0
raise ValueError(f"{field} is required")
try:
return float(stripped)
except ValueError:
raise ValueError(f"{field} must be a number, got {stripped!r}") from None
def parse_int(text: str, field: str, *, allow_empty: bool = False) -> int:
"""Parse ``text`` as an int or raise ``ValueError`` naming ``field``."""
value = parse_float(text, field, allow_empty=allow_empty)
if value != int(value):
raise ValueError(f"{field} must be a whole number, got {text.strip()!r}")
return int(value)
def unit_label(quantity: str, system: UnitSystem) -> str:
"""Return the display label for ``quantity`` in ``system``.
``quantity`` is one of ``length``, ``force``, ``moment``, ``stress``,
``curvature`` or ``rotation``; anything else raises ``ValueError``.
"""
field = _QUANTITY_FIELDS.get(quantity)
if field is None:
raise ValueError(f"unknown unit quantity: {quantity!r}")
labels: UnitLabels = labels_for(system)
return getattr(labels, field)

View file

@ -15,9 +15,8 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QSettings
from PySide6.QtGui import QCloseEvent, QIcon
from PySide6.QtWidgets import QMainWindow, QMessageBox
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMainWindow
from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers
@ -75,7 +74,6 @@ class MainWindow(
self._build_status_bar()
self._wire()
self._refresh_action_enablement()
self.restore_layout()
# ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None:
@ -84,54 +82,6 @@ class MainWindow(
# 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."""
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.
On first run no values are stored, so the defaults from ``__init__``
(``resize`` + dock construction) stay in effect.
"""
settings = QSettings("OTKO", "OTKO")
geometry = settings.value("geometry")
if geometry is not None:
self.restoreGeometry(geometry)
state = settings.value("windowState")
if state is not None:
self.restoreState(state)
def closeEvent(self, event: QCloseEvent) -> None: # (Qt override)
"""Prompt to save unsaved work, then persist the window layout.
The prompt is shown only for a visible window: Qt also closes hidden
widgets during teardown (for example under ``pytest-qt``), where a
modal dialog would block with no user to answer it.
"""
if self._vm.is_dirty and self.isVisible():
buttons = QMessageBox.StandardButton
choice = QMessageBox.warning(
self,
"Unsaved changes",
"Save changes to the current project before closing?",
buttons.Save | buttons.Discard | buttons.Cancel,
buttons.Save,
)
if choice == buttons.Cancel:
event.ignore()
return
if choice == buttons.Save:
self._on_save()
if self._vm.is_dirty:
event.ignore()
return
self.save_layout()
event.accept()
# ── wiring ────────────────────────────────────────────────────────
def _wire(self) -> None:
# File
@ -191,7 +141,6 @@ class MainWindow(
# Analyze
self._act_case_manager.triggered.connect(self._on_case_manager)
self._act_combo_manager.triggered.connect(self._on_combo_manager)
self._act_run.triggered.connect(self._on_run_analysis)
# Display
@ -207,7 +156,7 @@ class MainWindow(
self._act_back_to_model.triggered.connect(self._on_back_to_model)
# AnalysisRunner: stream log to console + show results in panel
self._runner.log.connect(self._console_dock.log)
self._runner.log.connect(self._console.appendPlainText)
self._runner.finished.connect(self._on_analysis_finished)
self._runner.failed.connect(self._on_analysis_failed)

View file

@ -155,7 +155,6 @@ class MenuBuilder:
# Analyze
self._act_case_manager = QAction("&Cases…", self, shortcut="Ctrl+Shift+A")
self._act_combo_manager = QAction("&Load Combinations…", self)
self._act_run = QAction("&Run…", self, shortcut="F5")
# View (Iso/Top/Front/Right are an exclusive checkable group so the
@ -286,11 +285,6 @@ class MenuBuilder:
"Analysis Cases (Ctrl+Shift+A)",
"Manage analysis cases.",
),
"_act_combo_manager": (
"cases",
"Load Combinations",
"Manage named case-result load combinations.",
),
"_act_show_deformed": (
"show-deformed",
"Show Deformed Shape",
@ -426,7 +420,6 @@ class MenuBuilder:
m_analyze = mb.addMenu("&Analyze")
m_analyze.addAction(self._act_case_manager)
m_analyze.addAction(self._act_combo_manager)
m_analyze.addSeparator()
m_analyze.addAction(self._act_run)
@ -489,7 +482,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)
@ -535,8 +528,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)

View file

@ -496,5 +496,5 @@ class RenderControls:
self._act_clear_display.setEnabled(has_project)
def _log(self, message: str) -> None:
self._console_dock.log(message)
self._console.appendPlainText(message)
self.statusBar().showMessage(message, 5000)

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